diff --git a/.env.example b/.env.example index c809f7653c..20c43643a2 100644 --- a/.env.example +++ b/.env.example @@ -229,6 +229,15 @@ PORT=20128 # unaffected by this dev-only flag). OMNIROUTE_USE_TURBOPACK=1 +# Disable systemd sd_notify (Type=notify / WatchdogSec=) even when running +# under a systemd unit with NOTIFY_SOCKET set. +# Used by: scripts/dev/systemd-notify.mjs. Set to 1 to disable. +# OMNIROUTE_DISABLE_SD_NOTIFY=1 + +# Injected by systemd when running under a service unit (sd_notify protocol). +# Read by scripts/dev/systemd-notify.mjs — never set this yourself. +# NOTIFY_SOCKET=/run/systemd/notify + # Skip the SQLite integrity health check on startup (faster boot on large DBs). # Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip. # OMNIROUTE_SKIP_DB_HEALTHCHECK=1 @@ -408,6 +417,15 @@ ALLOW_API_KEY_REVEAL=false # by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive # value only on memory-constrained deployments that need a hard ceiling. # OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0 + +# Skip OmniRoute's local context-window and max-input-token check for direct +# single-model requests. Default: false (dangerous opt-in). +# The upstream provider still enforces its real limits, so enabling this can +# replace an early OmniRoute 400 with an upstream context-length error. +# Prompt compression and the model's own output-token cap remain active. +# Also configurable from Dashboard > Settings > Feature Flags; no restart is +# required. Used by: src/shared/utils/featureFlags.ts and open-sse/handlers/chatCore.ts. +# DISABLE_CONTEXT_WINDOW_CHECKS=false # How long a heavy request waits for heavyweight capacity before a retryable 503. # A short bounded wait serializes agent bursts instead of an instant 503; 0 = instant. # Default 2000 (2s). @@ -696,6 +714,11 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # ALL_PROXY=socks5://127.0.0.1:7890 # NO_PROXY=localhost,127.0.0.1 +# Pin the echo-IP target used by proxy egress probes. Unset, the probe tries +# api64.ipify.org then api4.ipify.org so IPv4-only tunnels are not reported dead. +# Used by: src/lib/proxyEchoTarget.ts. +# OMNIROUTE_PROXY_ECHO_URL=https://api4.ipify.org?format=json + # Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher. # Long-lived SSE streams such as Codex /v1/responses need more than one # connection when multiple requests share the same account-level proxy. @@ -876,13 +899,21 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Set to 0/false/off to skip compression entirely. Default: rtk # OMNIROUTE_MCP_DESCRIPTION_COMPRESSION=rtk +# Abort budget (ms) for MCP-server internal management reads (health, resilience, +# combos, quota, usage). Default: 10000. Used by: open-sse/mcp-server/fetchTimeout.ts +# OMNIROUTE_MCP_FETCH_TIMEOUT_MS=10000 + +# Abort budget (ms) for MCP hops that wait on a provider (route_request, web_search, +# web_fetch). Default: 60000. Used by: open-sse/mcp-server/fetchTimeout.ts +# OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS=60000 + # Model catalog sync interval in hours. # Used by: src/shared/services/modelSyncScheduler.ts — periodic model refresh. # Default: 24 # MODEL_SYNC_INTERVAL_HOURS=24 # Provider limits sync interval in minutes (rate limit windows, quotas). -# Used by: src/server-init.ts — polls provider health endpoints. +# Used by: src/lib/usage/providerLimits.ts — polls provider health endpoints. # Default: 70 PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70 @@ -1033,6 +1064,10 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500 # Used by: src/lib/db/core.ts::getDbHealthCheckIntervalMs(). #OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS=21600000 +# WAL truncate cadence override (ms). Set to 0 to disable. Default: 21600000 (6h). +# Used by: src/lib/db/core.ts::getWalTruncateIntervalMs(). +#OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS=21600000 + # Skip the Redis-backed auth cache used by API key lookups (forces DB reads). # Used by: src/lib/db/apiKeys.ts. Set to 1 to disable. Default: enabled. #OMNIROUTE_DISABLE_REDIS_AUTH_CACHE=0 @@ -1348,6 +1383,14 @@ CURSOR_USER_AGENT="Cursor/3.4" # FETCH_BODY_TIMEOUT_MS=600000 # Time to receive full response body # FETCH_CONNECT_TIMEOUT_MS=30000 # TCP connection establishment (default: 30s) # FETCH_KEEPALIVE_TIMEOUT_MS=4000 # Keep-alive socket idle timeout (default: 4s) +# OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS=30000 # Bounded response-start window per direct +# # (no-proxy) attempt (#10214). A silently-dropped +# # pooled keep-alive socket surfaces no transport +# # error, so without this bound a direct request can +# # stall until undici's headersTimeout (600s) or the +# # caller's deadline; on expiry the request retries +# # once on a fresh no-keep-alive socket. 0 disables +# # the bound (default: 30000 = 30s). # Default timeout (ms) for src/shared/utils/fetchTimeout.ts. Acts as the # fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min). @@ -1402,6 +1445,14 @@ CURSOR_USER_AGENT="Cursor/3.4" # OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000 # OMNIROUTE_PPLX_TLS_GRACE_MS=10000 +# ── Perplexity web: built-in-search hint ── +# Used by: open-sse/executors/perplexity-web/protocol.ts — appends "You have +# built-in web search. Answer questions directly using search results." to the +# caller's system message. Off by default: Perplexity's answer engine searches +# anyway, and for coding clients the sentence leaks into replies as +# meta-commentary. Set to 1/true/yes/on to restore the old behavior. +# OMNIROUTE_PPLX_SEARCH_HINT=0 + # ── Grok web TLS sidecar (Chrome-fingerprinted client) ── # Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the # bogdanfinn/tls-client koffi binding and the JS-side grace window layered on @@ -1975,6 +2026,16 @@ APP_LOG_TO_FILE=true # Reachability probe target for the scheduler and the auto-test endpoint. # Point it at an internal/self-hosted URL to avoid the public default. # PROXY_HEALTH_TEST_URL=https://httpbin.org/ip +# Probes started at once per batch, for the scheduler and the auto-test endpoint. +# Floored at 1 and capped at 50. Default: 10. +# PROXY_HEALTH_TEST_CONCURRENCY=10 +# Delay in ms between two probe departures inside a batch. Without it the whole batch +# leaves at once and a shared egress IP can trip a rate-limited target. 0 disables the +# spacing; capped at 5000. Default: 100. +# PROXY_HEALTH_TEST_STAGGER_MS=100 +# Set "false" to stop probing the real host of a proxy's assigned provider (GET /models, +# no API key) and always use the generic target above instead. Default: enabled. +# PROXY_HEALTH_USE_PROVIDER_TARGET=true # Set "true" to let the scheduler auto-remove proxies after repeated failures. # PROXY_AUTO_REMOVE=false # Consecutive failures before an auto-remove fires. Default: 3. @@ -2117,6 +2178,19 @@ APP_LOG_TO_FILE=true # Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: detect local install, else pin. # CURSOR_AGENT_CLI_VERSION=2026.07.08-0c04a8a +# Path to the Cursor Agent binary used for image generation. +# Used by: open-sse/handlers/imageGeneration/providers (CURSOR_IMAGE.md). +# CURSOR_AGENT_BIN=/path/to/agent + +# Cursor image-generation wall clock (ms). Default: 210000. +# CURSOR_IMG_TIMEOUT_MS=210000 + +# Shared-seat concurrency gate for Cursor image jobs. Default: 2. +# CURSOR_IMG_MAX_CONCURRENT=2 + +# Override Cursor CLI --model for image jobs. Default: request model / auto. +# CURSOR_IMG_MODEL=auto + # Cursor Agent CLI data directory override (versions live under /versions/). # Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: ~/.local/share/cursor-agent (unix) # or %LOCALAPPDATA%\cursor-agent (win32). Official agent CLI also honors this var. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8db8504007..3dfad903a7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -50,13 +50,13 @@ updates: # bumps; majors here need their own PR and a deliberate migration review. - dependency-name: "ioredis" update-types: ["version-update:semver-major"] - # @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN. - # It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/ - # compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2) - # and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts), - # and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must - # be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore - # every version). Migrate it intentionally, not via dependabot (#4050). + # @huggingface/transformers is VPS-validated at ^4.2.0 (migrated intentionally in + # #9962). It is load-bearing for the LLMLingua ONNX compression engine (open-sse/ + # services/compression/engines/llmlingua/ — @atjsh/llmlingua-2@2.0.5 peers on + # "@huggingface/transformers": "^3.5.2 || ^4.0.0") and for local memory embeddings + # (src/lib/memory/embedding/transformersLocal.ts). Further majors must be re-validated + # on the VPS — so keep auto-bumps frozen (no update-types = ignore every version). + # Migrate it intentionally, not via dependabot (#4050). - dependency-name: "@huggingface/transformers" - package-ecosystem: "github-actions" diff --git a/.github/workflows/radar-export.yml b/.github/workflows/radar-export.yml new file mode 100644 index 0000000000..043de88d3d --- /dev/null +++ b/.github/workflows/radar-export.yml @@ -0,0 +1,64 @@ +# Publica o export estável do catálogo consumido pelo OmniRoute Radar numa URL +# fixa (asset de release `radar-export-latest`), para o servidor privado do Radar +# (1 GB RAM, nunca clona/builda o OmniRoute) baixá-lo via `RADAR_EXPORT_URL` em +# vez de depender do snapshot gravado no deploy. Fonte: scripts/release/radar-export.mjs. +# +# A URL estável resultante (definir em RADAR_EXPORT_URL no .env do radar-server): +# https://github.com/diegosouzapw/OmniRoute/releases/download/radar-export-latest/export-omniroute.json +name: Radar Export + +on: + workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref) + push: + branches: [main] # produção: só o catálogo do main clobra o asset estável + paths: + - open-sse/config/freeModelCatalog.data.ts + - open-sse/config/freeModelCatalog.ts + - open-sse/config/providerRegistry.ts + - open-sse/config/providers/** + - scripts/release/radar-export.mjs + - .github/workflows/radar-export.yml + schedule: + - cron: "17 6 * * 1" # semanal (segunda 06:17 UTC): mantém geradoEm/proveniência frescos + +permissions: + contents: read + +concurrency: + group: radar-export-${{ github.ref }} + cancel-in-progress: true + +env: + CI_NODE_VERSION: "24" + +jobs: + publish-export: + runs-on: ubuntu-latest + permissions: + contents: write # gh release upload — clobra o asset estável do export + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false # publish usa GH_TOKEN via gh release, não a credencial do checkout + - uses: actions/setup-node@v7 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - name: Generate catalog export with provenance + run: node --import tsx/esm scripts/release/radar-export.mjs "$RUNNER_TEMP/export-omniroute.json" + - name: Publish to the stable release asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="radar-export-latest" + # Cria o release estável na primeira vez; nas seguintes só re-anexa o asset. + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Radar catalog export (rolling)" \ + --notes "Export estável do catálogo OmniRoute para o Radar. Atualizado automaticamente; NÃO é um release de versão do produto." \ + --latest=false + fi + gh release upload "$TAG" "$RUNNER_TEMP/export-omniroute.json" --repo "$GITHUB_REPOSITORY" --clobber diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index bc18518cad..7c196aeccb 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -1294,10 +1294,15 @@ export function mapRawModelToModelV2( // `(providerID, modelID)`. If the raw id is already provider-prefixed // (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or // `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave - // it as-is — double-prefixing breaks OC's lookup. Otherwise prefix with - // the resolved `providerId` so a bare key like `claude-opus-4` parses as - // `(omniroute, claude-opus-4)` and the credentials resolve correctly. - id: raw.id.includes("/") ? raw.id : `${ctx.providerId}/${raw.id}`, + // it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids + // (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed: + // OpenCode looks up `-m /` as model id `` under + // the plugin provider (#10345). Other bare ids still prefix with + // `providerId` so credentials resolve as `(omniroute, model)`. + id: + raw.id.includes("/") || raw.owned_by === "combo" + ? raw.id + : `${ctx.providerId}/${raw.id}`, /** * Display name. Falls back to raw.id when no enrichment is available; * the caller (`createOmniRouteProviderHook`) overlays diff --git a/@omniroute/opencode-plugin/tests/bare-combo-ids-10345.test.ts b/@omniroute/opencode-plugin/tests/bare-combo-ids-10345.test.ts new file mode 100644 index 0000000000..f7afda9ab6 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/bare-combo-ids-10345.test.ts @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { mapRawModelToModelV2 } from "../src/index.ts"; + +test("mapRawModelToModelV2: bare combo ids stay unprefixed (#10345)", () => { + const combo = mapRawModelToModelV2( + { + id: "gpt-5.6-sol", + owned_by: "combo", + context_length: 272000, + max_output_tokens: 8192, + }, + { providerId: "omniroute", baseURL: "https://or.example.com/v1" } + ); + assert.equal(combo.id, "gpt-5.6-sol"); + assert.equal(combo.providerID, "omniroute"); + + const slashed = mapRawModelToModelV2( + { + id: "cx/gpt-5.6-sol", + owned_by: "combo", + context_length: 272000, + }, + { providerId: "omniroute", baseURL: "https://or.example.com/v1" } + ); + assert.equal(slashed.id, "cx/gpt-5.6-sol"); + + const ordinary = mapRawModelToModelV2( + { id: "claude-primary", context_length: 200000 }, + { providerId: "omniroute", baseURL: "https://or.example.com/v1" } + ); + assert.equal(ordinary.id, "omniroute/claude-primary"); +}); diff --git a/AGENTS.md b/AGENTS.md index b1bcfebafd..d4a7e7eb80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below. ## Project at a Glance -**OmniRoute** — unified AI proxy/router. One endpoint, 341 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 346 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 (154 migrations) | +| Database | `src/lib/db/` | SQLite domain modules (157 migrations) | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | MCP Server | `open-sse/mcp-server/` | 109 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | diff --git a/CHANGELOG.md b/CHANGELOG.md index 94438a0922..9c01b39a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,6 +169,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e - **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963 - **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366) +- **cli**: route provider test commands through configured connection test endpoints (#10570) - **executors**: fix internal timeout misclassified as client disconnect (499) for 7 niche executors — pass TimeoutError reason to controller.abort() (#8197 side-finding) - test(combo): guard auto/best-free never leaks the combo name as a model (#7754) - fix(vision-bridge): describe-model no longer returns unreachable "openai/gpt-4o-mini" when every vision-capable provider is unreachable on the instance — returns null instead and surfaces a clear error (#8430) diff --git a/Dockerfile b/Dockerfile index de9b5a1499..8eca2c3bd2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -173,7 +173,7 @@ COPY . ./ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \ mkdir -p /app/data \ && npm run build \ - && node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);" + && node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);" # ── Runner base ──────────────────────────────────────────────────────────── FROM base AS runner-base diff --git a/README.md b/README.md index d04278cab3..0d5ea9f25f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ # 🚀 OmniRoute — The Free AI Gateway -OmniRoute — Never stop coding. Every AI tool → 341 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 341 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. +OmniRoute — Never stop coding. Every AI tool → 346 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 346 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. @@ -63,7 +63,7 @@ | | v3.8.49 | **v3.8.50** | `v3.8.51+` | | ------------------------- | :-----: | :---------: | :---------: | -| 🌐 Providers | 290 | **341** | more queued | +| 🌐 Providers | 290 | **342** | more queued | | 🧠 Documented models | 1185 | **1202** | — | | 🖼️ Modality Bridge | — | 🆕 vision | video | | 📡 Radar free catalog | — | 🆕 opt-in | — | @@ -101,7 +101,7 @@ ⚙️ Features 🎯 Combos - 🌐 Providers + 🌐 Providers 🔌 CLI & MCP @@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint. 341 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 341 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests). +The Promise — One endpoint. 346 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 346 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 57 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests).

@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step: -What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 341 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs. +What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 346 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -559,7 +559,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute - **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md) - **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md) - **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md) -- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **341-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md) +- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **346-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md) - **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md) - **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md) @@ -642,11 +642,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
-## 🌐 341 AI Providers — 90+ Free +## 🌐 346 AI Providers — 90+ Free
-> The most complete catalog of any open-source router: **341 providers**, **90+ with a free tier**, **56 free forever**. +> The most complete catalog of any open-source router: **346 providers**, **90+ with a free tier**, **57 free forever**.
@@ -988,6 +988,8 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \ -p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest ``` +`:latest` follows the highest **published** stable SemVer. It does not track git `main`. Pin `:X.Y.Z` for GitOps. See [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels). + > **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and > `diegosouzapw/omniroute:next-web` follow the current default `release/v*` > branch. These mutable tags are intended only for testing unreleased fixes and @@ -1172,7 +1174,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c RuntimeNode.js 22.x / 24.x LTS — >=22.22.2 <23 || >=24.0.0 <27 LanguageTypeScript 6.0 — 100% TypeScript across src/ and open-sse/ (zero any in core since v2.0) FrameworkNext.js 16 + React 19 + Tailwind CSS 4 - Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 154 migrations + Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 157 migrations MemorySQLite FTS5 full-text + int8-quantized vector embeddings, typed decay SchemasZod 4 — MCP tool I/O validation + API contracts ProtocolsMCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE) diff --git a/bin/cli/api.mjs b/bin/cli/api.mjs index fff6cf0829..6534f91095 100644 --- a/bin/cli/api.mjs +++ b/bin/cli/api.mjs @@ -52,6 +52,19 @@ function resolveUrl(path, opts) { return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`; } +/** The machine-derived token is valid only for the local loopback server. */ +export function isLoopbackUrl(value) { + try { + const hostname = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (hostname === "localhost" || hostname === "::1") return true; + if (/^127(?:\.[0-9]{1,3}){3}$/.test(hostname)) return true; + if (/^::ffff:(?:127\.|7f[0-9a-f]{2}:)/i.test(hostname)) return true; + return false; + } catch { + return false; + } +} + export async function buildHeaders(opts) { const headers = new Headers(opts.headers || {}); if (!headers.has("accept")) headers.set("accept", "application/json"); @@ -87,10 +100,17 @@ export async function buildHeaders(opts) { if (auth && !headers.has("authorization")) { headers.set("authorization", `Bearer ${auth}`); } - // Inject machine-id derived CLI token; env var override for testing. - const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken()); - if (cliToken && !headers.has(CLI_TOKEN_HEADER)) { - headers.set(CLI_TOKEN_HEADER, cliToken); + // Inject the machine-derived credential only for an explicit local loopback + // destination. Remote contexts and absolute remote URLs use scoped access + // tokens and must never receive this machine-bound local credential. + const destinationUrl = opts.destinationUrl ?? getBaseUrl(opts); + if (!isLoopbackUrl(destinationUrl)) { + headers.delete(CLI_TOKEN_HEADER); + } else { + const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken()); + if (cliToken && !headers.has(CLI_TOKEN_HEADER)) { + headers.set(CLI_TOKEN_HEADER, cliToken); + } } if (opts.idempotencyKey && !headers.has("idempotency-key")) { headers.set("idempotency-key", opts.idempotencyKey); @@ -195,8 +215,12 @@ function fetchOnce(url, init, timeoutMs) { export async function apiFetch(path, opts = {}) { const method = String(opts.method || "GET").toUpperCase(); const url = resolveUrl(path, opts); - const headers = await buildHeaders(opts); + const headers = await buildHeaders({ ...opts, destinationUrl: url }); const body = serializeBody(opts.body, headers); + // Undici preserves custom headers across cross-origin redirects. A local server + // redirect must never turn the loopback machine credential into an outbound + // secret, so fail redirects whenever this header is present. + const redirect = headers.has(CLI_TOKEN_HEADER) ? "error" : opts.redirect; const timeout = opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000); const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts); @@ -205,7 +229,7 @@ export async function apiFetch(path, opts = {}) { let lastErr; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - const res = await fetchOnce(url, { method, headers, body }, timeout); + const res = await fetchOnce(url, { method, headers, body, redirect }, timeout); if (res.ok) return enrichResponse(res, opts); if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) { const delay = computeBackoff(attempt, res.headers.get("retry-after")); diff --git a/bin/cli/commands/doctor.mjs b/bin/cli/commands/doctor.mjs index 9ac34bf636..817013dd19 100644 --- a/bin/cli/commands/doctor.mjs +++ b/bin/cli/commands/doctor.mjs @@ -4,7 +4,9 @@ import os from "node:os"; import path from "node:path"; import { createDecipheriv, scryptSync } from "node:crypto"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { isLoopbackUrl } from "../api.mjs"; import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs"; +import { getCliToken, CLI_TOKEN_HEADER } from "../utils/cliToken.mjs"; import { printHeading } from "../io.mjs"; import { t } from "../i18n.mjs"; import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs"; @@ -378,11 +380,11 @@ function checkMemory() { }); } -async function fetchWithTimeout(url) { +async function fetchWithTimeout(url, options = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS); try { - return await fetch(url, { signal: controller.signal }); + return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timeout); } @@ -471,6 +473,98 @@ async function checkServerLiveness(options = {}) { ); } +export async function checkMachineTokenAuth(options = {}) { + if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") { + return warn("CLI machine token", "CLI machine-token authentication is disabled", { + derived: false, + accepted: false, + disabled: true, + tokenExposed: false, + }); + } + + let url; + try { + const parsed = new URL(resolveLivenessUrl(options)); + if ( + !["http:", "https:"].includes(parsed.protocol) || + parsed.username || + parsed.password || + !isLoopbackUrl(parsed.toString()) + ) { + return warn( + "CLI machine token", + "Machine-token probes are limited to HTTP(S) loopback endpoints", + { derived: false, accepted: false, tokenExposed: false } + ); + } + parsed.pathname = "/api/cli/whoami"; + parsed.search = ""; + parsed.hash = ""; + url = parsed.toString(); + } catch { + return warn("CLI machine token", "Could not resolve the management endpoint", { + derived: false, + accepted: false, + tokenExposed: false, + }); + } + + const token = await getCliToken(); + if (!token) { + return fail( + "CLI machine token", + "Could not derive a machine token; verify the node-machine-id runtime is installed", + { derived: false, accepted: false, tokenExposed: false } + ); + } + + try { + const response = await fetchWithTimeout(url, { + headers: { [CLI_TOKEN_HEADER]: token }, + redirect: "error", + }); + if (response.ok) { + return ok("CLI machine token", "Server accepted the local machine token", { + url, + status: response.status, + derived: true, + accepted: true, + tokenExposed: false, + }); + } + if (response.status === 401 || response.status === 403) { + return warn( + "CLI machine token", + "Server rejected the local machine token; if the CLI and server are on different hosts or container boundaries, run `omniroute connect --key `", + { + url, + status: response.status, + derived: true, + accepted: false, + containerBoundaryLikely: true, + tokenExposed: false, + } + ); + } + return warn("CLI machine token", `Machine-token probe returned HTTP ${response.status}`, { + url, + status: response.status, + derived: true, + accepted: false, + tokenExposed: false, + }); + } catch { + return warn("CLI machine token", "Machine-token endpoint could not be reached", { + url, + status: 0, + derived: true, + accepted: false, + tokenExposed: false, + }); + } +} + export async function collectDoctorChecks(context = {}, options = {}) { const rootDir = context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); @@ -488,6 +582,7 @@ export async function collectDoctorChecks(context = {}, options = {}) { if (!options.skipLiveness) { checks.push(await checkServerLiveness(options)); + checks.push(await checkMachineTokenAuth(options)); } // CLI tool health checks diff --git a/bin/cli/commands/providers.mjs b/bin/cli/commands/providers.mjs index 91d60cead8..eb872241bf 100644 --- a/bin/cli/commands/providers.mjs +++ b/bin/cli/commands/providers.mjs @@ -129,7 +129,34 @@ function buildTestInput(connection, apiKey) { }; } -async function runProviderTest(db, connection) { +async function testProviderConnectionThroughServer(connection) { + try { + const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}/test`, { + method: "POST", + body: {}, + retry: false, + timeout: 30000, + acceptNotOk: true, + }); + const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` }; + return { + connection: publicConnection(connection), + ...data, + valid: data.valid === true, + skipped: false, + }; + } catch (error) { + return { + connection: publicConnection(connection), + valid: false, + skipped: false, + error: error instanceof Error ? error.message : String(error), + statusCode: null, + }; + } +} + +async function runProviderTest(db, connection, { serverUp = false } = {}) { // Only API-key connections can be probed with a stored credential. OAuth / // no-auth connections have nothing for testProviderApiKey() to send, and // getProviderApiKey() throws for them by design — reporting that as a FAILED @@ -151,6 +178,9 @@ async function runProviderTest(db, connection) { // means the CLI has no probe recipe, not that the provider is unhealthy. // Persisting it would overwrite a good test_status with a failure. if (result.unsupported) { + if (serverUp) { + return testProviderConnectionThroughServer(connection); + } return { connection: publicConnection(connection), ...result, @@ -266,6 +296,7 @@ export async function runTestCommand(selector, opts = {}) { } export async function runTestAllCommand(opts = {}) { + const serverUp = await isServerUp(); const { db } = await openOmniRouteDb(); try { const connections = listProviderConnections(db); @@ -280,7 +311,7 @@ export async function runTestAllCommand(opts = {}) { }); continue; } - results.push(await runProviderTest(db, connection)); + results.push(await runProviderTest(db, connection, { serverUp })); } if (opts.json) { diff --git a/bin/cli/commands/test-provider.mjs b/bin/cli/commands/test-provider.mjs index 8802f75cd1..ec10c24649 100644 --- a/bin/cli/commands/test-provider.mjs +++ b/bin/cli/commands/test-provider.mjs @@ -38,12 +38,19 @@ export async function runTestProviderCommand(provider, model, opts = {}) { } const targetProvider = provider || "anthropic"; - const targetModel = model || "claude-haiku-4-5-20251001"; + const connections = await _loadConnections(); + if (!connections) return 1; + const connection = _resolveConnection(connections, targetProvider, model); + if (!connection) { + console.error(`Provider connection not found: ${targetProvider}`); + return 1; + } + const targetModel = model || connection.defaultModel; const repeat = opts.repeat && opts.repeat > 0 ? opts.repeat : 1; const results = []; for (let i = 0; i < repeat; i++) { - const result = await _runSingleTest(targetProvider, targetModel); + const result = await _runSingleTest(connection, targetModel); results.push(result); } @@ -70,18 +77,10 @@ export async function runTestProviderCommand(provider, model, opts = {}) { } async function _runAllProviders(opts) { - const res = await apiFetch("/api/providers?limit=200", { - retry: false, - timeout: 5000, - acceptNotOk: true, - }); - if (!res.ok) { - console.error(t("test.noServer")); - return 1; - } - const data = await res.json(); - const connections = (data.connections ?? data.providers ?? data.items ?? data).filter( - (c) => c.authType === "apikey" || c.testStatus !== "unavailable" + const loaded = await _loadConnections(); + if (!loaded) return 1; + const connections = loaded.filter( + (c) => c.isActive !== false && (c.authType === "apikey" || c.testStatus !== "unavailable") ); if (connections.length === 0) { console.log(t("test.noProviders")); @@ -89,6 +88,7 @@ async function _runAllProviders(opts) { } const providers = connections.map((c) => ({ + connectionId: c.id, provider: c.provider ?? c.id, model: c.defaultModel ?? c.model, })); @@ -102,8 +102,8 @@ async function _runAllProviders(opts) { } const results = await Promise.all( - providers.map(async ({ provider, model }) => { - const r = await _runSingleTest(provider, model); + providers.map(async ({ connectionId, provider, model }) => { + const r = await _runSingleTest({ id: connectionId }, model); return { provider, model, ...r }; }) ); @@ -123,6 +123,13 @@ async function _runAllProviders(opts) { async function _runCompare(provider, opts) { const targetProvider = provider || "anthropic"; + const connections = await _loadConnections(); + if (!connections) return 1; + const connection = _resolveConnection(connections, targetProvider); + if (!connection) { + console.error(`Provider connection not found: ${targetProvider}`); + return 1; + } const models = opts.compare .split(",") .map((m) => m.trim()) @@ -138,7 +145,7 @@ async function _runCompare(provider, opts) { for (const model of models) { const results = []; for (let i = 0; i < repeat; i++) { - const result = await _runSingleTest(targetProvider, model); + const result = await _runSingleTest(connection, model); results.push(result); } rows.push({ model, ..._aggregate(results, true) }); @@ -180,19 +187,55 @@ async function _runCompare(provider, opts) { return rows.every((r) => r.success) ? 0 : 1; } -async function _runSingleTest(provider, model) { +async function _loadConnections() { + const res = await apiFetch("/api/providers?limit=200", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("test.noServer")); + return null; + } + const data = await res.json(); + const connections = data.connections ?? data.providers ?? data.items ?? data; + if (!Array.isArray(connections)) { + console.error(t("test.noServer")); + return null; + } + return connections; +} + +function _resolveConnection(connections, selector, model) { + const normalized = String(selector || "") + .trim() + .toLowerCase(); + const active = connections.filter((connection) => connection.isActive !== false); + return ( + active.find((connection) => String(connection.id || "").toLowerCase() === normalized) ?? + active.find((connection) => String(connection.name || "").toLowerCase() === normalized) ?? + active.find( + (connection) => + String(connection.provider || "").toLowerCase() === normalized && + (!model || connection.defaultModel === model || connection.model === model) + ) ?? + active.find((connection) => String(connection.provider || "").toLowerCase() === normalized) + ); +} + +async function _runSingleTest(connection, model) { const startMs = Date.now(); try { - const res = await apiFetch("/api/v1/providers/test", { + const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}/test`, { method: "POST", - body: { provider, model }, + body: model ? { validationModelId: model } : {}, retry: false, timeout: 30000, acceptNotOk: true, }); const durationMs = Date.now() - startMs; - const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` }; - return { ...data, durationMs }; + const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` }; + return { ...data, success: data.valid === true, durationMs }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { diff --git a/bin/cli/tray/autostart.mjs b/bin/cli/tray/autostart.mjs index 6c1ba21aee..a61a5c739a 100644 --- a/bin/cli/tray/autostart.mjs +++ b/bin/cli/tray/autostart.mjs @@ -121,7 +121,16 @@ function writeLinuxSystemdUnit(cliPath) { "Wants=network-online.target", "", "[Service]", - "Type=simple", + // Type=notify + WatchdogSec: the server sends READY=1 once listening and + // WATCHDOG=1 every 60s; if its event loop ever blocks (frozen process), + // the pings stop and systemd kills+restarts the service. NotifyAccess=all + // because the pings come from the server child, not the serve supervisor. + // Foreground serve only: `--daemon` escapes the cgroup and would break + // the notify handshake. + "Type=notify", + "NotifyAccess=all", + "WatchdogSec=180", + "TimeoutStartSec=300", `ExecStart=${buildServeExecLine(cliPath, { tray: false })}`, "Restart=on-failure", "RestartSec=5", diff --git a/bin/cli/tui/ProvidersTestAll.jsx b/bin/cli/tui/ProvidersTestAll.jsx index 73fc78614a..c1911888ac 100644 --- a/bin/cli/tui/ProvidersTestAll.jsx +++ b/bin/cli/tui/ProvidersTestAll.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { render, Box, Text, useInput } from "ink"; import Spinner from "ink-spinner"; +import { apiFetch } from "../api.mjs"; import { DataTable } from "../tui-components/DataTable.jsx"; import { ProgressBar } from "../tui-components/ProgressBar.jsx"; @@ -31,22 +32,20 @@ const TABLE_SCHEMA = [ { key: "error", header: "Error", width: 28, formatter: (v) => (v ? v.slice(0, 26) : "") }, ]; -async function testOne(provider, model, baseUrl, apiKey) { - const headers = { - "Content-Type": "application/json", - ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), - }; +async function testOne(connectionId, model, baseUrl, apiKey) { const start = Date.now(); try { - const res = await fetch(`${baseUrl}/api/v1/providers/test`, { + const res = await apiFetch(`/api/providers/${encodeURIComponent(connectionId)}/test`, { method: "POST", - headers, - body: JSON.stringify({ provider, model }), - signal: AbortSignal.timeout(30000), + body: model ? { validationModelId: model } : {}, + baseUrl, + token: apiKey, + timeout: 30000, + acceptNotOk: true, }); const latencyMs = Date.now() - start; - const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` }; - return { status: data.success ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error }; + const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` }; + return { status: data.valid ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { @@ -63,6 +62,7 @@ function ProvidersTestAllApp({ providers, baseUrl, apiKey, concurrency = 4, onEx const [rows, setRows] = useState(() => providers.map((p, i) => ({ id: i, + connectionId: p.connectionId ?? p.id, provider: p.provider ?? p.id ?? String(p), model: p.model ?? p.defaultModel ?? "", status: STATUS.PENDING, @@ -91,7 +91,7 @@ function ProvidersTestAllApp({ providers, baseUrl, apiKey, concurrency = 4, onEx const row = queue[cursor++]; running++; update(row.id, { status: STATUS.RUNNING }); - testOne(row.provider, row.model, resolved, apiKey).then((result) => { + testOne(row.connectionId, row.model, resolved, apiKey).then((result) => { update(row.id, result); running--; nextSlot(); diff --git a/bin/cli/utils/cliToken.mjs b/bin/cli/utils/cliToken.mjs index 94691ba952..38895c13bf 100644 --- a/bin/cli/utils/cliToken.mjs +++ b/bin/cli/utils/cliToken.mjs @@ -12,25 +12,39 @@ function getActiveSalt() { return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT; } -export async function getCliToken() { - const salt = getActiveSalt(); - if (_cached !== null && _cachedSalt === salt) return _cached; +export function deriveCliToken(machineIdModule, salt) { try { // node-machine-id is CommonJS: under `await import()` its exports land on // `.default`, so destructuring `machineIdSync` off the namespace yields // undefined and calling it throws — which the catch below turned into an // empty token, silently disabling CLI auth for every management request. // Same resolution order as src/lib/machineToken.ts. - const mod = await import("node-machine-id"); - const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync; - if (typeof machineIdSync !== "function") throw new Error("machine-id API unavailable"); + const machineIdSync = + machineIdModule?.machineIdSync || machineIdModule?.default?.machineIdSync; + if (typeof machineIdSync !== "function") return ""; // machineIdSync(true) returns the original unhashed hardware ID — mirrors // getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening). - const mid = machineIdSync(true); - _cached = crypto.createHmac("sha256", mid).update(salt).digest("hex"); + const rawId = machineIdSync(true); + if (!rawId) return ""; + return crypto.createHmac("sha256", rawId).update(salt).digest("hex"); + } catch { + return ""; + } +} + +export async function getCliToken() { + const salt = getActiveSalt(); + if (_cached !== null && _cachedSalt === salt) return _cached; + try { + const imported = await import("node-machine-id"); + const token = deriveCliToken(imported, salt); + if (!token) { + // Swallowing here changes control flow (every management call goes out + // unauthenticated and 401s), so leave a breadcrumb rather than failing mute. + console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled"); + } + _cached = token; } catch (e) { - // Swallowing here changes control flow (every management call goes out - // unauthenticated and 401s), so leave a breadcrumb rather than failing mute. console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled:", e); _cached = ""; } diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index c023879da8..fb0a455520 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -119,6 +119,9 @@ function loadEnvFile() { addEnvPath(join(ROOT, ".env")); } + const keyOrigin = new Map(); + const shadowed = new Map(); + for (const envPath of envPaths) { try { if (existsSync(envPath)) { @@ -131,19 +134,31 @@ function loadEnvFile() { const key = trimmed.slice(0, eqIdx).trim(); if (process.env[key] === undefined) { process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1)); + keyOrigin.set(key, envPath); + } else if (!shadowed.has(key)) { + // The line is inert: something set this key first. Report it once + // per key, whether the winner was an earlier file or the process + // environment (#6194: a shell's own HOSTNAME beat the .env and the + // server bound to the wrong address in silence). + shadowed.set(key, { winner: keyOrigin.get(key) ?? null, loser: envPath }); } } } loadedEnvPaths.push(envPath); } - } catch { - // Ignore errors reading env files. + } catch (err) { + console.warn(` \x1b[33m⚠ Could not read ${envPath}: ${err?.message ?? err}\x1b[0m`); } } for (const envPath of loadedEnvPaths) { console.log(` \x1b[2m📋 Loaded env from ${envPath}\x1b[0m`); } + + for (const [key, { winner, loser }] of shadowed) { + const setter = winner ? winner : "the environment"; + console.warn(` \x1b[33m⚠ ${key} in ${loser} is ignored, ${setter} set it first\x1b[0m`); + } } loadEnvFile(); diff --git a/changelog.d/features/10303-healthz-event-loop-lag.md b/changelog.d/features/10303-healthz-event-loop-lag.md new file mode 100644 index 0000000000..991c123021 --- /dev/null +++ b/changelog.d/features/10303-healthz-event-loop-lag.md @@ -0,0 +1 @@ +- **feat(resilience):** warn when `/healthz` is served under event-loop lag ≥200ms so a slow 200 is visible as sick, not healthy ([#10303](https://github.com/diegosouzapw/OmniRoute/issues/10303)) diff --git a/changelog.d/features/10316-livez-endpoint.md b/changelog.d/features/10316-livez-endpoint.md new file mode 100644 index 0000000000..01409d7b48 --- /dev/null +++ b/changelog.d/features/10316-livez-endpoint.md @@ -0,0 +1 @@ +- **feat(docker):** add `GET`/`HEAD` `/livez` as a process-alive probe, distinct from `/healthz` readiness ([#10316](https://github.com/diegosouzapw/OmniRoute/issues/10316)) diff --git a/changelog.d/features/10587-ogg-speech-alias.md b/changelog.d/features/10587-ogg-speech-alias.md new file mode 100644 index 0000000000..118e2a7b48 --- /dev/null +++ b/changelog.d/features/10587-ogg-speech-alias.md @@ -0,0 +1 @@ +- **feat(providers):** accept `response_format=ogg` on `/v1/audio/speech` as an alias for the existing Opus/Ogg encoder ([#10587](https://github.com/diegosouzapw/OmniRoute/issues/10587)) diff --git a/changelog.d/features/10662-systemd-notify.md b/changelog.d/features/10662-systemd-notify.md new file mode 100644 index 0000000000..5a02e7df25 --- /dev/null +++ b/changelog.d/features/10662-systemd-notify.md @@ -0,0 +1 @@ +- feat(server): emit systemd sd_notify READY/WATCHDOG/STOPPING (generated unit becomes Type=notify with WatchdogSec=180) so a frozen server process is killed and restarted by systemd instead of lingering undetected diff --git a/changelog.d/features/10668-newapi-gateway-protocols.md b/changelog.d/features/10668-newapi-gateway-protocols.md new file mode 100644 index 0000000000..1ec6e5f0b7 --- /dev/null +++ b/changelog.d/features/10668-newapi-gateway-protocols.md @@ -0,0 +1,2 @@ +- **feat(providers):** add the TabiToken NewAPI gateway (`tabitoken`) and teach the existing HCNSec entry (`hcnsec`) the three further protocols it actually serves. TabiToken leaves the NewAPI pricing endpoint public, so its catalog is read from the host rather than guessed: four Claude models, each reporting the Anthropic and OpenAI protocols. HCNSec shipped OpenAI-only; probing the host showed `/v1/messages`, `/v1/responses` and the Gemini `/v1beta` path all reach its token layer, so each is now declared as an alternate format — with its default format, base URL, auth scheme and regional catalog classification untouched. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil +- **feat(sse):** allow an alternate protocol to build its own upstream URL. `AlternateFormat` gained an optional `urlBuilder`, because the Gemini protocol carries the model inside the path (`{base}/{model}:generateContent`) and the existing `chatPath`/`urlSuffix` fields are constants that cannot express it. The route builder is extracted as `buildGeminiGenerateContentUrl` and shared with the native `gemini` provider so the two consumers cannot drift on the `?alt=sse` streaming suffix. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil diff --git a/changelog.d/features/10670-call-logs-error-type.md b/changelog.d/features/10670-call-logs-error-type.md new file mode 100644 index 0000000000..1ffd94bd22 --- /dev/null +++ b/changelog.d/features/10670-call-logs-error-type.md @@ -0,0 +1 @@ +- **feat(call_logs):** persist the per-call error family in `call_logs.error_type` and expose a failure breakdown (`errorBreakdown`) in the usage analytics endpoint, reusing the existing production classifier ([#10670](https://github.com/diegosouzapw/OmniRoute/issues/10670)) diff --git a/changelog.d/features/10677-egress-sharing-summary.md b/changelog.d/features/10677-egress-sharing-summary.md new file mode 100644 index 0000000000..9e1f723a0d --- /dev/null +++ b/changelog.d/features/10677-egress-sharing-summary.md @@ -0,0 +1 @@ +- **feat(proxy):** the proxy-health sweep and `GET /api/settings/proxies/egress` now report an anonymous summary of egress-IP sharing — how many rotation groups share an egress IP and the largest number of accounts behind one IP — computed from persisted `proxy_logs` over a 24h window. No IPs and no account identities by default; `PROXY_LOG_INCLUDE_IPS=true` restores raw details. ([#10677](https://github.com/diegosouzapw/OmniRoute/issues/10677)) diff --git a/changelog.d/features/10729-cursor-api-key-and-cli-passthrough.md b/changelog.d/features/10729-cursor-api-key-and-cli-passthrough.md new file mode 100644 index 0000000000..96094ffff1 --- /dev/null +++ b/changelog.d/features/10729-cursor-api-key-and-cli-passthrough.md @@ -0,0 +1 @@ +- **feat(providers):** new `cursor-api` provider (card "Cursor API", alias `cua`): connect a Cursor user API key (`crsr_…`) and route `cursor-api/` through the existing Cursor agent executor (the key is exchanged for a 1h session token and cached), plus a `/api/cursor-cli/*` passthrough so the Cursor CLI itself runs through OmniRoute (`CURSOR_API_ENDPOINT=http:///api/cursor-cli`, `CURSOR_API_KEY=`) with every RPC attributed and logged. The IDE `cursor` provider is unchanged. (#10729) diff --git a/changelog.d/features/10771-health-root-endpoint.md b/changelog.d/features/10771-health-root-endpoint.md new file mode 100644 index 0000000000..a367bbce78 --- /dev/null +++ b/changelog.d/features/10771-health-root-endpoint.md @@ -0,0 +1 @@ +- **feat(api):** `GET /api/health` now answers `{ status, timestamp }` without a key. Until now the path had no route, so the management-auth boundary answered first with a 401 — indistinguishable from a wrong key or an unknown route, which left Docker HEALTHCHECKs and Kubernetes probes unable to tell "down" from "misconfigured". Kept deliberately minimal: version, uptime and memory stay behind the authenticated `/api/monitoring/health` ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10771)). diff --git a/changelog.d/features/10783-task-routing-configurable-patterns.md b/changelog.d/features/10783-task-routing-configurable-patterns.md new file mode 100644 index 0000000000..e5c37c390a --- /dev/null +++ b/changelog.d/features/10783-task-routing-configurable-patterns.md @@ -0,0 +1 @@ +- feat(routing): make Task-Aware Smart Routing's detection patterns operator-configurable via `settings.taskRouting.patternOverrides` (`PUT /api/settings/task-routing`) — the built-in patterns are English-only, so a non-English dashboard had no recourse short of turning detection off entirely; an override now replaces the pattern list for one task type without touching the rest (#10783) diff --git a/changelog.d/features/10869-combo-patch-verb.md b/changelog.d/features/10869-combo-patch-verb.md new file mode 100644 index 0000000000..f11893d95c --- /dev/null +++ b/changelog.d/features/10869-combo-patch-verb.md @@ -0,0 +1 @@ +- feat(api): accept PATCH on /api/combos/[id], the verb the OpenAPI spec already documents (#10869) diff --git a/changelog.d/features/10896-glm-5.3.md b/changelog.d/features/10896-glm-5.3.md new file mode 100644 index 0000000000..0edfc4a55b --- /dev/null +++ b/changelog.d/features/10896-glm-5.3.md @@ -0,0 +1 @@ +- **feat(sse):** add GLM-5.3 support (`glm-5.3`, `glm-5.3-high`, `glm-5.3-low`) across the z.ai first-party providers, mapping the upstream `reasoning_effort` request parameter to the existing 5.2 tier UX ([#10896](https://github.com/diegosouzapw/OmniRoute/pull/10896)) — thanks @phuongddx diff --git a/changelog.d/features/10897-home-recent-requests.md b/changelog.d/features/10897-home-recent-requests.md new file mode 100644 index 0000000000..fd6bcc9abe --- /dev/null +++ b/changelog.d/features/10897-home-recent-requests.md @@ -0,0 +1 @@ +- **feat(home):** add a live **Recent Requests** panel beside the home Provider Topology (polls `GET /api/usage/call-logs?excludeTests=1` every ~3s, gated by the topology appearance toggle + page visibility). `excludeTests` is now an allowlist of real provider inference (`/v1/%` or `/api/v1/%`), applied before `LIMIT`, so connection-test/model-sync/management rows can never leak into the feed ([#10897](https://github.com/diegosouzapw/OmniRoute/pull/10897), extracted from [#8450](https://github.com/diegosouzapw/OmniRoute/pull/8450)) — thanks @nguyenha935 diff --git a/changelog.d/features/8443-credential-health-per-connection-interval.md b/changelog.d/features/8443-credential-health-per-connection-interval.md new file mode 100644 index 0000000000..1fd1d3e5a2 --- /dev/null +++ b/changelog.d/features/8443-credential-health-per-connection-interval.md @@ -0,0 +1,2 @@ +- **feat(credential-health):** pace the credential health sweep per connection via `provider_connections.healthCheckInterval` (minutes, 0 = never), with `CREDENTIAL_HEALTH_CHECK_INTERVAL` as the global default ([#8443](https://github.com/diegosouzapw/OmniRoute/issues/8443)) +- **behavior change:** `healthCheckInterval` is a shared column — it paces both the OAuth token refresh and the credential health sweep, and `0` disables both. The connection editor defaults it to 60, so configured OAuth connections are now credential-checked at 60min instead of the previous ~10min (aligned with the probe-volume goal of #8443) diff --git a/changelog.d/features/cursor-agent-image-provider.md b/changelog.d/features/cursor-agent-image-provider.md new file mode 100644 index 0000000000..84646dc44e --- /dev/null +++ b/changelog.d/features/cursor-agent-image-provider.md @@ -0,0 +1 @@ +- feat(sse): add Cursor plan image generation via Agent CLI (`IMAGE_PROVIDERS.cursor`, format `cursor-agent-image`), reusing the chat Cursor OAuth connection diff --git a/changelog.d/features/disable-context-window-checks.md b/changelog.d/features/disable-context-window-checks.md new file mode 100644 index 0000000000..1cdd3cc0a8 --- /dev/null +++ b/changelog.d/features/disable-context-window-checks.md @@ -0,0 +1 @@ +- feat(routing): add the default-off `DISABLE_CONTEXT_WINDOW_CHECKS` feature flag to let operators bypass OmniRoute's local context-window and max-input-token check for direct single-model requests, leaving upstream limits, prompt compression, and output-token caps intact. diff --git a/changelog.d/features/kimi-coding-extra-usage.md b/changelog.d/features/kimi-coding-extra-usage.md new file mode 100644 index 0000000000..766ec1020c --- /dev/null +++ b/changelog.d/features/kimi-coding-extra-usage.md @@ -0,0 +1 @@ +- **feat(usage):** show Kimi Coding's fixed-order Code 5-hour/7-day quota windows plus Extra Usage status, balance, monthly spend/limit, and the official Additional Credits link on Dashboard → Quota cards. diff --git a/changelog.d/fixes/10095-antigravity-multiaccount-quota-false-exhaustion.md b/changelog.d/fixes/10095-antigravity-multiaccount-quota-false-exhaustion.md new file mode 100644 index 0000000000..579005e943 --- /dev/null +++ b/changelog.d/fixes/10095-antigravity-multiaccount-quota-false-exhaustion.md @@ -0,0 +1 @@ +- fix(domain): stop treating an unreported Antigravity quota fraction (`fractionReported:false`) as 0% remaining in `quotaCache.ts`, which was falsely marking every fresh/newly-connected account as exhausted and blocking multi-account rotation (#10095) diff --git a/changelog.d/fixes/10156-responses-commentary-completed-snapshot.md b/changelog.d/fixes/10156-responses-commentary-completed-snapshot.md new file mode 100644 index 0000000000..7a976ab85d --- /dev/null +++ b/changelog.d/fixes/10156-responses-commentary-completed-snapshot.md @@ -0,0 +1 @@ +- **fix(sse):** Responses-passthrough `response.completed` snapshots now drop `phase:"commentary"` items the same way live SSE frames already do, so the terminal `response.output` array no longer echoes internal commentary text that was already suppressed from the stream (#10156). diff --git a/changelog.d/fixes/10162-approximate-combo-context-advisory.md b/changelog.d/fixes/10162-approximate-combo-context-advisory.md new file mode 100644 index 0000000000..3c1bc703a0 --- /dev/null +++ b/changelog.d/fixes/10162-approximate-combo-context-advisory.md @@ -0,0 +1 @@ +- **fix(routing):** keep approximate Combo context estimates advisory so requests reach concrete targets instead of returning a pre-dispatch 400 ([#10162](https://github.com/diegosouzapw/OmniRoute/pull/10162)) — thanks @xz-dev diff --git a/changelog.d/fixes/10345-bare-combo-opencode-ids.md b/changelog.d/fixes/10345-bare-combo-opencode-ids.md new file mode 100644 index 0000000000..c3a6a499ec --- /dev/null +++ b/changelog.d/fixes/10345-bare-combo-opencode-ids.md @@ -0,0 +1 @@ +- **fix(opencode-plugin):** publish bare combo model ids without the plugin provider prefix so OpenCode can select them ([#10345](https://github.com/diegosouzapw/OmniRoute/issues/10345)) diff --git a/changelog.d/fixes/10346-empty-pool-warn-once.md b/changelog.d/fixes/10346-empty-pool-warn-once.md new file mode 100644 index 0000000000..e4b50ef3ff --- /dev/null +++ b/changelog.d/fixes/10346-empty-pool-warn-once.md @@ -0,0 +1 @@ +- **fix(backend):** log `auto/ matched no connected models` once per process per label instead of every minute ([#10346](https://github.com/diegosouzapw/OmniRoute/issues/10346)) diff --git a/changelog.d/fixes/10353-memory-heap-conflict-warn.md b/changelog.d/fixes/10353-memory-heap-conflict-warn.md new file mode 100644 index 0000000000..c52b7cc15c --- /dev/null +++ b/changelog.d/fixes/10353-memory-heap-conflict-warn.md @@ -0,0 +1 @@ +- **fix(docker):** warn at boot when `OMNIROUTE_MEMORY_MB` disagrees with `NODE_OPTIONS --max-old-space-size`, and document that the standalone/Docker launcher appends `OMNIROUTE_MEMORY_MB` last ([#10353](https://github.com/diegosouzapw/OmniRoute/issues/10353)) diff --git a/changelog.d/fixes/10470-antigravity-byop-account-rotation.md b/changelog.d/fixes/10470-antigravity-byop-account-rotation.md new file mode 100644 index 0000000000..9ec58e152a --- /dev/null +++ b/changelog.d/fixes/10470-antigravity-byop-account-rotation.md @@ -0,0 +1 @@ +- **fix(antigravity):** automatically rotate to a sibling account when one is BYOP (GCP Project ID required, `gcp_project_required` 422) — the account is excluded from selection for 24h and the request succeeds via another account instead of failing fast; the actionable 422 is surfaced only when no sibling exists (follow-up to the #10424 BYOP fast-fail) ([#10470](https://github.com/diegosouzapw/OmniRoute/pull/10470)) — thanks @rqzbeh diff --git a/changelog.d/fixes/10528-direct-dispatcher-response-start-timeout.md b/changelog.d/fixes/10528-direct-dispatcher-response-start-timeout.md new file mode 100644 index 0000000000..9354b02822 --- /dev/null +++ b/changelog.d/fixes/10528-direct-dispatcher-response-start-timeout.md @@ -0,0 +1 @@ +- **fix(network):** direct (no-proxy) egress now bounds each attempt's response-start window (default 30s, `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS`) and retries once on a fresh no-keep-alive socket, so a silently-dropped pooled keep-alive connection can no longer stall direct providers (opencode-go, command-code) until a service restart ([#10214](https://github.com/diegosouzapw/OmniRoute/issues/10214)) diff --git a/changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md b/changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md new file mode 100644 index 0000000000..11a1845715 --- /dev/null +++ b/changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md @@ -0,0 +1 @@ +- **fix(deps):** upgrade `@atjsh/llmlingua-2` from 2.0.3 to 2.0.5 and remove `@tensorflow/tfjs` from the LLMLingua SLM stack — 2.0.5 adds official Transformers.js v4 support (peers `@huggingface/transformers` at `^3.5.2 || ^4.0.0`) and 2.0.4+ no longer requires TensorFlow.js, restoring compatibility with OmniRoute's Transformers.js v4 while dropping the largest single contributor to the optional runtime footprint ([#10536](https://github.com/diegosouzapw/OmniRoute/issues/10536)) diff --git a/changelog.d/fixes/10550-responses-reasoning-transport.md b/changelog.d/fixes/10550-responses-reasoning-transport.md new file mode 100644 index 0000000000..d34c433deb --- /dev/null +++ b/changelog.d/fixes/10550-responses-reasoning-transport.md @@ -0,0 +1 @@ +- Preserve portable plaintext reasoning by default across streaming and non-streaming Chat Completions and Responses routes while keeping provider-bound opaque state target-compatible. Combos now drop incompatible continuation reasoning by default and can explicitly skip incompatible targets, while known providers no longer show redundant encrypted-reasoning controls. (#10550) diff --git a/changelog.d/fixes/10592-playground-chattab-endpoint-routing.md b/changelog.d/fixes/10592-playground-chattab-endpoint-routing.md new file mode 100644 index 0000000000..ca602b9122 --- /dev/null +++ b/changelog.d/fixes/10592-playground-chattab-endpoint-routing.md @@ -0,0 +1 @@ +- fix(dashboard): route the Playground's ChatTab "Send" through the endpoint actually selected in StudioConfigPane (`search`, `web.fetch`, etc.) instead of always POSTing to `/api/v1/chat/completions`, fixing the false "No active credentials for provider" 404 when testing search-only providers (#10592) diff --git a/changelog.d/fixes/10594-freepik-magnific-api.md b/changelog.d/fixes/10594-freepik-magnific-api.md new file mode 100644 index 0000000000..4c1c59a701 --- /dev/null +++ b/changelog.d/fixes/10594-freepik-magnific-api.md @@ -0,0 +1 @@ +- **fix(providers):** Magnific Mystic is now the canonical provider (`/dashboard/providers/magnific`, `magnific/`). It uses the Magnific API (`api.magnific.com` + `x-magnific-api-key`), dashboard Test Connection validates keys without starting a paid generation, and the old `freepik` slug remains a legacy alias ([#10594](https://github.com/diegosouzapw/OmniRoute/pull/10594)) diff --git a/changelog.d/fixes/10597-combo-log-error-body.md b/changelog.d/fixes/10597-combo-log-error-body.md new file mode 100644 index 0000000000..ff6608947c --- /dev/null +++ b/changelog.d/fixes/10597-combo-log-error-body.md @@ -0,0 +1 @@ +- **fix(sse):** Include the redacted upstream error body in the per-target COMBO failure log (`Model X failed, trying next`) so operators can triage a 400/500 without reproducing the request ([#10597](https://github.com/diegosouzapw/OmniRoute/issues/10597)) diff --git a/changelog.d/fixes/10686-combo-quota-token-limit-await.md b/changelog.d/fixes/10686-combo-quota-token-limit-await.md new file mode 100644 index 0000000000..a9e7b910e3 --- /dev/null +++ b/changelog.d/fixes/10686-combo-quota-token-limit-await.md @@ -0,0 +1 @@ +- **Combo routing:** await each connection's token limit before reserving quota. The old lookup treated the `Promise` as a connection and dropped `rateLimitOverrides.tpm` ([#10686](https://github.com/diegosouzapw/OmniRoute/pull/10686)). diff --git a/changelog.d/fixes/10732-copilot-m365-invocation-refresh.md b/changelog.d/fixes/10732-copilot-m365-invocation-refresh.md new file mode 100644 index 0000000000..acbfbbe693 --- /dev/null +++ b/changelog.d/fixes/10732-copilot-m365-invocation-refresh.md @@ -0,0 +1 @@ +- **fix(providers):** copilot-m365-web chat turns no longer surface as `(empty response)` — the type:4 invocation is aligned with the 2026-08 wire shape and now carries its type:1 Metrics follow-up in the same socket write, and the access token pre-flight-refreshes from a stored refresh_token instead of requiring a DevTools re-capture every ~75 minutes ([#10732](https://github.com/diegosouzapw/OmniRoute/pull/10732) — thanks @acc0mplish) diff --git a/changelog.d/fixes/10734-combo-context-generic-default.md b/changelog.d/fixes/10734-combo-context-generic-default.md new file mode 100644 index 0000000000..988c435d4e --- /dev/null +++ b/changelog.d/fixes/10734-combo-context-generic-default.md @@ -0,0 +1 @@ +- **fix(catalog):** stop counting `getTokenLimit()`'s generic 128k catch-all as a known combo window, so `/v1/models` advertises the min of sourced member contexts instead of collapsing a 500k combo to 128k ([#10734](https://github.com/diegosouzapw/OmniRoute/issues/10734)) diff --git a/changelog.d/fixes/10735-search-provider-named-errors.md b/changelog.d/fixes/10735-search-provider-named-errors.md new file mode 100644 index 0000000000..0e82f36aa8 --- /dev/null +++ b/changelog.d/fixes/10735-search-provider-named-errors.md @@ -0,0 +1 @@ +- **fix(search):** name `/v1/search` 502s with provider id and sanitized Node cause code, without hostnames ([#10735](https://github.com/diegosouzapw/OmniRoute/issues/10735)) diff --git a/changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md b/changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md new file mode 100644 index 0000000000..ff36462d47 --- /dev/null +++ b/changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md @@ -0,0 +1 @@ +- fix(compression): skip the expensive `createCompressionStats()` pass in RTK when no message was actually compressed, matching every sibling stacked engine (#10765) diff --git a/changelog.d/fixes/10769-cache-stats-real-cache.md b/changelog.d/fixes/10769-cache-stats-real-cache.md new file mode 100644 index 0000000000..baaacc660d --- /dev/null +++ b/changelog.d/fixes/10769-cache-stats-real-cache.md @@ -0,0 +1 @@ +- **fix(api):** `/api/cache/stats` reported the prompt-cache LRU, which no request path ever writes to — it answered `0 hit / 0 miss, size 0` while the semantic cache served real traffic, and the Health and Usage dashboards rendered that as fact. It now reports the semantic cache's in-memory entries, with the same response shape ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10769)) — thanks @Poid-ZA, who first fixed this in #9446. diff --git a/changelog.d/fixes/10770-console-interceptor-message-fidelity.md b/changelog.d/fixes/10770-console-interceptor-message-fidelity.md new file mode 100644 index 0000000000..c35260f36a --- /dev/null +++ b/changelog.d/fixes/10770-console-interceptor-message-fidelity.md @@ -0,0 +1 @@ +- **fix(logging):** the app log is filterable and readable again. Entries from the tagged logger (`[LEVEL] [TAG] message`) were filed under the level instead of the component, and printf format strings were never applied, so `%s`/`%d` stayed literal with the values trailing behind them unlabelled — including every LiveWS connection line, where the format is deliberate hardening against injected format specifiers ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10770)). diff --git a/changelog.d/fixes/10774-claude-code-flat-rate.md b/changelog.d/fixes/10774-claude-code-flat-rate.md new file mode 100644 index 0000000000..ea09e2b208 --- /dev/null +++ b/changelog.d/fixes/10774-claude-code-flat-rate.md @@ -0,0 +1 @@ +- **fix(analytics):** Claude Code (`claude`/`cc`) is a flat-rate subscription, so cost analytics reports `$0` for it instead of estimating Anthropic list prices — the metered `anthropic` API keeps its real cost, and budget/quota/routing still estimate as before ([#10774](https://github.com/diegosouzapw/OmniRoute/pull/10774)) — thanks @electrumguy diff --git a/changelog.d/fixes/10781-wal-truncate-scheduler.md b/changelog.d/fixes/10781-wal-truncate-scheduler.md new file mode 100644 index 0000000000..4eb13a271b --- /dev/null +++ b/changelog.d/fixes/10781-wal-truncate-scheduler.md @@ -0,0 +1 @@ +- fix(db): periodically run `wal_checkpoint(TRUNCATE)` so the SQLite WAL file shrinks on long-running servers (default 6h, override with `OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS`, `0` disables) (#10781) diff --git a/changelog.d/fixes/10782-ws-heartbeat-ping-pong.md b/changelog.d/fixes/10782-ws-heartbeat-ping-pong.md new file mode 100644 index 0000000000..23aeaf3d3a --- /dev/null +++ b/changelog.d/fixes/10782-ws-heartbeat-ping-pong.md @@ -0,0 +1 @@ +- fix(sse): replace LiveWS's application-only liveness check with a protocol-level `ws.ping()`/`pong` heartbeat (RFC 6455 §5.5.2) alongside the existing one, so a read-only dashboard subscriber that never sends anything survives the connection timeout — a socket that stops reading frames entirely is still reaped exactly as before (#10782) diff --git a/changelog.d/fixes/10788-ollama-cloud-effort-tiers.md b/changelog.d/fixes/10788-ollama-cloud-effort-tiers.md new file mode 100644 index 0000000000..0437576d38 --- /dev/null +++ b/changelog.d/fixes/10788-ollama-cloud-effort-tiers.md @@ -0,0 +1 @@ +- **fix(open-sse):** declare `supportedThinkingEfforts` (`low`/`medium`/`high`/`max`) on Ollama Cloud's `glm-5.1`, `glm-5.2`, `deepseek-v4-pro` and `deepseek-v4-flash` registry entries so the catalog's `appendSyncedEffortVariants()` pass — which only synthesizes selectable `-low`/`-high`/`-max` model ids from an already-populated `capabilities.effort_tiers` — can expose an effort selector for these reasoning-capable models, matching what `gpt-oss:20b`/`gpt-oss:120b` already had (#10788) diff --git a/changelog.d/fixes/10792-double-transport-retry-scope.md b/changelog.d/fixes/10792-double-transport-retry-scope.md new file mode 100644 index 0000000000..337b680add --- /dev/null +++ b/changelog.d/fixes/10792-double-transport-retry-scope.md @@ -0,0 +1 @@ +- **fix(resilience):** scope the same-account transport retry (#9708) out of emergency-fallback and combo hops — it was retrying the free fallback model and combo targets too, doubling upstream calls and corrupting the terminal error status on those paths. diff --git a/changelog.d/fixes/10799-provider-health-inconclusive-probes.md b/changelog.d/fixes/10799-provider-health-inconclusive-probes.md new file mode 100644 index 0000000000..72aacacee0 --- /dev/null +++ b/changelog.d/fixes/10799-provider-health-inconclusive-probes.md @@ -0,0 +1 @@ +- **fix(providers):** Keep NVIDIA timeout probes and generic Antigravity/AGY HTTP 400 probes from poisoning credential health while preserving explicit Google geo-block handling ([#10799](https://github.com/diegosouzapw/OmniRoute/pull/10799)) — thanks @Zartharas diff --git a/changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md b/changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md new file mode 100644 index 0000000000..51768aab4c --- /dev/null +++ b/changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md @@ -0,0 +1 @@ +- fix(db): disambiguate `createProviderConnection()`'s OAuth email dedup by `providerSpecificData.profileArn` in addition to `username`, so adding a second Kiro/AWS profile with the same email creates a new connection instead of silently merging into the first (#10815) diff --git a/changelog.d/fixes/10832-unprefixed-dalle3.md b/changelog.d/fixes/10832-unprefixed-dalle3.md new file mode 100644 index 0000000000..2dfd970b13 --- /dev/null +++ b/changelog.d/fixes/10832-unprefixed-dalle3.md @@ -0,0 +1 @@ +- **fix(images):** register OpenAI `dall-e-3` in the image registry so unprefixed `dall-e-3` (and `openai/dall-e-3`) route to OpenAI Images instead of Microsoft Designer Web, and so the chat catalog no longer lists `openai/dall-e-3` as a 128k chat model ([#10832](https://github.com/diegosouzapw/OmniRoute/issues/10832)) diff --git a/changelog.d/fixes/10843-outbound-guard-mapped-ipv4.md b/changelog.d/fixes/10843-outbound-guard-mapped-ipv4.md new file mode 100644 index 0000000000..2894ff65b0 --- /dev/null +++ b/changelog.d/fixes/10843-outbound-guard-mapped-ipv4.md @@ -0,0 +1 @@ +- **fix(security):** Outbound URL guard now resolves IPv4-mapped IPv6 literals to their embedded address, so `[::ffff:169.254.169.254]` is refused by the unconditional cloud-metadata block like its dotted spelling; `[::]` is refused alongside `0.0.0.0` ([#10843](https://github.com/diegosouzapw/OmniRoute/pull/10843)) — thanks @ntdat812 diff --git a/changelog.d/fixes/10848-image-scan-cookie-bridge.md b/changelog.d/fixes/10848-image-scan-cookie-bridge.md new file mode 100644 index 0000000000..07e0f20291 --- /dev/null +++ b/changelog.d/fixes/10848-image-scan-cookie-bridge.md @@ -0,0 +1 @@ +- fix(config): exclude cookie-auth image bridges (chatgpt-web, gemini-web) from the unprefixed model scan so a bare id never silently binds to an unofficial web bridge (#10848) diff --git a/changelog.d/fixes/10849-search-provider-opaque-400.md b/changelog.d/fixes/10849-search-provider-opaque-400.md new file mode 100644 index 0000000000..a8982fb194 --- /dev/null +++ b/changelog.d/fixes/10849-search-provider-opaque-400.md @@ -0,0 +1 @@ +- fix(api): POST /v1/search now replies with a named `Unknown search provider: ` error (and field-named validation messages) instead of an opaque `Invalid request` for unrecognized or short-alias provider ids like `brave`/`serper` (#10849) diff --git a/changelog.d/fixes/10853-i18n-disabled-mistranslation.md b/changelog.d/fixes/10853-i18n-disabled-mistranslation.md new file mode 100644 index 0000000000..836cc6491e --- /dev/null +++ b/changelog.d/fixes/10853-i18n-disabled-mistranslation.md @@ -0,0 +1 @@ +- **fix(i18n):** The "Disabled" status no longer renders as the noun for a person with a disability in Japanese, Spanish, Hindi, Polish, Telugu, Urdu and both Chinese locales — 24 strings now use each catalog's existing wording (ja 無効, es Deshabilitado, hi अक्षम, pl Wyłączone, te నిలిపివేయబడింది, ur غیر فعال, zh-CN 已禁用, zh-TW 已停用) ([#10812](https://github.com/diegosouzapw/OmniRoute/issues/10812), [#10853](https://github.com/diegosouzapw/OmniRoute/pull/10853)) — thanks @ntdat812 diff --git a/changelog.d/fixes/10857-hide-auto-models-when-routing-disabled.md b/changelog.d/fixes/10857-hide-auto-models-when-routing-disabled.md new file mode 100644 index 0000000000..39da596ee3 --- /dev/null +++ b/changelog.d/fixes/10857-hide-auto-models-when-routing-disabled.md @@ -0,0 +1 @@ +- **fix(catalog):** `/v1/models` no longer advertises the built-in `auto/*` ids while auto routing is disabled — they were listed but rejected at request time with `Auto routing is disabled` ([#10831](https://github.com/diegosouzapw/OmniRoute/issues/10831), [#10857](https://github.com/diegosouzapw/OmniRoute/pull/10857)) — thanks @ntdat812 diff --git a/changelog.d/fixes/10858-base64-file-token-estimate.md b/changelog.d/fixes/10858-base64-file-token-estimate.md new file mode 100644 index 0000000000..18d8104b10 --- /dev/null +++ b/changelog.d/fixes/10858-base64-file-token-estimate.md @@ -0,0 +1 @@ +- **fix(context):** Base64 file payloads (OpenAI `file` parts, Responses `input_file`, Claude `document` blocks) are budgeted like the Gemini `inlineData` path instead of being counted as prompt text — a ~1MB PDF estimated at 350k tokens and was rejected on the context limit before reaching the provider's document pipeline ([#10840](https://github.com/diegosouzapw/OmniRoute/issues/10840), [#10858](https://github.com/diegosouzapw/OmniRoute/pull/10858)) — thanks @ntdat812 diff --git a/changelog.d/fixes/10860-mcp-upstream-fetch-timeout.md b/changelog.d/fixes/10860-mcp-upstream-fetch-timeout.md new file mode 100644 index 0000000000..a23aeed2a1 --- /dev/null +++ b/changelog.d/fixes/10860-mcp-upstream-fetch-timeout.md @@ -0,0 +1 @@ +- **fix(mcp):** MCP tool calls that wait on a model provider no longer abort after 10 seconds. `omniRouteFetch` applied a single hardcoded `AbortSignal.timeout(10000)` to every internal hop, and `omniroute_route_request` — which posts to `/v1/chat/completions` and waits on the upstream provider, plus auto-combo candidate probing before a provider is even chosen — passed no signal of its own, so it inherited it. Any route slower than 10s failed from the MCP side while the identical request succeeded through the REST API. `omniroute_web_search` and `omniroute_web_fetch` in the same file already carried an explicit 60s signal, so that value is now shared by all three provider-bound calls instead of being repeated as a literal, while management reads (health, resilience, rate limits, combos, quota, usage) keep their fast-fail 10s budget so a stalled local endpoint still cannot hold a tool call open. Both budgets are overridable through `OMNIROUTE_MCP_FETCH_TIMEOUT_MS` and `OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS`, replacing the reported workaround of patching the compiled `dist/.build/next/server/chunks/*.js`; a malformed or non-positive override falls back to the default rather than disabling the timeout diff --git a/changelog.d/fixes/10862-sync-models-degraded-cached-catalog.md b/changelog.d/fixes/10862-sync-models-degraded-cached-catalog.md new file mode 100644 index 0000000000..fd6f7d3f04 --- /dev/null +++ b/changelog.d/fixes/10862-sync-models-degraded-cached-catalog.md @@ -0,0 +1 @@ +- **fix(providers):** importing models with an expired API key now surfaces the credential error instead of reporting "No new models were added". The Import button posts to `/api/providers/{id}/sync-models`, which self-fetches the models route; that route does not fail on an upstream 401 but degrades to a catalog it already has, preferring the cache and using the local catalog only when there is no cache. A provider that imported successfully once therefore has a cache, so an expired key produced `{ source: "cache", warning: "Models probe failed (401) — using cached catalog" }` with HTTP 200 — and the #5460/#5465 degradation guard only recognised the `local_catalog` branch, so model-sync accepted it as a successful discovery, found every cached model already imported, and returned the empty-diff result. Retest does not go through this path, which is why it failed correctly and made the import look like a genuine "nothing to do". The existing rule — a degraded discovery must not be persisted as the synced catalog — is now applied to the branch it missed rather than special-casing 401/403, discriminating on the warning the fallback builder always attaches (an ordinary non-refresh cache hit attaches none, and model-sync always requests `refresh=true`). `isDegradedLocalCatalog` keeps its exact meaning and its existing tests diff --git a/changelog.d/fixes/10866-combo-empty-models.md b/changelog.d/fixes/10866-combo-empty-models.md new file mode 100644 index 0000000000..e71092d5d4 --- /dev/null +++ b/changelog.d/fixes/10866-combo-empty-models.md @@ -0,0 +1 @@ +- fix(api): reject a combo update that removes every model, and store the copilot's combo targets where the router reads them (#10866) diff --git a/changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md b/changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md new file mode 100644 index 0000000000..91f4e717e8 --- /dev/null +++ b/changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md @@ -0,0 +1 @@ +- **fix(proxy):** proxy "Test connection" no longer reports an IPv4-only SOCKS5/SSH proxy as dead. #1255 moved every egress probe from `api.ipify.org` to `api64.ipify.org` so proxies with IPv6 egress could be tested, but `api64` is IPv6-first: a tunnel with no IPv6 route has nothing to connect to, so the probe hung until the caller's deadline and a proxy that was carrying live LLM traffic came back as a failure. Swapping the target to `api4` fixes that case and re-breaks the one #1255 fixed, so the probe now tries the targets in order instead — `api64` first, so a proxy with working IPv6 answers on the first attempt and keeps the exact behaviour #1255 introduced, including which of its addresses is reported (the egress IP is used as an identity to detect accounts of one rotation group sharing an address, so the attempts are sequential rather than raced). The attempts split the budget each call site already enforced, so no probe can take longer than it could before, and each attempt gets its own `AbortController` so exhausting the budget on an unreachable target does not abort the next one. `OMNIROUTE_PROXY_ECHO_URL` pins a single target — including a self-hosted echo — replacing the workaround of rewriting the compiled bundle after every upgrade. The relay branch of the test route still targets `api64` through `x-relay-target`, since that request egresses from the relay worker rather than the operator's tunnel diff --git a/changelog.d/fixes/10870-cli-env-collision.md b/changelog.d/fixes/10870-cli-env-collision.md new file mode 100644 index 0000000000..95a428ba08 --- /dev/null +++ b/changelog.d/fixes/10870-cli-env-collision.md @@ -0,0 +1 @@ +- fix(cli): warn when a .env line never takes effect, and stop swallowing an unreadable .env (#10870) diff --git a/changelog.d/fixes/10873-mimocode-retirement-state-cleanup.md b/changelog.d/fixes/10873-mimocode-retirement-state-cleanup.md new file mode 100644 index 0000000000..44443eac6c --- /dev/null +++ b/changelog.d/fixes/10873-mimocode-retirement-state-cleanup.md @@ -0,0 +1 @@ +- **fix(db):** Remove stale MiMoCode provider configuration, including the legacy `mcode` alias, left after provider retirement while preserving historical usage and call logs ([#10873](https://github.com/diegosouzapw/OmniRoute/pull/10873)) — thanks @Zartharas diff --git a/changelog.d/fixes/10877-quota-alias-fetcher-lookup-gap.md b/changelog.d/fixes/10877-quota-alias-fetcher-lookup-gap.md new file mode 100644 index 0000000000..9501c6dad2 --- /dev/null +++ b/changelog.d/fixes/10877-quota-alias-fetcher-lookup-gap.md @@ -0,0 +1 @@ +- **fix(sse):** `getResetAwareProvider()` and the auto-combo quota lookup in `combo.ts` now canonicalize the provider id via `resolveProviderId()` before calling `getQuotaFetcher()`, so a fetcher registered under a provider's canonical id (e.g. `ollama-cloud`, `codex`) is found for combo targets stored under an alias spelling (e.g. `ollamacloud`, `cx`) instead of silently degrading reset-aware/reset-window/auto quota-aware routing to plain priority ordering (#10877) diff --git a/changelog.d/fixes/10878-unsupported-validation-probes-neutral.md b/changelog.d/fixes/10878-unsupported-validation-probes-neutral.md new file mode 100644 index 0000000000..1fc7c01933 --- /dev/null +++ b/changelog.d/fixes/10878-unsupported-validation-probes-neutral.md @@ -0,0 +1 @@ +- **fix(provider-health):** Keep unsupported 404/405 validation probes neutral so they do not poison stored credential health or scheduler failure state, while still honoring per-connection health-check pacing ([#10878](https://github.com/diegosouzapw/OmniRoute/pull/10878)) — thanks @Zartharas diff --git a/changelog.d/fixes/10882-antigravity-gemini37-flash-tiers.md b/changelog.d/fixes/10882-antigravity-gemini37-flash-tiers.md new file mode 100644 index 0000000000..b1ff4bbf5a --- /dev/null +++ b/changelog.d/fixes/10882-antigravity-gemini37-flash-tiers.md @@ -0,0 +1 @@ +- **fix(antigravity):** map Gemini 3.7 Flash tier ids (`gemini-3.7-flash-high/medium/low`, bare `gemini-3.7-flash`) to the upstream `gemini-3.7-flash-tiered` model id Google's Cloud Code endpoint expects, and configure per-tier thinking budgets ([#10882](https://github.com/diegosouzapw/OmniRoute/pull/10882)) — thanks @adevwithpurpose diff --git a/changelog.d/fixes/10887-memory-mcp-tools.md b/changelog.d/fixes/10887-memory-mcp-tools.md new file mode 100644 index 0000000000..8dc02db1d9 --- /dev/null +++ b/changelog.d/fixes/10887-memory-mcp-tools.md @@ -0,0 +1 @@ +- **fix(memory):** enable agent memory save/update via MCP tools (`memory_save`/`update`/`search`/`delete` builtins with per-provider schemas, `apiKeyId` optional with caller-principal fallback) and gate server-side memory builtin injection to non-stream requests only ([#10887](https://github.com/diegosouzapw/OmniRoute/pull/10887)) — thanks @Egorich-print diff --git a/changelog.d/fixes/10902-pplx-search-hint-optin.md b/changelog.d/fixes/10902-pplx-search-hint-optin.md new file mode 100644 index 0000000000..233fb61f19 --- /dev/null +++ b/changelog.d/fixes/10902-pplx-search-hint-optin.md @@ -0,0 +1 @@ +- **fix(perplexity-web):** make the built-in-search hint appended to every system message opt-in via `OMNIROUTE_PPLX_SEARCH_HINT` (off by default) — Perplexity's answer engine searches anyway, and the hint leaked into replies as meta-commentary for coding clients ([#10902](https://github.com/diegosouzapw/OmniRoute/pull/10902), extracted from [#8634](https://github.com/diegosouzapw/OmniRoute/pull/8634)) — thanks @danscMax diff --git a/changelog.d/fixes/8307-codex-image-account-fallback-retryable.md b/changelog.d/fixes/8307-codex-image-account-fallback-retryable.md new file mode 100644 index 0000000000..bd4a70a1ab --- /dev/null +++ b/changelog.d/fixes/8307-codex-image-account-fallback-retryable.md @@ -0,0 +1 @@ +- **fix(images):** retry Codex image generation on a sibling ChatGPT account when the requested model isn't entitled on the current account, instead of failing the request outright ([#8307](https://github.com/diegosouzapw/OmniRoute/pull/8307)). diff --git a/changelog.d/fixes/9692-openai-to-claude-tool-images.md b/changelog.d/fixes/9692-openai-to-claude-tool-images.md new file mode 100644 index 0000000000..c082d0dbc3 --- /dev/null +++ b/changelog.d/fixes/9692-openai-to-claude-tool-images.md @@ -0,0 +1 @@ +- **fix(translator):** convert OpenAI `image_url` blocks nested in `role: "tool"` / `tool_result` content to Claude `image` source blocks so OpenAI-compatible clients (Kimi Code CLI `ReadMediaFile`, and any other tool that returns media) no longer 400 the next Claude-format upstream turn ([#9692](https://github.com/diegosouzapw/OmniRoute/issues/9692)) diff --git a/changelog.d/fixes/9708-codex-same-account-retry.md b/changelog.d/fixes/9708-codex-same-account-retry.md new file mode 100644 index 0000000000..2ccb7fb97f --- /dev/null +++ b/changelog.d/fixes/9708-codex-same-account-retry.md @@ -0,0 +1 @@ +- **fix(resilience):** retry a retryable Codex pre-output 502/503/504/507 once on the same account (2–3s jitter) before cooling the connection, and stop translating that mixed pool into an all-accounts quota `429` ([#9708](https://github.com/diegosouzapw/OmniRoute/issues/9708)) diff --git a/changelog.d/fixes/PENDING-electron-window-hidden-hostname-bind.md b/changelog.d/fixes/PENDING-electron-window-hidden-hostname-bind.md new file mode 100644 index 0000000000..6a88ba926a --- /dev/null +++ b/changelog.d/fixes/PENDING-electron-window-hidden-hostname-bind.md @@ -0,0 +1 @@ +- **fix(electron):** desktop window stays hidden on Windows because the embedded Next.js server binds to the machine hostname instead of loopback ([#PENDING](https://github.com/diegosouzapw/OmniRoute/pull/PENDING)) diff --git a/changelog.d/fixes/assemble-standalone-cpsync-race.md b/changelog.d/fixes/assemble-standalone-cpsync-race.md new file mode 100644 index 0000000000..82fabbcad6 --- /dev/null +++ b/changelog.d/fixes/assemble-standalone-cpsync-race.md @@ -0,0 +1 @@ +- fix(build): tolerate a same-realpath symlink or stale-typed dest in the standalone bundle assembler, fixing non-deterministic `ERR_FS_CP_EINVAL`/`ERR_FS_CP_DIR_TO_NON_DIR` crashes under heavy concurrent build I/O diff --git a/changelog.d/fixes/claude-to-gemini-consecutive-roles.md b/changelog.d/fixes/claude-to-gemini-consecutive-roles.md new file mode 100644 index 0000000000..17483dce52 --- /dev/null +++ b/changelog.d/fixes/claude-to-gemini-consecutive-roles.md @@ -0,0 +1 @@ +- **fix(translator):** merge consecutive same-role contents in direct Claude to Gemini request translation to prevent upstream HTTP 400 errors diff --git a/changelog.d/fixes/combo-connection-scoped-reasoning-efforts.md b/changelog.d/fixes/combo-connection-scoped-reasoning-efforts.md new file mode 100644 index 0000000000..3a53f1323a --- /dev/null +++ b/changelog.d/fixes/combo-connection-scoped-reasoning-efforts.md @@ -0,0 +1 @@ +- **fix(catalog):** derive combo reasoning-effort tiers from the exact runtime-selectable connection scope, intersecting dynamic, pinned, allowlisted, and compatible provider-node evidence while failing closed on unknown capabilities. diff --git a/changelog.d/fixes/minimax-music-generation-dispatch.md b/changelog.d/fixes/minimax-music-generation-dispatch.md new file mode 100644 index 0000000000..e0cf2114ca --- /dev/null +++ b/changelog.d/fixes/minimax-music-generation-dispatch.md @@ -0,0 +1 @@ +- **fix(sse):** MiniMax music models now generate audio instead of failing with `Unsupported music format: minimax-music` — the provider entry was registered in the music registry (and advertised by `/v1/models`), but `handleMusicGeneration` had no branch for its format, so every `minimax/*` music request fell through the dispatch chain to a 400. Adds the missing dispatch: a single synchronous POST with the `base_resp` envelope check (a non-zero `status_code` arrives on HTTP 200 too), `data.status` handling (an unfinished generation is reported instead of polled — the operation has no task id and no query endpoint), `url` and `hex` output formats (hex normalized to base64), `mp3`/`wav`/`pcm` containers via `audio_setting`, and the regional endpoint through the per-connection base-URL override, which is also the only host that accepts `aigc_watermark`. The registry entry gains the generation and cover model ids it was missing and drops a query URL that does not exist for this operation. Regression guard: `tests/unit/minimax-music-generation.test.ts` (9 tests). diff --git a/changelog.d/maintenance/10317-latest-tracks-highest-stable.md b/changelog.d/maintenance/10317-latest-tracks-highest-stable.md new file mode 100644 index 0000000000..9fdb4d2c81 --- /dev/null +++ b/changelog.d/maintenance/10317-latest-tracks-highest-stable.md @@ -0,0 +1 @@ +- **docs(docker):** spell out that `:latest` tracks the highest **published** stable SemVer (not git `main`), and that GitOps should pin `X.Y.Z` ([#10317](https://github.com/diegosouzapw/OmniRoute/issues/10317)) diff --git a/changelog.d/maintenance/10349-optional-work-event-loop.md b/changelog.d/maintenance/10349-optional-work-event-loop.md new file mode 100644 index 0000000000..0cd695e4a7 --- /dev/null +++ b/changelog.d/maintenance/10349-optional-work-event-loop.md @@ -0,0 +1 @@ +- **docs(backend):** document that memory extraction, skills injection, and token refresh share the request event loop, plus dashboard kill switches ([#10349](https://github.com/diegosouzapw/OmniRoute/issues/10349)) diff --git a/changelog.d/maintenance/10350-sqlite-single-replica-ha.md b/changelog.d/maintenance/10350-sqlite-single-replica-ha.md new file mode 100644 index 0000000000..9b158d5787 --- /dev/null +++ b/changelog.d/maintenance/10350-sqlite-single-replica-ha.md @@ -0,0 +1 @@ +- **docs(docker):** document default SQLite as single-replica / HA-unsupported, including Recreate and HEALTHCHECK session blast radius ([#10350](https://github.com/diegosouzapw/OmniRoute/issues/10350)) diff --git a/changelog.d/maintenance/10351-pre-write-backup-throttle.md b/changelog.d/maintenance/10351-pre-write-backup-throttle.md new file mode 100644 index 0000000000..f6141d30ea --- /dev/null +++ b/changelog.d/maintenance/10351-pre-write-backup-throttle.md @@ -0,0 +1 @@ +- **docs(backend):** document that pre-write SQLite backups (including models.dev pricing) are throttled to once per 60 minutes and can be disabled with `DISABLE_SQLITE_AUTO_BACKUP` ([#10351](https://github.com/diegosouzapw/OmniRoute/issues/10351)) diff --git a/changelog.d/maintenance/10775-remove-dead-enforce-secrets.md b/changelog.d/maintenance/10775-remove-dead-enforce-secrets.md new file mode 100644 index 0000000000..4f44473360 --- /dev/null +++ b/changelog.d/maintenance/10775-remove-dead-enforce-secrets.md @@ -0,0 +1 @@ +- chore(security): remove the unused `enforceSecrets()` duplicate of the boot secret check and pin the live `enforceWebRuntimeEnv()` wiring with a regression test (#10775) diff --git a/changelog.d/maintenance/10779-combo-invocation-docs.md b/changelog.d/maintenance/10779-combo-invocation-docs.md new file mode 100644 index 0000000000..00138a3a65 --- /dev/null +++ b/changelog.d/maintenance/10779-combo-invocation-docs.md @@ -0,0 +1 @@ +- **docs:** Custom combos are only invoked by their exact name in the `model` field — `auto` remains a separate zero-config router, and `openrouter/auto` is a paid OpenRouter product, not an alias ([#10779](https://github.com/diegosouzapw/OmniRoute/pull/10779)) — thanks @maxmad64bis diff --git a/changelog.d/maintenance/10780-server-init-dead-code.md b/changelog.d/maintenance/10780-server-init-dead-code.md new file mode 100644 index 0000000000..ffe204a233 --- /dev/null +++ b/changelog.d/maintenance/10780-server-init-dead-code.md @@ -0,0 +1 @@ +- chore(startup): remove `src/server-init.ts` (183 lines, never imported — the boot path is `src/instrumentation-node.ts`) and correct four `"called from server-init.ts"` comments left pointing at the dead entry point (#10780) diff --git a/changelog.d/maintenance/10875-combos-id-verb-coverage.md b/changelog.d/maintenance/10875-combos-id-verb-coverage.md new file mode 100644 index 0000000000..5610a7ea41 --- /dev/null +++ b/changelog.d/maintenance/10875-combos-id-verb-coverage.md @@ -0,0 +1 @@ +- **docs(openapi):** document the `GET` and `PUT` operations on `/api/combos/{id}`, and add an operation-level coverage floor so a missing verb can no longer hide behind a path that already counts as covered ([#10875](https://github.com/diegosouzapw/OmniRoute/pull/10875)) diff --git a/changelog.d/maintenance/7786-management-auth-guide.md b/changelog.d/maintenance/7786-management-auth-guide.md new file mode 100644 index 0000000000..54f84a79b9 --- /dev/null +++ b/changelog.d/maintenance/7786-management-auth-guide.md @@ -0,0 +1 @@ +- **docs(auth):** distinguish dashboard sessions, `oma_live_…` Access Tokens, manage-scoped API keys, and inference keys ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786)) diff --git a/changelog.d/maintenance/env-doc-sync-adhoc-bot.md b/changelog.d/maintenance/env-doc-sync-adhoc-bot.md new file mode 100644 index 0000000000..dbd759be29 --- /dev/null +++ b/changelog.d/maintenance/env-doc-sync-adhoc-bot.md @@ -0,0 +1 @@ +- **chore(ci):** ignore ad-hoc `BOT_TOKEN`/`BOT_URL` in env-doc-sync (scripts/ad-hoc mesh helpers, not runtime config) diff --git a/changelog.d/maintenance/release-v3850-basereds-stream-utils-20260820.md b/changelog.d/maintenance/release-v3850-basereds-stream-utils-20260820.md new file mode 100644 index 0000000000..98b62cd448 --- /dev/null +++ b/changelog.d/maintenance/release-v3850-basereds-stream-utils-20260820.md @@ -0,0 +1 @@ +- **fix(tests):** realign the two `stream-utils` passthrough cases that still asserted the pre-#10017 SSE framing — the event-boundary case declares the OpenAI Responses client format it actually exercises, and the metadata case now pins that surviving lines stay inside one event instead of expecting the `:`/`id:` control lines that #10473 stopped forwarding to every client format. diff --git a/config/quality/dependency-allowlist.json b/config/quality/dependency-allowlist.json index f276fc45d2..c4476f95d1 100644 --- a/config/quality/dependency-allowlist.json +++ b/config/quality/dependency-allowlist.json @@ -20,7 +20,6 @@ "@stryker-mutator/tap-runner", "@swc/helpers", "@tailwindcss/postcss", - "@tensorflow/tfjs", "@testing-library/jest-dom", "@testing-library/react", "@toon-format/toon", diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 2c4434e5a8..84bfc0e6ca 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -54,11 +54,6 @@ "count": 3 } }, - "open-sse/handlers/chatCore/codexFailover.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "open-sse/handlers/chatCore/comboContextCache.ts": { "no-restricted-imports": { "count": 1 @@ -1636,11 +1631,6 @@ "count": 1 } }, - "src/lib/api/modelTestRunner.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "src/lib/api/proxyRegistryRouteHandlers.ts": { "no-restricted-imports": { "count": 1 @@ -1696,11 +1686,6 @@ "count": 1 } }, - "src/lib/embeddings/service.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "src/lib/evals/runtime.ts": { "no-restricted-imports": { "count": 1 @@ -3609,11 +3594,6 @@ "count": 83 } }, - "tests/unit/responses-parse-once-4041.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, "tests/unit/responses-translation-fixes.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 35 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 05323c8002..72bc030875 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,6 @@ { + "_rebaseline_2026_08_20_10531_freebuff_provider": "PR #10531 (adrianaryaputra, feat/freebuff-provider-support, closes #6793) own growth: src/shared/constants/providers/apikey/gateways.ts 1283->1298 (+15, the freebuff APIKEY_PROVIDERS_GATEWAYS catalog entry, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines) and src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx 1062->1067 (+5, freebuff credential placeholder/hint at the existing per-provider switch chokepoint). Covered by tests/unit/freebuff-provider.test.ts (9/9 passing).", + "_rebaseline_2026_08_20_10574_reasoning_transport_fallback": "PR #10574 (jackjinke, fix/responses-reasoning-transport, fixes #10550) own growth: src/sse/handlers/chatHelpers.ts 1017->1019 (+2 = the new reasoningTransportFallback option threaded through executeChatWithBreaker's options destructure and its downstream handleSingleModel call, at the existing per-attempt options-passthrough chokepoint; not extractable without splitting the option-forwarding call itself). Covered by the PR's own reasoning-policy test suite (tests/unit/chatcore-translation-paths.test.ts, tests/unit/combo-attempt-body-isolation-7847.test.ts, tests/unit/reasoning-cache.test.ts, tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts among others), 446/446 focused tests passing.", "_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).", "_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).", "_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)", @@ -304,6 +306,8 @@ "_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.", "_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)", "frozen": { + "_rebaseline_2026_08_20_10878_10799_provider_health_probes": "PRs #10878 (unsupported OpenAI-like validation probes stay neutral) + #10799 (preserve credential health on inconclusive NVIDIA-timeout/Antigravity-400 probes) own growth: src/app/api/providers/[id]/test/route.ts 946->1025 (+79, sum of both boarded together). Both add narrowly-scoped classification branches at the existing test-route dispatch chokepoint (unsupported-capability skip, credential-inconclusive detection) rather than new files, mirroring the prior 2026_06_27_5193 rebaseline of the same file. Covered by tests/unit/provider-validation-unsupported-neutral.test.ts + tests/unit/provider-health-inconclusive-probes.test.ts.", + "src/app/api/providers/[id]/test/route.ts": 1025, "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", "_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, 1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", "src/lib/modelCapabilities.ts": 1006, "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014, - "open-sse/config/imageRegistry.ts": 1019 + "open-sse/config/imageRegistry.ts": 1034, + "src/sse/handlers/chatHelpers.ts": 1019, + "src/shared/middleware/chatBodyAdmission.ts": 1005, + "_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file)." }, "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", @@ -612,5 +619,8 @@ "_rebaseline_2026_08_12_proxyfetch_redaction": "Base-reds round 3 (#9985): proxyFetch.ts 1220->1239 (+19) = redactProxyDetailsInMessage() helper closing the credential leak #10032 reintroduced (raw proxy URL with user:password appended to the propagated error, Hard Rule #12); irreducible security fix at the existing error-surface chokepoint. Covered by tests/unit/tls-proxy-context.test.ts (strengthened leak guards).", "_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts.", "_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule).", - "_rebaseline_2026_08_18_imageregistry_merge_train": "merge-train 2026-08-18 (owner-authorized, /merge-prs batch of 84): open-sse/config/imageRegistry.ts crossed the 1000-line new-file cap for the first time purely from combining three independent, already-legitimate provider registrations boarded in the same local merge-train — #10542 (aihorde optional-key image catalog), #10494 (gemini-web image generation), #10594 (freepik/magnific provider rename + validation). 996 on release tip -> 1019 on the train tip. Each PR individually adds a small, additive IMAGE_PROVIDERS registry entry at the existing chokepoint; none crosses the cap alone. Not modularized as part of this train's gate fix (out of scope for a merge reconciliation, not a feature change). Covered by each PR's own focused tests (aihorde-image-catalog/generation, gemini-web image tests, freepik/magnific provider tests)." -} + "_rebaseline_2026_08_18_imageregistry_merge_train": "merge-train 2026-08-18 (owner-authorized, /merge-prs batch of 84): open-sse/config/imageRegistry.ts crossed the 1000-line new-file cap for the first time purely from combining three independent, already-legitimate provider registrations boarded in the same local merge-train — #10542 (aihorde optional-key image catalog), #10494 (gemini-web image generation), #10594 (freepik/magnific provider rename + validation). 996 on release tip -> 1019 on the train tip. Each PR individually adds a small, additive IMAGE_PROVIDERS registry entry at the existing chokepoint; none crosses the cap alone. Not modularized as part of this train's gate fix (out of scope for a merge reconciliation, not a feature change). Covered by each PR's own focused tests (aihorde-image-catalog/generation, gemini-web image tests, freepik/magnific provider tests).", + "_rebaseline_2026_08_20_v3850_merge_train_batch1": "Merge-train batch1 (2026-08-19/20, 30 PRs boarded onto release/v3.8.50): gateways.ts 1255->1268 = PR #10722 (Token Kiosk OpenAI-compatible provider gateway catalog entry, +13 declarative lines, same god-file no-split rationale as prior gateways.ts rebaselines); chatHelpers.ts (uncapped, not previously frozen) new 1017 = PR #10797 (relay/bifrost error normalization, +23/-2, own-PR growth, existing file already near cap from accumulated chokepoint wiring per its own rebaseline history above); chatBodyAdmission.ts (uncapped) new 1005 = pre-existing base-red on the pure release tip (1004>1000 before this train boarded anything, no PR in this batch touches this file) — frozen here at its current size, not authorizing further growth. Owner-authorized rebaseline (2026-08-19 merge-prs session).", + "_rebaseline_2026_08_20_8338_cursor_image_provider": "PR (reimplementation of #8338, @valvesss): imageRegistry.ts 1019->1033 = new cursor IMAGE_PROVIDERS entry (Cursor plan image generation via Agent CLI), +14 lines of declarative provider metadata. Same god-registry no-split rationale as prior imageRegistry/gateways rebaselines.", + "_rebaseline_2026_08_20_imageregistry_1034": "imageRegistry.ts 1033->1034: +1 line drift between #10842 (cursor image provider, froze at 1033) and its actual merged state on release (measured 1034) — trivial rebaseline, not a new feature." +} \ No newline at end of file diff --git a/config/quality/open-sse-typecheck-baseline.json b/config/quality/open-sse-typecheck-baseline.json index 9b900ce2bd..c6de98b418 100644 --- a/config/quality/open-sse-typecheck-baseline.json +++ b/config/quality/open-sse-typecheck-baseline.json @@ -2,28 +2,10 @@ "open-sse/handlers/chatCore/clientUsageBuffer.ts": { "TS2345": 2 }, - "open-sse/services/browserBackedChat.ts": { - "TS2353": 2 - }, - "open-sse/services/compression/engines/omniglyphAdapter.ts": { - "TS2307": 1 - }, - "open-sse/services/compression/stats.ts": { - "TS2307": 1 - }, - "open-sse/utils/cursorImages.ts": { - "TS2339": 1 - }, - "open-sse/utils/imageNormalize.ts": { - "TS2339": 1 - }, "open-sse/utils/stream.ts": { "TS2345": 2, "TS2322": 2 }, - "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/markdown.ts": { - "TS2307": 2 - }, "src/lib/guardrails/videoBridgeHelpers.ts": { "TS2488": 1, "TS2365": 2, diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 3c4b9e5191..e04be30d95 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -167,7 +167,8 @@ "dedicatedGate": true }, "zizmorFindings": { - "value": 190, + "value": 192, + "_rebaseline_2026_08_20_radar_export_workflow": "190 -> 192 (+2). Workflow novo `.github/workflows/radar-export.yml` (passo 10 do go-live do Radar: publica o export estável do catálogo como asset de release para o servidor privado baixar via RADAR_EXPORT_URL). Os +2 são unpinned-uses @vN: actions/checkout@v7 + actions/setup-node@v7 — a MESMA convenção deliberada de todos os workflows (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só este violaria a convenção. O findings artipacked do checkout foi CORRIGIDO com `persist-credentials: false` (o job publica via GH_TOKEN em `gh release`, não usa a credencial do checkout). Nenhuma classe nova de template-injection / cache-poisoning / dangerous-triggers. Medido local com zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 191; +1 do delta conhecido do runner (ver _rebaseline_2026_07_28_ci_runner_delta: o runner enxerga 1 unpinned-uses @vN a mais que o devbox no mesmo commit; a baseline segue o runner) => 192.", "_rebaseline_2026_07_20_aliasresolver_hook_split_7808": "175 -> 176 (+1). Companion to PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix in bin/aliasResolver.mjs). The +1 is NOT caused by this PR's code changes (bin/* is not a workflow file) — it is a pre-existing drift that surfaced because the ratchet gate runs on this PR's CI: the zizmor scanner version on the GitHub runner gained a new rule (or extended an existing one) since the v3.8.49 baseline was seeded on 2026-07-17. Breakdown: the new finding is an unpinned-uses @vN class item on one of the existing workflows (same deliberate convention as _scanner_harden_workflows_2026_06_16 — @vN is intentional, SHA-pinning only this one would violate the convention). No new template-injection/artipacked/cache-poisoning/dangerous-triggers classes introduced. Measured by the Quality Gates (Extended) job on run 29713001401 = 176, baseline was 175. Note: by the time this landed on release/v3.8.49, the baseline was already at 176 via _rebaseline_2026_07_17_combo_recovery_hints — this entry is kept as historical record; no further bump applied.", "_rebaseline_2026_07_17_v3849_release": "169 -> 175 (+6). Cycle workflow drift (v3.8.48/v3.8.49): npm-publish.yml (new, WS1.3 #7092), electron-release.yml, nightly-compat.yml, nightly-release-green.yml, CI restructures (#7501 full-history base fetch, #7355 main-green, #7202 merge-queue gates, Trunk/Codecov). Breakdown vs v3.8.47: +3 unpinned-uses (@vN convention, deliberate per _scanner_harden_workflows_2026_06_16), +2 cache-poisoning (artifact upload/cache in the OWN electron-release/npm-publish RELEASE workflows -- operator-controlled, not fork-PR exploitable), +1 excessive-permissions (nightly-compat.yml permissions:issues). No new template-injection/artipacked/dangerous-triggers. Measured with zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 175 on da3a0be69.", "direction": "down", diff --git a/docs/architecture/ADAPTIVE_ROUTING.md b/docs/architecture/ADAPTIVE_ROUTING.md new file mode 100644 index 0000000000..73bacfec3a --- /dev/null +++ b/docs/architecture/ADAPTIVE_ROUTING.md @@ -0,0 +1,350 @@ +--- +title: "Adaptive Routing: Routing Events, Quality Feedback & Explainability" +version: 3.8.50 +lastUpdated: 2026-08-20 +--- + +# Adaptive Routing: Routing Events, Quality Feedback & Explainability + +This document describes the feedback-driven adaptive routing foundation added to +OmniRoute. It is deliberately small: it introduces a typed routing-outcome +channel, an online quality signal that feeds the existing auto-combo scorer, an +optional OpenTelemetry exporter, and an explainability endpoint. It does **not** +replace the existing resilience stack (circuit breaker, connection cooldown, +model lockout, health matrix, autopilot) — it complements it. + +## 1. Architectural context + +OmniRoute is a data plane with a **request hot path** and a **control/intelligence +plane**. The hot path must stay fast, memory-efficient, asynchronous, resilient and +predictable. Evaluation, quality scoring, experiments and historical analysis belong +to the control plane. + +``` +AI Agent / IDE + │ + ▼ +┌─────────────────────┐ +│ OmniRoute │ data plane (fast, sync, in-memory) +│ routing / failover │ +│ health / guardrail │ +│ cache / streaming │ +└──────────┬──────────┘ + │ RoutingEvent (fire-and-forget, ~0.2µs) + ▼ +┌─────────────────────┐ +│ Feedback sinks │ control plane (async, best-effort) +│ quality tracker │ +│ OTel exporter │ +│ explain store │ +└──────────┬──────────┘ + ▼ quality score + auto-combo scorer +``` + +### What was already there (audited, not duplicated) + +| Concept | Existing implementation | +| ----------------------------------- | -------------------------------------------------------------------------------------------------- | +| Availability (can we send traffic?) | Circuit breaker (CLOSED/DEGRADED/OPEN/HALF_OPEN, DB-persisted), connection cooldown, model lockout | +| Health reporting | `providerHealthMatrix.ts`, `providerHealthAutopilot.ts` | +| Shadow traffic | `open-sse/services/combo/shadowRouting.ts` | +| Guardrails | `src/lib/guardrails/` (pre/post hooks) | +| Exact cache | `src/lib/semanticCache.ts` (signature-based) | +| Evaluators / eval-driven routing | `src/lib/evals/`, `open-sse/services/evalRouting.ts` | +| Combo decision explainability | `open-sse/services/combo/decisionTrace.ts` | +| Dashboard real-time events | `src/lib/events/eventBus.ts` (UI notification channel, `unknown` payloads, 100-entry history) | + +The routing-event layer is **not** a re-implementation of `eventBus`: that bus is +the dashboard's real-time notification channel (typed _event names_, opaque +payloads, UI consumers). `RoutingEvent` is a typed _outcome_ struct +(latency/tokens/cost/outcome/finish-reason) consumed by the control plane's +feedback sinks (quality tracker, OTel exporter, explain store). + +### What was missing (added here) + +1. A **typed routing-outcome event + sink abstraction** (`RoutingEvent` / + `RoutingEventSink`). `decisionTrace` is combo-scoped and in-memory-only; + `comboMetrics` are cumulative counters; `call_logs` is raw async persistence. + None is a typed, sink-based outcome channel that a quality tracker, an OTel + exporter, or a Future-AGI-style evaluator can subscribe to. +2. An **online quality signal** (EWMA) for output quality — the scorer previously + proxied "quality" only through static task fitness and opt-in eval pass-rates. +3. An **optional, dependency-free OTel exporter** using GenAI semantic conventions. +4. An **explainability endpoint** returning the real routing decisions + quality state. + +## 2. Routing Events (feedback foundation) + +Files: `open-sse/services/routing/events.ts`, `.../index.ts` + +A `RoutingEvent` carries only routing metadata: + +```ts +interface RoutingEvent { + requestId: string; + provider: string; + model: string; + strategy: string; // "auto" | "priority" | "direct" | ... + latencyMs: number; + ttftMs: number | null; + inputTokens: number | null; + outputTokens: number | null; + cost: number | null; + retries: number; + fallbackUsed: boolean; + outcome: RoutingOutcome; // allowlisted union + status: number | null; + finishReason: string | null; + connectionId: string | null; + ts: number; +} +``` + +`RoutingEventSink` is a `Send+Sync`-style trait in TypeScript: + +```ts +interface RoutingEventSink { + readonly name: string; + record(event: RoutingEvent): void; // must be O(1), no sync I/O +} +``` + +The hot path calls `emitRoutingEvent(event)` once per completed request +(the streaming-completion callback, the non-streaming success path, and the +malformed-200 failure path in `handleChatCore`). Dispatch is synchronous fan-out +to registered sinks, but each sink only enqueues/updates in-memory state. **No +synchronous database writes, no network I/O on the hot path.** + +Default sinks: + +- `MemoryRoutingEventStore` — bounded (500) ring buffer, newest-first, for the + explain endpoint. +- `QualityTracker` consumer — updates the EWMA quality estimate. +- `OtlpHttpsEventSink` — optional, enabled only when `OMNIROUTE_OTEL_ENDPOINT` + (or `OTEL_EXPORTER_OTLP_ENDPOINT`) is set. + +### Measured overhead (honest comparison) + +`npm run bench:routing-events` on this workstation (100k iterations; sub-µs ops +measured as aggregate µs/op because per-op percentiles are below +`performance.now()` timer resolution): + +| Scenario | µs/op | ops/s | +| --------------------------------- | ------ | ------ | +| baseline (scoring only) | ~0.045 | ~22 M | +| baseline + RoutingEvent (2 sinks) | ~0.168 | ~5.9 M | +| baseline + event + OTel enqueue | ~0.163 | ~6.1 M | +| concurrent (8 interleaved bursts) | ~0.18 | — | + +The event-dispatch delta over baseline scoring is ~0.12 µs/request; the OTel sink +only enqueues (O(1) buffer push), adding nothing measurable. These numbers are +machine-specific and relative — not a production guarantee. The v1 "~0.2 µs" +figure was an aggregate estimate; this methodology separates the scoring baseline +from the event-dispatch cost. + +## 3. Quality Signal (feedback-driven provider state) + +Files: `open-sse/services/routing/quality.ts` + +v2 separates **operational** from **semantic** quality: + +- **Operational** — derived from the routing hot path (HTTP 4xx/5xx, connection + failures, 429s, malformed responses, stream interruptions, `finish_reason=length`, + zero-output successes, latency/TTFT EWMA). A 200 is NOT treated as semantic + quality. +- **Semantic** — the actual value of the generated output. ONLY ever produced by + an evaluator via `setSemanticQuality()`. It is `null` until one provides it and + never leaks into the operational score. + +Per-(provider, model) state (EWMA + bounded counters): + +- `successEwma` — EWMA (α=0.2) of outcome success. +- `latencyEwma` / `ttftEwma` — EWMA of latency (α=0.1). +- `samples`, `anomalies`, `rateLimited`, `semantic`, `semanticConfidence`. +- `recencyMs` — how recently the model was last observed. + +### Confidence / sample awareness + +`confidence = clamp01(samples / 50)`, and the score returned to the scorer is +blended toward the neutral midpoint: + +``` +score = 0.5 + confidence * (operational - 0.5) +``` + +Consequences (verified by tests): + +- A cold provider (0 samples) scores **0.5** — not unfairly penalized, but + unable to dominate a provider with thousands of solid observations. +- A provider with 7 lucky successes is pulled toward 0.5 (never dominates from + optimistic initialization). +- A provider with 50+ samples converges to its true operational score. +- Degradation and recovery are gradual (EWMA), and one isolated failure does + not destroy a healthy provider. + +`ProviderQuality` exposes `{ operational, semantic, confidence, samples, anomalies, +rateLimited, successEwma, latencyEwmaMs, ttftEwmaMs, recencyMs }`. + +This feeds the auto-combo scorer as the `quality` scoring factor: + +- `ScoringFactors.quality` / `ScoringWeights.quality` in + `open-sse/services/autoCombo/scoring.ts`. +- `DEFAULT_WEIGHTS`: `health` 0.1905 → 0.1605, `quality` 0.03. Sum stays 1.0. +- `buildAutoCandidates` populates `candidate.quality` from the tracker; candidates + without data default to neutral **0.5** (a cold candidate is neither boosted nor + penalized). + +The closed loop: + +``` +RoutingEvent → QualityTracker → getQualityScore → auto-combo quality factor + ↑ │ + └────── request outcome (handleChatCore) ←────────────┘ +``` + +### Hard exclusion vs soft penalty + +The quality signal is a **soft adaptive preference** only. Hard exclusion stays +with the existing resilience stack: circuit breaker OPEN, quota exhausted, +auth failure, model lockout — none of these are affected by the quality score. +A provider whose quality score dips temporarily is de-preferenced, never +hard-disabled. + +## 3b. Canonical stream timing (TTFT / ITL) + +Files: `open-sse/utils/streamTiming.ts` + +`createStreamTiming()` is the single instrumentation seam for the streaming path, +wired into `createSSEStream` (open-sse/utils/stream.ts): + +- `markByte()` — first upstream chunk received. +- `markForward()` — first chunk forwarded to the client (used for TTFT). +- `markInterrupted()` — stream timeout/abort/error before a clean finish. +- `ttft()` = first-forwarded-SSE-chunk latency. **This is NOT token-level TTFT** — + a single SSE chunk may carry zero/one/many tokens. Documented precisely. +- `avgItlMs()` = mean inter-chunk gap (a chunk-latency proxy for ITL). + +TTFT/ITL/interrupted flow into the `RoutingEvent` (`ttftMs`, `itlMs`) and are +exported as GenAI/OmniRoute span attributes by the OTel sink. + +## 4. OpenTelemetry / GenAI observability + +Files: `open-sse/services/routing/otel.ts` + +- Dependency-free OTLP/HTTP JSON exporter (uses global `fetch`, no + `@opentelemetry/*` SDK). +- Spans follow GenAI semantic conventions (`gen_ai.provider.name`, + `gen_ai.request.model`, `gen_ai.usage.input_tokens/output_tokens`, + `gen_ai.completion.finish_reason`, `gen_ai.system`) plus OmniRoute routing + attributes (outcome, status, ttft, retries, fallback). +- `record()` only enqueues into a bounded buffer (O(1)); a background timer + flushes via `POST {endpoint}/v1/traces` asynchronously. Under overload the + oldest events are dropped (`dropped` counter) — never backpressure the data + plane. +- **Disabled unless configured.** `OMNIROUTE_OTEL_ENDPOINT` (or + `OTEL_EXPORTER_OTLP_ENDPOINT`) must be set; otherwise the sink is not + registered and zero OTel code runs. + +## 5. Explainability + +- `GET /v1/explain/routing` returns the recent `RoutingEvent`s (the real + decisions, newest first) and the per-provider/model quality snapshot. +- Auth mirrors `/v1/combos` (Bearer API key or dashboard session; anonymous on + single-user local deployments with `REQUIRE_API_KEY=false`). +- Combo-level per-invocation traces remain available via the existing + `decisionTrace.ts` (header `X-OmniRoute-Combo-Trace`). +- Safety: events carry only routing metadata, never prompts/bodies/credentials. + +## 6. Evaluation-plane integration (Future AGI readiness) + +OmniRoute treats Future AGI (or any evaluator) as a **potential +intelligence/evaluation backend, not a dependency**. The seams: + +- A `RoutingEventSink` can forward events to an evaluator asynchronously. +- The `MemoryRoutingEventStore` + quality snapshot give an evaluator the raw + decision stream. +- A future `Evaluator` (deterministic, local judge, HTTP, WASM) would consume + events/traces and return a `QualityScore` that feeds the same + `getQualityScore`/quality-factor path. +- Existing eval-driven routing (`open-sse/services/evalRouting.ts`) already + re-orders combo targets by `eval_runs` pass-rates when enabled. + +No evaluation runs synchronously on the request path, and the gateway operates +fully with the evaluator absent. + +## 7. Final architectural review + +1. **What remains on the synchronous hot path?** Routing/scoring, guardrail + pre-checks, cache lookup, and one `emitRoutingEvent` fan-out (~0.12 µs over + baseline scoring) to in-memory sinks. +2. **What moved to asynchronous processing?** OTel export (timer + fetch), + `call_logs`/usage persistence, semantic-cache writes, quality is in-memory + and O(1) (no async needed). +3. **How does a routing outcome become feedback?** `handleChatCore` emits a + `RoutingEvent` → `QualityTracker` updates EWMA state → `getQualityScore` + feeds the auto-combo `quality` factor. +4. **How does quality influence future routing?** A low quality score reduces + the weighted score of that provider/model in `scoreAutoTargets`, so degraded + models are gradually de-preferenced and recover as their EWMA improves. +5. **How can Future AGI integrate without becoming a dependency?** Via the + `RoutingEventSink` interface / a future `Evaluator` adapter — no hardcoded + dependency. +6. **What happens when the evaluator is unavailable?** Routing is unaffected; + quality falls back to neutral (1.0) for models with no observed signal. +7. **What happens when telemetry is unavailable?** The OTel sink simply isn't + registered; the rest of the routing layer runs unchanged. +8. **What happens under overload?** The OTel buffer drops oldest events; quality + and the ring buffer are bounded by construction; no backpressure. +9. **How does provider state recover after degradation?** EWMA re-converges as + successes accumulate; warmup keeps cold models neutral; the circuit breaker + independently recovers via HALF_OPEN probes. +10. **Which proposed features were intentionally NOT implemented, and why?** + - Shadow traffic / experiments — already implemented + (`combo/shadowRouting.ts`); not re-built. + - Guardrails — already implemented (`src/lib/guardrails/`); not duplicated. + - Semantic cache — already implemented (`src/lib/semanticCache.ts`); not + duplicated. + - A full experiment-management platform, dataset tooling, prompt-optimization + platform, vector DB, or mandatory external OTel infrastructure — out of + scope for a lean data plane. + - A Rust `RoutingEvent` struct — the data plane is TypeScript; the TS type + is the adapted equivalent. + +## 8. Configuration reference + +| Variable | Default | Effect | +| ----------------------------- | ----------- | ------------------------------------------------------------------------------- | +| `OMNIROUTE_OTEL_ENDPOINT` | unset | When set, enables the OTLP/HTTP traces exporter (e.g. `http://collector:4318`). | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Fallback alias for the OTLP endpoint. | +| `OTEL_SERVICE_NAME` | `omniroute` | `service.name` resource attribute. | + +## 9. Tests + +- `tests/unit/routing-events.test.ts` — event normalization, status + classification, bounded ring buffer, sink fan-out + isolation. +- `tests/unit/routing-quality.test.ts` — EWMA warmup, failure/success recovery, + anomaly penalties, 429 transient handling, snapshot, reset. +- `tests/unit/routing-scoring-quality.test.ts` — weight integrity, neutral + default, quality factor ranking. +- `tests/unit/routing-otel.test.ts` — enable gating, GenAI span payload, async + flush, drop-under-overload. +- `tests/unit/routing-events-concurrency.test.ts` — thousands of events, ring + buffer boundedness, throwing-sink isolation, interleaved async bursts, + reset-during-inserts. +- `tests/unit/routing-adaptive-e2e.test.ts` — deterministic end-to-end loop via + the real `scoreAutoTargets` scorer: healthy → degrade → recover → blip, plus + cold-start and lucky-cold-provider scenarios. +- `tests/unit/stream-timing.test.ts` — TTFT (first-forwarded-chunk), ITL, + first-byte vs first-forward, interruption, malformed/empty chunk safety. + +## 10. Pre-existing issues status (Phase 18) + +| Issue | Status | Notes | +| ----------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `omniglyph` export mismatch | **FIXED (environmental)** | `node_modules` was out of sync with `package-lock.json` (installed 1.3.1 vs locked 1.4.0). Running `npm install omniglyph@1.4.0` restored the locked version; type errors dropped to 0. Manifests unchanged. | +| Stale `getKnownContextOverflow` tests | **KNOWN — not fixed** | `combo-context-overflow-compression-probe.test.ts` imports a function that no longer exists in `open-sse/services/combo.ts` (only comments reference it). Fixing requires re-implementing or re-writing those tests — unrelated architectural churn. | +| `combo-runtime-unit-concurrency.test.ts` DB isolation | **KNOWN — not fixed** | Test-harness SQLite-isolation assertion fails when run directly; fails identically on the base branch. | +| i18n `llm.txt` drift | **KNOWN — not fixed** | `docs/i18n/*/llm.txt` differ from root; pre-existing, blocks the docs-sync pre-commit gate. | + +Environmental vs code issues are kept distinct; no unrelated failures are hidden +behind changed test filters. diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index dacf0d6f58..23099237db 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -89,7 +89,6 @@ src/ ├── i18n/ Locale bundles ├── instrumentation.ts Next.js instrumentation hook ├── instrumentation-node.ts -├── server-init.ts Process-level bootstrap (env, DB, jobs, sync) └── proxy.ts Top-level proxy bootstrap helper ``` diff --git a/docs/architecture/REPOSITORY_MAP.md b/docs/architecture/REPOSITORY_MAP.md index f61131d594..4d59418054 100644 --- a/docs/architecture/REPOSITORY_MAP.md +++ b/docs/architecture/REPOSITORY_MAP.md @@ -133,7 +133,6 @@ src/ ├── types/ # Shared TS type files ├── instrumentation.ts # Next.js telemetry hook (browser + edge) ├── instrumentation-node.ts # Node-only instrumentation -├── server-init.ts # Server bootstrap (DB migrations, jobs, cleanup) └── proxy.ts # HTTP-proxy entry shim ``` diff --git a/docs/architecture/meta.json b/docs/architecture/meta.json index dba5872324..c5b10b7923 100644 --- a/docs/architecture/meta.json +++ b/docs/architecture/meta.json @@ -12,6 +12,7 @@ "ROUTER_BACKENDS", "admission-lanes", "cluster-decisions", - "persistence-backend-boundary" + "persistence-backend-boundary", + "ADAPTIVE_ROUTING" ] } diff --git a/docs/compression/COMPRESSION_ENGINES.md b/docs/compression/COMPRESSION_ENGINES.md index 22cffe7c42..4a232a0e5d 100644 --- a/docs/compression/COMPRESSION_ENGINES.md +++ b/docs/compression/COMPRESSION_ENGINES.md @@ -28,12 +28,12 @@ The `omniglyph` engine (package `omniglyph`, 1.4.0+) accepts a named semantic pr globally through `omniglyph.profile` in the compression settings or per step through the stacked pipeline's step config: -| Profile | Boundary | -| -------------- | --------------------------------------------------------------------------- | -| `aggressive` | Default. The policy the published receipts measured — images system, tool docs and dense history | -| `balanced` | Keeps live state native, protects the last 8 turns, collapses older closed history | -| `coding-safe` | Keeps authority, tool schemas and live tool output native, protects the last 12 turns | -| `passthrough` | Routes without transforming; the engine is skipped | +| Profile | Boundary | +| ------------- | ------------------------------------------------------------------------------------------------ | +| `aggressive` | Default. The policy the published receipts measured — images system, tool docs and dense history | +| `balanced` | Keeps live state native, protects the last 8 turns, collapses older closed history | +| `coding-safe` | Keeps authority, tool schemas and live tool output native, protects the last 12 turns | +| `passthrough` | Routes without transforming; the engine is skipped | The profile is a **ceiling, not a floor**: `mergeCompressionProfileOptions` in the package refuses to let a caller override reopen a lossy lane the profile closed, so a per-step @@ -170,22 +170,22 @@ override points it at a local copy instead (offline / air-gapped installs). ### Optional dependencies & on-demand install -The prunable LLMLingua runtime peer stack is **optional**. Three packages are declared as +The prunable LLMLingua runtime peer stack is **optional**. Two packages are declared as `optionalDependencies` in `package.json` and kept **external** by the production build (`scripts/build/prepublish.ts` does not bundle them): -| Package | Version (pin) | Notes | -| -------------------- | ------------- | ---------------------------------------------- | -| `@atjsh/llmlingua-2` | `2.0.3` | Entry package; declares the others as peers | -| `@tensorflow/tfjs` | `4.22.0` | Heaviest dep — dominates the ~800 MB footprint | -| `js-tiktoken` | `^1.0.20` | Tokenizer | +| Package | Version (pin) | Notes | +| -------------------- | ------------- | ------------------------------------------- | +| `@atjsh/llmlingua-2` | `2.0.5` | Entry package; declares the others as peers | +| `js-tiktoken` | `^1.0.20` | Tokenizer | -`@huggingface/transformers` is pinned at `3.5.2` as an **optional** dependency (shared with -the local embeddings path and also traced into the standalone bundle). Keeping it optional prevents -`onnxruntime-node` CUDA provider postinstall failures on CUDA 11 hosts from aborting the whole -OmniRoute install; when the optional stack is absent, LLMLingua still fail-opens. Only the three -packages above are prunable SLM peers. A standard `npm install` (dev) installs the optional stack -automatically unless optional dependencies are omitted. +`@huggingface/transformers` is pinned at `^4.2.0` (shared with the local embeddings path and +also traced into the standalone bundle); `@atjsh/llmlingua-2@2.0.5` peers on it with +`"^3.5.2 || ^4.0.0"`, so both Transformers.js v3 and v4 are supported. Since 2.0.4, +`@atjsh/llmlingua-2` no longer requires `@tensorflow/tfjs`, which removed the largest single +contributor (TensorFlow.js) from the SLM stack. Only the two packages above are prunable SLM +peers. A standard `npm install` (dev) installs the optional stack automatically unless optional +dependencies are omitted. **Why on-demand:** the npm-published package, the standalone bundle, and the Docker image ship **without** these deps to stay slim. When they are absent, the worker's dependency @@ -195,11 +195,12 @@ error logged). To activate it in a pruned environment, install the optional stac ```bash # pin to the versions declared in package.json optionalDependencies -npm install @atjsh/llmlingua-2@2.0.3 @tensorflow/tfjs@4.22.0 js-tiktoken +npm install @atjsh/llmlingua-2@2.0.5 js-tiktoken ``` -Roughly **~800 MB** total: the TensorFlow.js + transformers runtimes dominate; the -TinyBERT model adds ~57 MB downloaded at first use (not via npm). +The `@tensorflow/tfjs` removal (2.0.4+) eliminates the previously dominant ~800 MB +contributor — the remaining footprint is the transformers.js + onnxruntime-node runtimes, +plus the TinyBERT model (~57 MB) downloaded at first use (not via npm). Per environment: diff --git a/docs/diagrams/cli-terminal.svg b/docs/diagrams/cli-terminal.svg index 99bc29b327..4fd6887859 100644 --- a/docs/diagrams/cli-terminal.svg +++ b/docs/diagrams/cli-terminal.svg @@ -1,6 +1,6 @@ - + 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. - + @@ -16,12 +16,12 @@ OmniRoute Providers1f3a9c2e  anthropic   Claude Max 20x    active8c2d5b1a  codex       Codex Pro (team)  activef4e0a97b  glm         GLM Coding Plan   active03bd6e5f  kimi        Kimi K2 free      active… 334 more providers - + $ omniroute combo list - - + + OmniRoute Combos   always-on     [priority      ] enabled   cost-saver    [cost-optimized] enabled   fusion-panel  [fusion        ] enabled   context-relay [context-relay ] enabled… run: omniroute combo create diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg index 80b3cbcdb2..d73a1fb255 100644 --- a/docs/diagrams/comparison-table.svg +++ b/docs/diagrams/comparison-table.svg @@ -1,4 +1,4 @@ - + Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg index a25437f78c..1c3f0a6cc9 100644 --- a/docs/diagrams/promise-pillars.svg +++ b/docs/diagrams/promise-pillars.svg @@ -1,4 +1,4 @@ - + Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle. @@ -21,7 +21,7 @@ - One endpoint. 341 providers. Never stop building — OmniRoute picks the cheapest one that works. + One endpoint. 346 providers. Never stop building — OmniRoute picks the cheapest one that works. @@ -38,7 +38,7 @@ Never hit limits - Auto-fallback across 341 providers in + Auto-fallback across 346 providers in milliseconds. Quota out? The next provider takes over — zero downtime. diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg index 0182df44a2..fb332a4574 100644 --- a/docs/diagrams/readme-hero.svg +++ b/docs/diagrams/readme-hero.svg @@ -1,4 +1,4 @@ - + Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame. @@ -28,7 +28,7 @@ Never stop coding. - Every AI tool → 341 providers90+ free — through one endpoint. + Every AI tool → 346 providers90+ free — through one endpoint. Claude Code · Codex · Cursor · Cline · Copilot · Antigravity  →  FREE Claude / GPT / Gemini · auto-fallback diff --git a/docs/frameworks/MCP-SERVER.md b/docs/frameworks/MCP-SERVER.md index 67e946528f..18877057fd 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -347,6 +347,8 @@ per-key path take precedence once it is. stdio has no per-caller identity (see | `OMNIROUTE_MCP_SCOPES` | (empty) | Comma-separated allowlist of scopes considered "available" by default (used when caller does not provide its own scopes) | | `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | (unset = on) | When set to `0/false/off/no`, disables MCP description compression at registration time | | `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | (unset = on) | Alternate alias for the same toggle as above | +| `OMNIROUTE_MCP_FETCH_TIMEOUT_MS` | `10000` | Abort budget for internal management reads (health, resilience, combos, quota, usage) | +| `OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS` | `60000` | Abort budget for hops that wait on a provider (`route_request`, `web_search`, `web_fetch`) | | `MCP_TOOL_DENY` | (unset = no filter) | Comma-separated tool names to drop from `tools/list` (tool-cardinality reduction — see below) | | `MCP_TOOL_ALLOW` | (unset = no filter) | Comma-separated tool names to keep exclusively (allow-list mode — see below) | | `DATA_DIR` | `~/.omniroute` | Heartbeat file is written to `${DATA_DIR}/runtime/mcp-heartbeat.json` | diff --git a/docs/getting-started/AUTO-COMBO-GUIDE.md b/docs/getting-started/AUTO-COMBO-GUIDE.md index b5797c780e..fbba5abbe7 100644 --- a/docs/getting-started/AUTO-COMBO-GUIDE.md +++ b/docs/getting-started/AUTO-COMBO-GUIDE.md @@ -189,7 +189,7 @@ Use `auto/smart` when you want the best quality and are okay with occasional exp ### "Can I force a specific provider?" -Yes! Use a combo with `priority` strategy instead of `auto`. See the [Technical Reference](../routing/AUTO-COMBO.md) for details. +Yes! Use a combo with `priority` strategy instead of `auto`, then send the combo's **exact name** as the `model` field (e.g. `model: "my-combo"` — not `auto`). See the [Technical Reference](../routing/AUTO-COMBO.md) for details. ### "How is this different from round-robin?" diff --git a/docs/getting-started/PROVIDERS-GUIDE.md b/docs/getting-started/PROVIDERS-GUIDE.md index d54b656a8a..d6d20906c5 100644 --- a/docs/getting-started/PROVIDERS-GUIDE.md +++ b/docs/getting-started/PROVIDERS-GUIDE.md @@ -52,6 +52,8 @@ safely retry only the failures after a partial result. - **Pollinations** — Free GPT-5, Claude, Gemini (no key needed) - **LongCat** — 10M tokens free (one-time grant, requires account + KYC) - **Cloudflare AI** — 50+ models, 10K neurons/day + - **MLX Gemma 26B** — Local Apple Silicon model (~38.5 tok/s, ~15.9GB RAM) + - **MLX Qwen 3.8 27B** — Local Apple Silicon model (~9.1 tok/s, ~13.1GB RAM) 4. Click **Connect** 5. Done! You now have free AI access. @@ -79,6 +81,94 @@ safely retry only the failures after a partial result. 5. Login with your account 6. Done! You now have access to your subscription models. +### Option D: Local MLX Models (Apple Silicon) + +For Apple Silicon Macs with unified memory, OmniRoute supports connecting to local MLX models running via `mlx-lm.server` as regular OpenAI-compatible local providers. + +#### Prerequisites + +- **Apple Silicon Mac** (M1/M2/M3/M4) with 24GB+ unified memory recommended +- **uv** package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh` +- **mlx-lm**: `uv pip install mlx-lm` + +#### Quick Start + +1. **Install dependencies**: + + ```bash + # Install uv if not already installed + curl -LsSf https://astral.sh/uv/install.sh | sh + + # Install mlx-lm + uv pip install mlx-lm + ``` + +2. **Start MLX servers manually** (in separate terminals): + + ```bash + # Terminal 1: Gemma 4 26B A4B IT-QAT (port 11435) + uv run mlx_lm.server --model mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned --port 11435 --host 127.0.0.1 + + # Terminal 2: Qwen 3.8 27B MLX Mixed (port 11436) + uv run mlx_lm.server --model maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw --port 11436 --host 127.0.0.1 + ``` + +3. **Connect in OmniRoute Dashboard**: + - Go to **Providers** → **Add Provider** + - Select **MLX Gemma 26B** or **MLX Qwen 3.8 27B** + - Click **Connect** (no API key needed) + +4. **Use with OpenCode**: + ```bash + # Configure OpenCode to use OmniRoute + opencode config set api.base_url http://localhost:20128/v1 + opencode config set api.key + + # Use MLX models + opencode run --model mlx-gemma/gemma-4-26b + opencode run --model mlx-qwen/qwen3.8-27b + ``` + +#### Memory Management + +**Important**: With 24GB unified memory, only **one large MLX model can run at a time**. + +- Gemma 26B: ~15.9GB peak memory +- Qwen 3.8 27B: ~13.1GB peak memory + +You must manage this manually: + +- Run only one MLX server at a time, or +- Run both on separate machines, or +- Stop one before starting the other + +OmniRoute does not automatically manage MLX server processes — it only routes requests to the OpenAI-compatible endpoints you configure. + +#### Tool Calling Support + +Both models support OpenAI-compatible tool calling. Test with: + +```bash +curl -X POST http://localhost:20128/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "mlx-gemma/gemma-4-26b", + "messages": [{"role": "user", "content": "What is 2+2? Use the calculator tool."}], + "tools": [{"type": "function", "function": {"name": "calculator", "description": "Calculate", "parameters": {"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]}}}] + }' +``` + +#### Troubleshooting + +| Issue | Solution | +| ------------------ | ----------------------------------------------------------------------------- | +| Server won't start | Check `uv run mlx_lm.server --help` and verify model IDs | +| Out of memory | Ensure only one model runs; close other apps; check Activity Monitor | +| Connection refused | Verify server is running on correct port (11435/11436) | +| Slow responses | First request loads model into memory (~30-60s); subsequent requests are fast | +| Tool calling fails | Ensure model supports tools; check OmniRoute logs for translation errors | + --- ## Best Free Providers @@ -239,3 +329,7 @@ Go to Providers → click on the provider → click **Disconnect**. - **[Free Tiers Guide](./FREE-TIERS-GUIDE.md)** — Get free AI with no credit card - **[Troubleshooting](../guides/TROUBLESHOOTING.md)** — Fix common issues - **[Provider Reference](../reference/PROVIDER_REFERENCE.md)** — Full list of 226 providers + +## Cursor images + +Cursor plan images use `IMAGE_PROVIDERS.cursor` (`cursor-agent-image`). See [CURSOR_IMAGE.md](../providers/CURSOR_IMAGE.md). diff --git a/docs/getting-started/QUICK-START.md b/docs/getting-started/QUICK-START.md index d336934436..bed9fcb71e 100644 --- a/docs/getting-started/QUICK-START.md +++ b/docs/getting-started/QUICK-START.md @@ -26,6 +26,8 @@ npm install -g omniroute docker run -d --name omniroute -p 20128:20128 diegosouzapw/omniroute:latest ``` +`:latest` is the highest **published** stable SemVer. It does **not** track git `main`. Pin `diegosouzapw/omniroute:X.Y.Z` for GitOps. See [Image Tags / Release Channels](../guides/DOCKER_GUIDE.md#release-channels). + ### Option C: From Source ```bash diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 464aa55319..40487ee6cc 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -22,6 +22,7 @@ lastUpdated: 2026-06-28 - [Docker Compose with Caddy (HTTPS)](#docker-compose-with-caddy-https-auto-tls) - [Cloudflare Quick Tunnel](#cloudflare-quick-tunnel) - [Image Tags](#image-tags) +- [Availability: default SQLite is single-replica](#availability-default-sqlite-is-single-replica) - [Important Notes](#important-notes) --- @@ -341,13 +342,15 @@ For orchestrators (Kubernetes, Nomad, etc.): | Probe | Prefer | Avoid | | --- | --- | --- | -| Liveness | TCP on the main port (`PORT`, default `20128`), or soft HTTP `/healthz` | `/api/monitoring/health` as liveness | +| Liveness | HTTP `GET /livez`, or TCP on the main port (`PORT`, default `20128`) | `/api/monitoring/health` as liveness | | Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead | | Deep / blackbox | `/api/monitoring/health` | — | -`/healthz` only reports process lifecycle (`ok` / `starting` / `stopping`). It still -runs on the same Node event loop as request handling, so CPU-bound catalog or -compression work can delay it — busy ≠ dead. Full probe guidance: +`/healthz` reports process lifecycle (`ok` / `starting` / `stopping`). `/livez` is +process-alive only (200 whenever the handler can run; it does not wait for +readiness). Both still run on the same Node event loop as request handling, so +CPU-bound catalog or compression work can delay them — busy ≠ dead. Prefer TCP +liveness if HTTP probes time out. Full probe guidance: [Monitoring guide — Kubernetes probe recommendations](../ops/MONITORING_GUIDE.md#kubernetes-probe-recommendations). ## Docker Compose with Caddy (HTTPS Auto-TLS) @@ -409,8 +412,8 @@ Endpoint tunnel panels (Cloudflare, Tailscale, ngrok) can be shown or hidden fro | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | -| `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `3.8.0` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `latest` | ~250MB | Highest **published** stable SemVer (not git `main`) | +| `diegosouzapw/omniroute` | `3.8.0` | ~250MB | Pin this class of tag for GitOps | Multi-platform manifest: `linux/amd64` + `linux/arm64` native (Apple Silicon, AWS Graviton, Raspberry Pi). Docker selects the matching architecture automatically; pass `--platform linux/amd64` if you need to force AMD64 emulation on ARM hosts. @@ -421,7 +424,7 @@ OmniRoute publishes separate Docker channels for stable releases, active release | Channel | Source | Mutability | Recommended use | | ------------------------------- | ----------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- | | `:` / `:-web` | Signed/versioned release | Immutable | Production deployments that pin an exact release | -| `:latest` / `:latest-web` | Highest stable release | Mutable stable pointer | Production deployments that intentionally follow stable releases | +| `:latest` / `:latest-web` | Highest **published** stable SemVer | Mutable stable pointer | Follows stable releases **after** a SemVer publish job — does **not** track `main` or unreleased `release/v*` commits | | `:next` / `:next-web` | Current default `release/v*` branch | Mutable pre-release pointer | Testing fixes that have landed on the active release branch but are not yet in a stable release | | `:main` / `:main-web` | `main` branch | Mutable development pointer | Development and integration testing only | @@ -465,6 +468,37 @@ docker compose up -d A release-branch build can never move `latest`; only an eligible stable semantic version may promote the stable pointer. The `next` images retain the release image inspection and blocking CRITICAL-vulnerability gate. +**`latest` is not a currency guarantee for git.** Merged fixes on `main` or on the active `release/v*` branch are **not** in `:latest` until a stable SemVer image is published and the publish job promotes `:latest` (same digest as that SemVer). If `latest` looks frozen while GitHub already shows the fix, pull `:next` to test the release branch or wait for the SemVer tag. + +| You want | Use | +| --- | --- | +| GitOps / production that must not drift | Pin `:X.Y.Z` (or the image digest) | +| Follow published stables and accept a recreate on each release | `:latest` | +| Test unreleased `release/v*` commits | `:next` (not production) | +| Test `main` | `:main` (not production) | + +## Availability: default SQLite is single-replica + +Stock Docker / Kubernetes OmniRoute is **one Node process + one SQLite writer**. High availability is **not supported** on that topology. + +| Constraint | Consequence | +| --- | --- | +| Single writer | Do **not** run multiple replicas against the same SQLite file. That corrupts the DB. | +| Recreate / restart / HEALTHCHECK kill | **Full outage** of in-flight SSE, dashboard sessions, and in-memory state. Every connected client drops. | +| Same event loop as `/healthz` | A busy catalog or compression tick can delay probes; a short timeout then restarts the **only** replica. | + +**Probe matrix** (see also [Kubernetes probe recommendations](../ops/MONITORING_GUIDE.md#kubernetes-probe-recommendations)): + +| Probe | Target | Do not use | +| --- | --- | --- | +| Liveness | TCP on `PORT` (default `20128`), or soft HTTP `/healthz` | `/api/monitoring/health` | +| Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead | +| Deep / humans | `/api/monitoring/health` | Automated kubelet liveness | + +**Upgrades:** expect every session to drop. Drain clients if you can; there is no rolling update on default SQLite. Compose `restart: unless-stopped` plus Docker `HEALTHCHECK` will also replace the only process when the container is Unhealthy — same blast radius. + +External Postgres / multi-writer HA is **not** a documented stock path. If you need HA, keep a single replica or run a topology the project has tested and documented separately. + ## Important Notes - **SQLite WAL Mode:** `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40`. diff --git a/docs/guides/FREE_PROVIDER_RANKINGS.md b/docs/guides/FREE_PROVIDER_RANKINGS.md index 7eb49f4660..79d2ba6ed8 100644 --- a/docs/guides/FREE_PROVIDER_RANKINGS.md +++ b/docs/guides/FREE_PROVIDER_RANKINGS.md @@ -169,7 +169,7 @@ stale data. The sync runs **on by default**: - It runs once at server startup and then on a periodic timer - (`src/lib/arenaEloSync.ts`, wired from `src/server-init.ts`). + (`src/lib/arenaEloSync.ts`, wired from `src/instrumentation-node.ts`). - It is **non-blocking and never fatal** — if the upstream fetch fails, OmniRoute keeps running and the rankings simply show the last good data (or an empty state). diff --git a/docs/guides/MANAGEMENT-AUTH.md b/docs/guides/MANAGEMENT-AUTH.md index 25e0d7ae59..5e0d8d6eeb 100644 --- a/docs/guides/MANAGEMENT-AUTH.md +++ b/docs/guides/MANAGEMENT-AUTH.md @@ -1,47 +1,159 @@ --- title: "Management Authentication" version: 3.8.50 -lastUpdated: 2026-08-05 +lastUpdated: 2026-08-20 --- # Management Authentication -OmniRoute uses four distinct credential families for management access. This guide -distinguishes them by purpose, scope, and locality. +OmniRoute has **four credential families** that can authorize management routes. +They are not interchangeable. Inference API keys (`sk-…`) do **not** manage the +server unless they were explicitly granted `manage` or `admin` scope. -| Credential | Scope | Locality | Use Case | -|-------------------------|--------------------|---------------|-----------------------------------| -| Dashboard JWT session | Full management | Localhost | Web dashboard login | -| CLI machine-id token | Full management | Per-machine | `omniroute` CLI commands | -| Scoped `oma_` token | Configurable scope | External | Automation / CI / API access | -| Manage-scope API key | `manage` scope | External | Management API calls | +Canonical implementation: `src/lib/api/requireManagementAuth.ts`. -## Dashboard JWT Session +| Credential | Typical form | Created where | Intended use | Management capability | +|---|---|---|---|---| +| Dashboard JWT session | `auth_token` cookie | Dashboard login | Browser UI | Full dashboard management, subject to CSRF, locality, and always-protected-route rules | +| CLI machine-id token | internal / local | CLI bootstrap (`omniroute` on the same machine) | Local CLI | Local management only | +| Scoped Access Token | `oma_live_…` | **Settings → Access Tokens** or `omniroute connect` | Remote CLI and management API | Must satisfy the route's required `read`, `write`, or `admin` scope | +| Inference API key | `sk-…` (and other API-key prefixes) | **API Manager / API Keys** | `/v1/*` inference | **None** unless the key metadata includes `manage` or `admin` | -Generated on dashboard login (`/api/auth/login`). Stored in HTTP-only cookie. -Valid for the session duration. Cannot be used from external hosts. +`oma_` credentials are management/CLI credentials. They are **not** inference API keys. -## CLI Machine-ID Token +If login/API-key auth is disabled for the server, some management routes may +accept unauthenticated calls. Local-only and always-protected routes still apply +their own rules. Presenting one of these credentials is therefore not universally +mandatory, and possessing one is not universally sufficient without the required +scope and route locality. -Created by `omniroute auth login` on first use. Stored in `~/.omniroute/auth.json`. -Used by the CLI for all management operations. Tied to the machine identity. +Related: [Remote Mode](./REMOTE-MODE.md) (how `oma_live_…` is minted for a remote CLI). -## Scoped `oma_` Access Token +--- -Created via dashboard or CLI with configurable scopes (e.g., `manage`, `read`). -Format: `oma_`. Used for programmatic access from external systems. +## Scope matrices -## Manage-Scope API Key +These two scope vocabularies are **different**. Do not mix them. -Standard API key with the `manage` scope enabled. Created in dashboard API Keys page. -Used for management API calls from external hosts. +### Access Token scopes (`oma_live_…`) -## Header Examples +| Scope | Typical operations | +|---|---| +| `read` | List/status GETs that the token is allowed to see | +| `write` | Mutations (create/update/delete) below admin | +| `admin` | Full remote CLI / connect token (password bootstrap defaults here) | -``` -Authorization: Bearer oma_abc123def456 -Authorization: Bearer -Cookie: omniroute_session= +A token with `read` cannot call a `write` route. Runtime message shape: +`Access token scope '' is insufficient; '' required.` + +### API-key management scopes + +| Scope | Meaning | +|---|---| +| (none) | Inference only. Management routes return 403. | +| `manage` | Management API (same gate as `requireManagementAuth` API-key branch) | +| `admin` | Also satisfies `hasManageScope` (treated as management-capable) | + +Enable `manage` on the key in the API Keys / API Manager UI. Do not reuse a +chat client key for automation unless you deliberately granted that scope. + +--- + +## How to create and revoke + +### Dashboard JWT session + +1. Open `/login`, sign in with the management password (`INITIAL_PASSWORD` on first boot). +2. Cookie `auth_token` is HttpOnly. Browser dashboard uses it automatically. +3. Log out via `/api/auth/logout`. There is no long-lived secret to copy. + +### CLI machine-id token + +1. Run `omniroute` on the **same host** as the server (loopback). +2. The CLI bootstraps a machine-id token under `~/.omniroute/` (chmod 600). +3. This does **not** work from another machine. Use an Access Token for remote CLI. + +### Scoped Access Token (`oma_live_…`) + +1. Dashboard: **Settings → Access Tokens** → create (name + scope). **The secret is shown once.** +2. Or CLI: `omniroute connect ` (password → token). See [Remote Mode](./REMOTE-MODE.md). +3. Header: `Authorization: Bearer oma_live_…` +4. Revoke from the same Access Tokens page (or delete the CLI context). +5. Server stores only a hash. Treat the plaintext like a password. + +### Manage-scoped API key + +1. Dashboard: **API Manager / API Keys** → create or edit a key → enable `manage` (or `admin`). +2. Header: `Authorization: Bearer sk-…` (the key's actual prefix). +3. Revoke or strip `manage` in the same UI. +4. Least privilege for automation that is not the CLI: prefer a `read` Access Token for GET-only jobs; use `manage` on an API key only when the caller must also speak `/v1` and management. + +--- + +## Header format + +```http +Authorization: Bearer oma_live_ +Authorization: Bearer sk- +Cookie: auth_token= ``` -See `docs/reference/API_REFERENCE.md` for endpoint-specific auth requirements. +Do not put management credentials in the URL path or query string. Management +auth is header/cookie only. + +--- + +## Copy-paste examples + +Read-only (list providers). Use a `read` Access Token: + +```bash +curl -sS "$OMNIROUTE_URL/api/providers" \ + -H "Authorization: Bearer oma_live_" +``` + +Modifying (create a provider connection). Use `write`/`admin` Access Token or a +manage-scoped API key: + +```bash +curl -sS -X POST "$OMNIROUTE_URL/api/providers" \ + -H "Authorization: Bearer oma_live_" \ + -H "Content-Type: application/json" \ + -d '{"provider":"openai","apiKey":""}' +``` + +Inference (not management). Ordinary API key, no `manage` required: + +```bash +curl -sS "$OMNIROUTE_URL/v1/models" \ + -H "Authorization: Bearer sk-" +``` + +--- + +## Current runtime errors (do not echo secrets) + +| Situation | Typical status | Message (sanitized) | +|---|---|---| +| No credential | 401 | `Authentication required` | +| Invalid/expired `oma_live_…` | 401 | `Invalid or expired access token` | +| Valid API key without `manage`/`admin` | 403 | `API key lacks 'manage' scope. Enable it in the API Keys dashboard.` | +| Invalid ordinary API key on a management route | 403 | `Invalid management token` | +| Access Token scope too low | 403 | `Access token scope '' is insufficient; '' required.` | + +"Invalid management token" means the bearer was **not** accepted as a management +credential. It does **not** tell you which family to mint. Use the table above: +inference keys need `manage` scope; remote CLI needs `oma_live_…`; the dashboard +uses the session cookie. + +--- + +## Recommended least-privilege choice + +| Caller | Use | +|---|---| +| Browser | Dashboard session | +| CLI on the server host | Machine token | +| CLI on a laptop talking to a remote server | `oma_live_…` from `omniroute connect` | +| CI / scripts (management only) | `oma_live_…` with the smallest scope that works | +| CI that must call both `/v1` and `/api` | API key with `manage` **or** two credentials | diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index d5a97ec33a..53dfd1c0d6 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 6116eb395e..cf0018a415 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 6116eb395e..cf0018a415 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index 18ab0fc537..2749b7b108 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index 08f8297c10..76a283564b 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index 531f324c3a..5bcb53ef10 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index dfefdf04bf..17cb4a70bf 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index cd36f88e07..0441be519d 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/fa/README.md b/docs/i18n/fa/README.md index beab2b5e95..15cb5ef09a 100644 --- a/docs/i18n/fa/README.md +++ b/docs/i18n/fa/README.md @@ -1,14 +1,14 @@ -# 🚀 OmniRoute — The Free AI Gateway (فارسی) +# 🚀 OmniRoute — درگاه رایگان هوش مصنوعی (فارسی) -🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇸🇦 [ar](../ar/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇧🇩 [bn](../bn/README.md) · 🇨🇿 [cs](../cs/README.md) · 🇩🇰 [da](../da/README.md) · 🇩🇪 [de](../de/README.md) · 🇪🇸 [es](../es/README.md) · 🇮🇷 [fa](../fa/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇮🇳 [gu](../gu/README.md) · 🇮🇱 [he](../he/README.md) · 🇮🇳 [hi](../hi/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇮🇩 [id](../id/README.md) · 🇮🇹 [it](../it/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇮🇳 [mr](../mr/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇳🇴 [no](../no/README.md) · 🇵🇭 [phi](../phi/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇰🇪 [sw](../sw/README.md) · 🇮🇳 [ta](../ta/README.md) · 🇮🇳 [te](../te/README.md) · 🇹🇭 [th](../th/README.md) · 🇹🇷 [tr](../tr/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇵🇰 [ur](../ur/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) +🌐 **زبان‌ها:** 🇺🇸 [English](../../../README.md) · 🇸🇦 [ar](../ar/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇧🇩 [bn](../bn/README.md) · 🇨🇿 [cs](../cs/README.md) · 🇩🇰 [da](../da/README.md) · 🇩🇪 [de](../de/README.md) · 🇪🇸 [es](../es/README.md) · 🇮🇷 [fa](../fa/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇮🇳 [gu](../gu/README.md) · 🇮🇱 [he](../he/README.md) · 🇮🇳 [hi](../hi/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇮🇩 [id](../id/README.md) · 🇮🇹 [it](../it/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇮🇳 [mr](../mr/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇳🇴 [no](../no/README.md) · 🇵🇭 [phi](../phi/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇰🇪 [sw](../sw/README.md) · 🇮🇳 [ta](../ta/README.md) · 🇮🇳 [te](../te/README.md) · 🇹🇭 [th](../th/README.md) · 🇹🇷 [tr](../tr/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇵🇰 [ur](../ur/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) --- -### Keep coding through provider limits. Smart routing to free-access and low-cost AI models with automatic fallback. +### کدنویسی را از طریق محدودیت های ارائه دهنده ادامه دهید. مسیریابی هوشمند به مدل‌های هوش مصنوعی با دسترسی رایگان و کم‌هزینه با بازگشت خودکار. -_Your universal API proxy — one endpoint, 329 provider catalog entries, resilient fallback subject to upstream availability. Includes **MCP Server (107 tools, 32 scopes)**, **A2A Protocol**, **Memory/Skills Systems** & **Electron Desktop App**._ +_پراکسی جهانی API شما - یک نقطه پایانی، 329 ورودی کاتالوگ ارائه‌دهنده، بازگشت انعطاف‌پذیر به شرط در دسترس بودن بالادست. شامل **سرور MCP (107 ابزار، 32 دامنه)**، **پروتکل A2A**، **سیستم های حافظه/مهارت** و **برنامه دسکتاپ الکترونیک**._ -**Chat Completions • Embeddings • Image Generation • Video • Music • Audio • Reranking • **Web Search** • MCP Server • A2A Protocol • 100% TypeScript** +** تکمیل چت • جاسازی ها • تولید تصویر • ویدئو • موسیقی • صدا • رتبه بندی مجدد • **جستجوی وب** • سرور MCP • پروتکل A2A • 100% TypeScript** --- @@ -17,12 +17,12 @@ _Your universal API proxy — one endpoint, 329 provider catalog entries, resili [![npm version](https://img.shields.io/npm/v/omniroute?color=cb3837&logo=npm)](https://www.npmjs.com/package/omniroute) [![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute) -![NPM Downloads](https://img.shields.io/npm/dw/omniroute?label=npm%20down%20week&color=red) -![NPM Downloads](https://img.shields.io/npm/dm/omniroute?label=npm%20down%20month&color=red) +![بارگیری‌های NPM](https://img.shields.io/npm/dw/omniroute?label=npm%20down%20week&color=red) +![بارگیری‌های NPM](https://img.shields.io/npm/dm/omniroute?label=npm%20down%20month&color=red) -![NPM Downloads](https://img.shields.io/npm/d18m/omniroute?label=npm%20down%20year&color=red) +![بارگیری‌های NPM](https://img.shields.io/npm/d18m/omniroute?label=npm%20down%20year&color=red) ![Docker Pulls](https://img.shields.io/docker/pulls/diegosouzapw/omniroute) -![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/diegosouzapw/omniroute/total?style=flat&label=eletron%20donwloads&color=blue) +![بارگیری‌های GitHub (همه دارایی‌ها، همه نسخه‌ها)](https://img.shields.io/github/downloads/diegosouzapw/omniroute/total?style=flat&label=eletron%20donwloads&color=blue) [![stars](https://custom-icon-badges.demolab.com/github/stars/diegosouzapw/OmniRoute?logo=star&style=flat)](https://github.com/diegosouzapw/OmniRoute/stargazers) [![open issues](https://custom-icon-badges.demolab.com/github/issues-raw/diegosouzapw/OmniRoute?logo=issue)](https://github.com/diegosouzapw/OmniRoute/issues) @@ -45,11 +45,11 @@ _Your universal API proxy — one endpoint, 329 provider catalog entries, resili
-🌐 **Available in:** 🇺🇸 [English](README.md) | 🇧🇷 [Português (Brasil)](docs/i18n/pt-BR/README.md) | 🇪🇸 [Español](docs/i18n/es/README.md) | 🇫🇷 [Français](docs/i18n/fr/README.md) | 🇮🇹 [Italiano](docs/i18n/it/README.md) | 🇷🇺 [Русский](docs/i18n/ru/README.md) | 🇨🇳 [中文 (简体)](docs/i18n/zh-CN/README.md) | 🇩🇪 [Deutsch](docs/i18n/de/README.md) | 🇮🇳 [हिन्दी](docs/i18n/in/README.md) | 🇹🇭 [ไทย](docs/i18n/th/README.md) | 🇺🇦 [Українська](docs/i18n/uk-UA/README.md) | 🇸🇦 [العربية](docs/i18n/ar/README.md) | 🇯🇵 [日本語](docs/i18n/ja/README.md) | 🇻🇳 [Tiếng Việt](docs/i18n/vi/README.md) | 🇧🇬 [Български](docs/i18n/bg/README.md) | 🇩🇰 [Dansk](docs/i18n/da/README.md) | 🇫🇮 [Suomi](docs/i18n/fi/README.md) | 🇮🇱 [עברית](docs/i18n/he/README.md) | 🇭🇺 [Magyar](docs/i18n/hu/README.md) | 🇮🇩 [Bahasa Indonesia](docs/i18n/id/README.md) | 🇰🇷 [한국어](docs/i18n/ko/README.md) | 🇲🇾 [Bahasa Melayu](docs/i18n/ms/README.md) | 🇳🇱 [Nederlands](docs/i18n/nl/README.md) | 🇳🇴 [Norsk](docs/i18n/no/README.md) | 🇵🇹 [Português (Portugal)](docs/i18n/pt/README.md) | 🇷🇴 [Română](docs/i18n/ro/README.md) | 🇵🇱 [Polski](docs/i18n/pl/README.md) | 🇸🇰 [Slovenčina](docs/i18n/sk/README.md) | 🇸🇪 [Svenska](docs/i18n/sv/README.md) | 🇵🇭 [Filipino](docs/i18n/phi/README.md) | 🇨🇿 [Čeština](docs/i18n/cs/README.md) +🌐 **موجود در:** 🇺🇸 [انگلیسی](README.md) | 🇧🇷 [پرتغال (برزیل)](docs/i18n/pt-BR/README.md) | 🇪🇸 [اسپانیول](docs/i18n/es/README.md) | 🇫🇷 [Français](docs/i18n/fr/README.md) | 🇮🇹 [ایتالیانو](docs/i18n/it/README.md) | 🇷🇺 [Русский](docs/i18n/ru/README.md) | 🇨🇳 [中文 (简体)](docs/i18n/zh-CN/README.md) | 🇩🇪 [دویچ](docs/i18n/de/README.md) | 🇮🇳 [हिन्दी](docs/i18n/in/README.md) | 🇹🇭 [ไทย](docs/i18n/th/README.md) | 🇺🇦 [Українська](docs/i18n/uk-UA/README.md) | 🇸🇦 [العربیة](docs/i18n/ar/README.md) | 🇯🇵 [日本語](docs/i18n/ja/README.md) | 🇻🇳 [Tiếng Việt](docs/i18n/vi/README.md) | 🇧🇬 [Български](docs/i18n/bg/README.md) | 🇩🇰 [دانسک](docs/i18n/da/README.md) | 🇫🇮 [Suomi](docs/i18n/fi/README.md) | 🇮🇱 [עברית](docs/i18n/he/README.md) | 🇭🇺 [مگیار](docs/i18n/hu/README.md) | 🇮🇩 [Bahasa Indonesia](docs/i18n/id/README.md) | 🇰🇷 [한국어](docs/i18n/ko/README.md) | 🇲🇾 [Bahasa Melayu](docs/i18n/ms/README.md) | 🇳🇱 [هلند](docs/i18n/nl/README.md) | 🇳🇴 [نورسک](docs/i18n/no/README.md) | 🇵🇹 [پرتغال (پرتغال)](docs/i18n/pt/README.md) | 🇷🇴 [Română](docs/i18n/ro/README.md) | 🇵🇱 [Polski](docs/i18n/pl/README.md) | 🇸🇰 [Slovenčina](docs/i18n/sk/README.md) | 🇸🇪 [Svenska](docs/i18n/sv/README.md) | 🇵🇭 [فیلیپینی](docs/i18n/phi/README.md) | 🇨🇿 [Čeština](docs/i18n/cs/README.md) --- -## 🖼️ Main Dashboard +## 🖼️ داشبورد اصلی
OmniRoute Dashboard @@ -57,30 +57,30 @@ _Your universal API proxy — one endpoint, 329 provider catalog entries, resili --- -## 📸 Dashboard Preview +## 📸 پیش نمایش داشبورد
Click to see dashboard screenshots -| Page | Screenshot | +| صفحه | اسکرین شات | | -------------- | ------------------------------------------------- | -| **Providers** | ![Providers](docs/screenshots/01-providers.png) | -| **Combos** | ![Combos](docs/screenshots/02-combos.png) | -| **Analytics** | ![Analytics](docs/screenshots/03-analytics.png) | -| **Health** | ![Health](docs/screenshots/04-health.png) | -| **Translator** | ![Translator](docs/screenshots/05-translator.png) | -| **Settings** | ![Settings](docs/screenshots/06-settings.png) | -| **CLI Tools** | ![CLI Tools](docs/screenshots/07-cli-tools.png) | -| **Usage Logs** | ![Usage](docs/screenshots/08-usage.png) | -| **Endpoints** | ![Endpoints](docs/screenshots/09-endpoint.png) | +| **ارائه دهندگان** | ![ارائه دهندگان](docs/screenshots/01-providers.png) | +| **ترکیب** | ![Combos](docs/screenshots/02-combos.png) | +| **تحلیل** | ![Analytics](docs/screenshots/03-analytics.png) | +| **سلامت** | ![سلامت](docs/screenshots/04-health.png) | +| **مترجم** | ![مترجم](docs/screenshots/05-translator.png) | +| **تنظیمات** | ![تنظیمات](docs/screenshots/06-settings.png) | +| **ابزار CLI** | ![ابزار CLI](docs/screenshots/07-cli-tools.png) | +| ** سیاهههای استفاده ** | ![استفاده](docs/screenshots/08-usage.png) | +| **نقاط پایانی** | ![نقاط پایانی](docs/screenshots/09-endpoint.png) |
--- -### 🤖 Free AI Provider for your favorite coding agents +### 🤖 ارائه دهنده رایگان هوش مصنوعی برای عوامل برنامه نویسی مورد علاقه شما -_Connect any AI-powered IDE or CLI tool through OmniRoute — free-access AI gateway; provider limits and terms apply._ +_هر ابزار IDE یا CLI مجهز به هوش مصنوعی را از طریق OmniRoute - دروازه هوش مصنوعی با دسترسی آزاد وصل کنید. محدودیت ها و شرایط ارائه دهنده اعمال می شود._ @@ -138,14 +138,14 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free-access AI gat @@ -156,46 +156,46 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free-access AI gat --- -## 🤔 Why OmniRoute? +## 🤔 چرا OmniRoute؟ -**Stop wasting money and hitting limits:** +**از هدر دادن پول و رسیدن به محدودیت ها جلوگیری کنید:** -- Subscription quota expires unused every month -- Rate limits stop you mid-coding -- Expensive APIs ($20-50/month per provider) -- Manual switching between providers +- سهمیه اشتراک بدون استفاده هر ماه منقضی می شود +- محدودیت های نرخ شما را در میانه کدنویسی متوقف می کند +- API های گران قیمت (20-50 دلار در ماه برای هر ارائه دهنده) +- تعویض دستی بین ارائه دهندگان -**OmniRoute solves this:** +**OmniRoute این مشکل را حل می کند:** -- ✅ **Maximize subscriptions** - Track quota, use every bit before reset -- ✅ **Auto fallback** - Subscription → API Key → Cheap → Free; availability depends on eligible upstream routes -- ✅ **Multi-account** - Round-robin between accounts per provider +- ✅ ** اشتراک ها را به حداکثر برسانید ** - سهمیه را پیگیری کنید، از هر بیت قبل از تنظیم مجدد استفاده کنید +- ✅ ** بازگشت خودکار ** - اشتراک → کلید API → ارزان → رایگان. در دسترس بودن بستگی به مسیرهای واجد شرایط بالادست دارد +- ✅ **چند حساب ** - دور برگشت بین حساب ها در هر ارائه دهنده --- -## 📧 Support +## 📧 پشتیبانی -> 💬 **Join our community!** [WhatsApp Group](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Get help, share tips, and stay updated. +> 💬 **به انجمن ما بپیوندید!** [گروه واتساپ](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) - راهنمایی دریافت کنید، نکات را به اشتراک بگذارید و به روز بمانید. -- **Website**: [omniroute.online](https://omniroute.online) +- **وب سایت**: [omniroute.online](https://omniroute.online) - **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute) -- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues) -- **WhatsApp**: [Community Group](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) -- **Contributing**: See [CONTRIBUTING.md](CONTRIBUTING.md), open a PR, or pick a `good first issue` +- **مشکلات**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues) +- **WhatsApp**: [گروه انجمن](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) +- **مشارکت**: به [CONTRIBUTING.md](CONTRIBUTING.md) مراجعه کنید، یک PR باز کنید، یا یک `good first issue` انتخاب کنید -### 🐛 Reporting a Bug? +### 🐛 یک اشکال را گزارش می کنید؟ -When opening an issue, please run the system-info command and attach the generated file: +هنگام باز کردن یک مشکل، لطفاً دستور system-info را اجرا کنید و فایل تولید شده را پیوست کنید: ```bash npm run system-info ``` -This generates a `system-info.txt` with your Node.js version, OmniRoute version, OS details, installed CLI tools (qoder, gemini, claude, codex, antigravity, droid, etc.), Docker/PM2 status, and system packages — everything we need to reproduce your issue quickly. Attach the file directly to your GitHub issue. +این فرمان فایل `system-info.txt` را شامل نسخه Node.js و OmniRoute، جزئیات سیستم‌عامل، ابزارهای CLI نصب‌شده (qoder، gemini، claude، codex، antigravity، droid و غیره)، وضعیت Docker/PM2 و بسته‌های سیستم تولید می‌کند؛ هرآنچه برای بازتولید سریع مشکل لازم است. فایل را مستقیماً به گزارش مشکل GitHub پیوست کنید. --- -## 🔄 How It Works +## 🔄 چگونه کار می کند ``` ┌─────────────┐ @@ -224,467 +224,467 @@ Result: broader fallback coverage and cost control; availability is not guarante --- -## 🎯 What OmniRoute Solves — 30 Real Pain Points & Use Cases +## 🎯 آنچه OmniRoute حل می کند - 30 نقطه درد واقعی و موارد استفاده -> **Every developer using AI tools faces these problems daily.** OmniRoute was built to solve them all — from cost overruns to regional blocks, from broken OAuth flows to protocol operations and enterprise observability. +> **هر برنامه‌نویسی که از ابزارهای هوش مصنوعی استفاده می‌کند، روزانه با این مشکلات روبرو می‌شود. ** OmniRoute برای حل همه آنها ساخته شده است - از مازاد هزینه تا بلوک‌های منطقه‌ای، از جریان‌های شکسته OAuth تا عملیات پروتکل و قابلیت مشاهده سازمانی.
-💸 1. "I pay for an expensive subscription but still get interrupted by limits" +💸 ۱. «برای اشتراک گران‌قیمت پول می‌دهم، اما محدودیت‌ها همچنان کارم را قطع می‌کنند» -Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Even paying, quota has a ceiling — 5h of usage, weekly limits, or per-minute rate limits. Mid-coding session, the provider stops responding and the developer loses flow and productivity. +توسعه‌دهندگان 20 تا 200 دلار در ماه برای Claude Pro، Codex Pro، یا GitHub Copilot می‌پردازند. حتی با پرداخت، سهمیه سقفی دارد - 5 ساعت استفاده، محدودیت های هفتگی یا محدودیت نرخ در دقیقه. در اواسط جلسه کدنویسی، ارائه دهنده پاسخ نمی دهد و توسعه دهنده جریان و بهره وری را از دست می دهد. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Smart 4-Tier Fallback** — If subscription quota runs out, automatically redirects to API Key → Cheap → Free with zero manual intervention -- **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI -- **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next -- **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) -- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets -- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use -- **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard +- ** بازگشت هوشمند 4-سطحی ** - اگر سهمیه اشتراک تمام شود، به طور خودکار به کلید API هدایت می شود → ارزان → رایگان با دخالت دستی صفر +- **ردیابی محدودیت های ارائه دهنده** - عکس های لحظه ای سهمیه ذخیره شده در حافظه پنهان در یک برنامه زمانی سمت سرور (پیش فرض `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) با بازخوانی دستی موجود در رابط کاربری بازخوانی می شوند. +- **پشتیبانی چند حساب** - چندین حساب در هر ارائه دهنده با چرخش خودکار - وقتی یکی تمام شد، به حساب بعدی تغییر می کند +- **ترکیب های سفارشی** - زنجیره های بازگشتی قابل تنظیم با 13 استراتژی متعادل کننده (اولویت، وزن، پر کردن، دور روبین، P2C، تصادفی، کم استفاده، بهینه سازی هزینه، تصادفی دقیق، خودکار، lkgp، بهینه سازی زمینه، **رله زمینه**) +- **سازگار ترکیبی ساختاریافته** - ساخت ترکیبی گام به گام با ارائه دهنده صریح + مدل + انتخاب حساب، از جمله ارائه دهندگان مکرر و اهداف حساب ثابت +- **Quota-Aware P2C** - انتخاب اکانت قدرت از دو در حال حاضر سهمیه حجم، عقب نشینی، خطاهای اخیر و استفاده متوالی را فاکتور می کند. +- ** Codex سهمیه های تجاری ** - نظارت بر سهمیه فضای کاری تجاری/تیم به طور مستقیم در داشبورد
-🔌 2. "I need to use multiple providers but each has a different API" +🔌 ۲. «به چند ارائه‌دهنده نیاز دارم، اما هرکدام API متفاوتی دارند» -OpenAI uses one format, Claude (Anthropic) uses another, Gemini yet another. If a dev wants to test models from different providers or fallback between them, they need to reconfigure SDKs, change endpoints, deal with incompatible formats. Custom providers (FriendLI, NIM) have non-standard model endpoints. +OpenAI از یک قالب استفاده می کند، Claude (انتروپیک) از فرمت دیگری، جمینی از فرمت دیگری استفاده می کند. اگر یک برنامه‌نویس بخواهد مدل‌هایی را از ارائه‌دهندگان مختلف آزمایش کند یا بین آن‌ها بازگشتی ایجاد کند، باید SDK‌ها را دوباره پیکربندی کند، نقاط پایانی را تغییر دهد، با فرمت‌های ناسازگار برخورد کند. ارائه دهندگان سفارشی (FriendLI، NIM) دارای نقاط پایانی مدل غیر استاندارد هستند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Unified Endpoint** — A single `http://localhost:20128/v1` serves as proxy for all 329 provider catalog entries -- **Format Translation** — Automatic and transparent: OpenAI ↔ Claude ↔ Gemini ↔ Responses API -- **Response Sanitization** — Strips non-standard fields (`x_groq`, `usage_breakdown`, `service_tier`) that break OpenAI SDK v1.83+ -- **Role Normalization** — Converts `developer` → `system` for non-OpenAI providers; `system` → `user` for GLM/ERNIE -- **Think Tag Extraction** — Extracts `` blocks from models like DeepSeek R1 into standardized `reasoning_content` -- **Structured Output for Gemini** — `json_schema` → `responseMimeType`/`responseSchema` automatic conversion -- **`stream` defaults to `false`** — Aligns with OpenAI spec, avoiding unexpected SSE in Python/Rust/Go SDKs +- **نقطه پایانی یکپارچه** - یک `http://localhost:20128/v1` به عنوان پروکسی برای تمام 329 ورودی کاتالوگ ارائه دهنده عمل می کند +- **ترجمه فرمت** — خودکار و شفاف: OpenAI ↔ Claude ↔ Gemini ↔ پاسخ ها API +- **عفونی‌سازی پاسخ** - فیلدهای غیر استاندارد (`x_groq`، `usage_breakdown`، `service_tier`) را که OpenAI SDK v1.83+ را می‌شکنند، حذف می‌کند. +- ** عادی سازی نقش ** - تبدیل `developer` → `system` برای ارائه دهندگان غیر OpenAI. `system` → `user` برای GLM/ERNIE +- **Think Tag Extraction** - بلوک های `` را از مدل هایی مانند DeepSeek R1 به استاندارد `reasoning_content` استخراج می کند +- **خروجی ساختاریافته برای Gemini** — تبدیل خودکار `json_schema` → `responseMimeType`/`responseSchema` +- **`stream` به طور پیش فرض روی `false`** تنظیم می شود - با مشخصات OpenAI همسو می شود، از SSE غیرمنتظره در SDK های Python/Rust/Go اجتناب می کند
-🌐 3. "My AI provider blocks my region/country" +🌐 ۳. «ارائه‌دهندهٔ هوش مصنوعی، منطقه یا کشور من را مسدود می‌کند» -Providers like OpenAI/Codex block access from certain geographic regions. Users get errors like `unsupported_country_region_territory` during OAuth and API connections. This is especially frustrating for developers from developing countries. +ارائه دهندگانی مانند OpenAI/Codex دسترسی از مناطق جغرافیایی خاص را مسدود می کنند. کاربران در طول اتصالات OAuth و API خطاهایی مانند `unsupported_country_region_territory` دریافت می کنند. این امر به ویژه برای توسعه دهندگان کشورهای در حال توسعه ناامید کننده است. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **3-Level Proxy Config** — Configurable proxy at 3 levels: global (all traffic), per-provider (one provider only), and per-connection/key -- **Color-Coded Proxy Badges** — Visual indicators: 🟢 global proxy, 🟡 provider proxy, 🔵 connection proxy, always showing the IP -- **OAuth Token Exchange Through Proxy** — OAuth flow also goes through the proxy, solving `unsupported_country_region_territory` -- **Connection Tests via Proxy** — Connection tests use the configured proxy (no more direct bypass) -- **SOCKS5 Support** — Full SOCKS5 proxy support for outbound routing -- **TLS Fingerprint Spoofing** — Browser-like TLS fingerprint via `wreq-js` to bypass bot detection -- **🔏 CLI Fingerprint Matching** — Reorders headers and body fields to match native CLI binary signatures, drastically reducing account flagging risk. The proxy IP is preserved — you get both stealth **and** IP masking simultaneously +- ** پیکربندی پروکسی 3 سطح ** - پروکسی قابل تنظیم در 3 سطح: جهانی (تمام ترافیک)، هر ارائه دهنده (فقط یک ارائه دهنده) و هر اتصال/کلید +- **نشانهای پروکسی با کد رنگی** - نشانگرهای تصویری: پروکسی جهانی، پروکسی ارائه دهنده، 🔵 پروکسی اتصال، همیشه IP را نشان می دهد +- ** تبادل رمز OAuth از طریق پروکسی** — جریان OAuth نیز از طریق پروکسی می رود و `unsupported_country_region_territory` را حل می کند +- **تست های اتصال از طریق پروکسی** - تست های اتصال از پروکسی پیکربندی شده استفاده می کنند (دیگر دور زدن مستقیم وجود ندارد) +- **پشتیبانی SOCKS5** - پشتیبانی کامل از پروکسی SOCKS5 برای مسیریابی خروجی +- **تقلب اثر انگشت TLS** - اثر انگشت TLS مرورگر مانند از طریق `wreq-js` برای دور زدن تشخیص ربات +- ** IP پروکسی حفظ می شود - شما هر دو پنهان **و** IP را به طور همزمان دریافت می کنید
-🆓 4. "I want to use AI for coding but I have no money" +🆓 ۴. «می‌خواهم برای برنامه‌نویسی از هوش مصنوعی استفاده کنم، اما بودجه‌ای ندارم» -Not everyone can pay $20–200/month for AI subscriptions. Students, devs from emerging countries, hobbyists, and freelancers need access to quality models at zero cost. +همه نمی توانند 20 تا 200 دلار در ماه برای اشتراک هوش مصنوعی بپردازند. دانش‌آموزان، توسعه‌دهندگان کشورهای نوظهور، علاقه‌مندان و مشاغل آزاد نیاز به دسترسی به مدل‌های باکیفیت با هزینه صفر دارند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` with free "Light usage" tier; use `ollamacloud/` prefix -- **Free-Only Combos** — Chain `if/kimi-k2-thinking → qw/qwen3-coder-plus` can use currently listed $0 access; limits and availability apply -- **NVIDIA NIM Free Access** — ~40 RPM free access as currently listed; provider terms and model availability apply at build.nvidia.com (transitioning from credits to pure rate limits) -- **Cost Optimized Strategy** — Routing strategy that automatically chooses the cheapest available provider +- **Ollama Cloud** - مدل های Ollama میزبان ابر در `api.ollama.com` با سطح رایگان "استفاده از نور". از پیشوند `ollamacloud/` استفاده کنید +- **ترکیب‌های فقط رایگان** - زنجیره `if/kimi-k2-thinking → qw/qwen3-coder-plus` می‌تواند از دسترسی $0 فهرست‌شده فعلی استفاده کند. محدودیت ها و در دسترس بودن اعمال می شود +- **NVIDIA NIM دسترسی رایگان ** — دسترسی آزاد ~40 RPM همانطور که در حال حاضر لیست شده است. شرایط ارائه دهنده و در دسترس بودن مدل در build.nvidia.com اعمال می شود (انتقال از اعتبار به محدودیت نرخ خالص) +- **استراتژی بهینه سازی هزینه** - استراتژی مسیریابی که به طور خودکار ارزان ترین ارائه دهنده موجود را انتخاب می کند
-🔒 5. "I need to protect my AI gateway from unauthorized access" +🔒 ۵. «باید درگاه هوش مصنوعی‌ام را در برابر دسترسی غیرمجاز محافظت کنم» -When exposing an AI gateway to the network (LAN, VPS, Docker), anyone with the address can consume the developer's tokens/quota. Without protection, APIs are vulnerable to misuse, prompt injection, and abuse. +هنگامی که یک دروازه هوش مصنوعی را در معرض شبکه قرار می دهید (LAN، VPS، Docker)، هر کسی که آدرس را داشته باشد می تواند توکن ها/سهمیه توسعه دهنده را مصرف کند. بدون محافظت، APIها در برابر سوء استفاده، تزریق سریع و سوء استفاده آسیب پذیر هستند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **API Key Management** — Generation, rotation, and scoping per provider with a dedicated `/dashboard/api-manager` page -- **Model-Level Permissions** — Restrict API keys to specific models (`openai/*`, wildcard patterns), with Allow All/Restrict toggle -- **API Endpoint Protection** — Require a key for `/v1/models` and block specific providers from the listing -- **Auth Guard + CSRF Protection** — All dashboard routes protected with `withAuth` middleware + CSRF tokens -- **Rate Limiter** — Per-IP rate limiting with configurable windows -- **IP Filtering** — Allowlist/blocklist for access control -- **Prompt Injection Guard** — Sanitization against malicious prompt patterns -- **AES-256-GCM Encryption** — Credentials encrypted at rest +- **API مدیریت کلید** — تولید، چرخش و محدوده برای هر ارائه دهنده با صفحه اختصاصی `/dashboard/api-manager` +- **مجوزهای سطح مدل** - کلیدهای API را به مدل‌های خاص محدود کنید (`openai/*`، الگوهای عام)، با تغییر حالت Allow All/Restrict +- **API حفاظت نقطه پایانی** - نیاز به کلید برای `/v1/models` و مسدود کردن ارائه دهندگان خاص از فهرست +- **Auth Guard + CSRF Protection** - همه مسیرهای داشبورد با میان افزار `withAuth` + توکن های CSRF محافظت می شوند +- **Rate Limiter** - محدود کردن نرخ به ازای IP با پنجره های قابل تنظیم +- ** فیلتر IP ** - لیست مجاز / لیست مسدود برای کنترل دسترسی +- **محافظ تزریق سریع** - ضدعفونی کردن در برابر الگوهای سریع مخرب +- ** رمزگذاری AES-256-GCM ** - اعتبارنامه ها در حالت استراحت رمزگذاری شده اند
-🛑 6. "My provider went down and I lost my coding flow" +🛑 ۶. «ارائه‌دهنده از دسترس خارج شد و جریان برنامه‌نویسی‌ام را از دست دادم» -AI providers can become unstable, return 5xx errors, or hit temporary rate limits. If a dev depends on a single provider, they're interrupted. Without circuit breakers, repeated retries can crash the application. +ارائه‌دهندگان هوش مصنوعی می‌توانند ناپایدار شوند، خطاهای 5xx را برگردانند یا به محدودیت‌های نرخ موقت برسند. اگر یک توسعه دهنده به یک ارائه دهنده وابسته باشد، آنها قطع می شوند. بدون قطع کننده مدار، تلاش های مجدد مکرر می تواند برنامه را خراب کند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Request Queue & Pacing** — Per-connection request buckets smooth bursts before they hit upstream rate caps -- **Connection Cooldown** — A single connection cools down after retryable failures with optional upstream `Retry-After` hints and exponential backoff -- **Provider Circuit Breaker** — The provider only trips after fallback is exhausted and the provider request still fails with provider-wide transient errors; connection-scoped `429` rate limits stay in Connection Cooldown -- **Wait For Cooldown** — The server can wait for the earliest connection cooldown to expire and retry the same client request automatically -- **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms -- **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention -- **Health Dashboard** — Uptime monitoring, provider circuit breaker states, cooldowns, cache stats, p50/p95/p99 latency +- **صف و سرعت درخواست** - سطل های درخواست هر اتصال قبل از اینکه به سقف های نرخ بالادستی برسند، یکنواخت می شوند +- ** خنک کننده اتصال ** - یک اتصال واحد پس از خرابی های قابل امتحان مجدد با نکات اختیاری بالادست `Retry-After` و عقب نشینی نمایی خنک می شود +- ** مدار شکن ارائه دهنده** - ارائه دهنده فقط پس از اتمام بازگشت مجدد و درخواست ارائه دهنده با خطاهای گذرا در سراسر ارائه دهنده با شکست مواجه می شود. محدودیت‌های سرعت `429` با محدوده اتصال، در Cooldown اتصال باقی می‌مانند +- **Wait For Cooldown** - سرور می تواند منتظر بماند تا اولین خنک شدن اتصال منقضی شود و دوباره همان درخواست مشتری را به طور خودکار امتحان کند. +- ** گله ضد رعد ** - محافظت موتکس + سمافور در برابر طوفان های تکراری همزمان +- ** زنجیره های بازگشتی ترکیبی ** - اگر ارائه دهنده اصلی شکست بخورد، به طور خودکار بدون مداخله از طریق زنجیره می افتد +- ** داشبورد سلامت ** - نظارت بر زمان، وضعیت های قطع کننده مدار ارائه دهنده، خنک شدن، آمار حافظه پنهان، تاخیر p50/p95/p99
-🔧 7. "Configuring each AI tool is tedious and repetitive" +🔧 ۷. «پیکربندی تک‌تک ابزارهای هوش مصنوعی خسته‌کننده و تکراری است» -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **CLI Tools Dashboard** — Dedicated page with one-click setup for Claude Code, Codex CLI, OpenClaw, Kilo Code, Antigravity, Cline -- **GitHub Copilot Config Generator** — Generates `chatLanguageModels.json` for VS Code with bulk model selection -- **Onboarding Wizard** — Guided 4-step setup for first-time users -- **One endpoint, all models** — Configure `http://localhost:20128/v1` once, access 329 provider catalog entries +- **داشبورد ابزارهای CLI** — صفحه‌ای اختصاصی با راه‌اندازی تک‌کلیکی برای Claude Code، Codex CLI، OpenClaw، Kilo Code، Antigravity و Cline +- **GitHub Copilot Config Generator** — `chatLanguageModels.json` را برای کد VS با انتخاب مدل انبوه تولید می کند +- **جادوگر سوار شدن** - راه اندازی 4 مرحله ای هدایت شده برای کاربرانی که اولین بار هستند +- **یک نقطه پایانی، همه مدل ها** - یک بار `http://localhost:20128/v1` را پیکربندی کنید، به 329 ورودی کاتالوگ ارائه دهنده دسترسی داشته باشید
-🔑 8. "Managing OAuth tokens from multiple providers is hell" +🔑 ۸. «مدیریت توکن‌های OAuth چندین ارائه‌دهنده بسیار دشوار است» -Claude Code, Codex, Copilot — all use OAuth 2.0 with expiring tokens. Developers need to re-authenticate constantly, deal with `client_secret is missing`, `redirect_uri_mismatch`, and failures on remote servers. OAuth on LAN/VPS is particularly problematic. +کد Claude، Codex، Copilot — همه از OAuth 2.0 با توکن های در حال انقضا استفاده می کنند. توسعه‌دهندگان باید دائماً احراز هویت مجدد کنند، با `client_secret is missing`، `redirect_uri_mismatch` و خرابی‌های سرورهای راه دور مقابله کنند. OAuth در LAN/VPS به ویژه مشکل ساز است. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Auto Token Refresh** — OAuth tokens refresh in background before expiration -- **OAuth 2.0 (PKCE) Built-in** — Automatic flow for Claude Code, Codex, Copilot, Kiro, Qwen, Qoder -- **Multi-Account OAuth** — Multiple accounts per provider via JWT/ID token extraction -- **OAuth LAN/Remote Fix** — Private IP detection for `redirect_uri` + manual URL mode for remote servers -- **OAuth Behind Nginx** — Uses `window.location.origin` for reverse proxy compatibility -- **Remote OAuth Guide** — Step-by-step guide for Google Cloud credentials on VPS/Docker +- **بازسازی خودکار توکن** - توکن های OAuth قبل از انقضا در پس زمینه به روز می شوند +- **OAuth 2.0 (PKCE) داخلی** — جریان خودکار برای Claude Code، Codex، Copilot، Kiro، Qwen و Qoder +- ** چند حساب OAuth ** - چندین حساب در هر ارائه دهنده از طریق استخراج رمز JWT/ID +- **OAuth LAN/Remote Fix** - تشخیص IP خصوصی برای `redirect_uri` + حالت دستی URL برای سرورهای راه دور +- **OAuth پشت Nginx** — از `window.location.origin` برای سازگاری با پراکسی معکوس استفاده می کند +- **راهنمای راه دور OAuth** - راهنمای گام به گام اعتبارنامه Google Cloud در VPS/Docker
-📊 9. "I don't know how much I'm spending or where" +📊 ۹. «نمی‌دانم چقدر و برای چه چیزی هزینه می‌کنم» -Developers use multiple paid providers but have no unified view of spending. Each provider has its own billing dashboard, but there's no consolidated view. Unexpected costs can pile up. +توسعه‌دهندگان از چندین ارائه‌دهنده پولی استفاده می‌کنند، اما دیدگاه واحدی از هزینه‌ها ندارند. هر ارائه دهنده داشبورد صورتحساب خود را دارد، اما هیچ نمای تلفیقی وجود ندارد. هزینه های غیرمنتظره می تواند روی هم انباشته شود. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Cost Analytics Dashboard** — Per-token cost tracking and budget management per provider -- **Budget Limits per Tier** — Spending ceiling per tier that triggers automatic fallback -- **Per-Model Pricing Configuration** — Configurable prices per model -- **Usage Statistics Per API Key** — Request count and last-used timestamp per key -- **Analytics Dashboard** — Stat cards, model usage chart, provider table with success rates and latency +- **داشبورد تجزیه و تحلیل هزینه** - ردیابی هزینه هر توکن و مدیریت بودجه به ازای هر ارائه دهنده +- **محدودیت بودجه در هر ردیف** - سقف هزینه در هر ردیف که باعث بازگشت خودکار می شود +- **پیکربندی قیمت گذاری برای هر مدل** - قیمت های قابل تنظیم برای هر مدل +- **آمار استفاده به ازای کلید API** — تعداد درخواست و آخرین مهر زمانی استفاده شده در هر کلید +- **داشبورد تجزیه و تحلیل** - کارت های آمار، نمودار استفاده از مدل، جدول ارائه دهنده با میزان موفقیت و تاخیر
-🐛 10. "I can't diagnose errors and problems in AI calls" +🐛 ۱۰. «نمی‌توانم خطاها و مشکلات فراخوانی‌های هوش مصنوعی را عیب‌یابی کنم» -When a call fails, the dev doesn't know if it was a rate limit, expired token, wrong format, or provider error. Fragmented logs across different terminals. Without observability, debugging is trial-and-error. +وقتی یک تماس با شکست مواجه می‌شود، برنامه‌نویس نمی‌داند که آیا محدودیت نرخ، رمز منقضی شده، فرمت اشتباه یا خطای ارائه‌دهنده بوده است. لاگ های تکه تکه شده در پایانه های مختلف. بدون قابلیت مشاهده، اشکال زدایی آزمون و خطا است. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Unified Logs Dashboard** — 4 tabs: Request Logs, Proxy Logs, Audit Logs, Console -- **Console Log Viewer** — Real-time terminal-style viewer with color-coded levels, auto-scroll, search, filter -- **SQLite Summary Logs** — Request and proxy log indexes stay queryable across restarts without loading large payload blobs into SQLite -- **Translator Playground** — 4 debugging modes: Playground (format translation), Chat Tester (round-trip), Test Bench (batch), Live Monitor (real-time) -- **Request Telemetry** — p50/p95/p99 latency + X-Request-Id tracing -- **File-Based Detail Artifacts** — App logs rotate by size, retention days, and archive count; detailed request/response payloads live in `DATA_DIR/call_logs/` and rotate independently of SQLite summaries -- **System Info Report** — `npm run system-info` generates `system-info.txt` with your full environment (Node version, OmniRoute version, OS, CLI tools, Docker/PM2 status). Attach it when reporting issues for instant triage. +- **داشبورد گزارش های یکپارچه** - 4 تب: گزارش های درخواست، گزارش های پروکسی، گزارش های حسابرسی، کنسول +- **نمایشگر ورود به سیستم** - نمایشگر به سبک ترمینال در زمان واقعی با سطوح رنگی، اسکرول خودکار، جستجو، فیلتر +- **SQLite Summary Logs** - فهرست درخواست و پروکسی در طول راه اندازی مجدد بدون بارگیری حباب های بار بزرگ در SQLite قابل پرس و جو می ماند. +- ** مترجم زمین بازی ** - 4 حالت اشکال زدایی: زمین بازی (ترجمه فرمت)، تستر چت (رفت و برگشت)، میز تست (دسته ای)، مانیتور زنده (زمان واقعی) +- **تله متری درخواست** — تأخیر p50/p95/p99 + ردیابی X-Request-Id +- ** مصنوعات جزئیات مبتنی بر فایل ** - سیاهههای مربوط به برنامه بر اساس اندازه، روزهای نگهداری و تعداد آرشیو می چرخند. بارهای درخواست/پاسخ دقیق در `DATA_DIR/call_logs/` زندگی می کنند و مستقل از خلاصه های SQLite می چرخند +- **گزارش اطلاعات سیستم** - `npm run system-info` `system-info.txt` را با محیط کامل شما تولید می کند (نسخه Node، نسخه OmniRoute، سیستم عامل، ابزار CLI، وضعیت Docker/PM2). هنگام گزارش مشکلات برای تریاژ فوری، آن را ضمیمه کنید.
-🏗️ 11. "Deploying and maintaining the gateway is complex" +🏗️ ۱۱. «استقرار و نگهداری درگاه پیچیده است» -Installing, configuring, and maintaining an AI proxy across different environments (local, VPS, Docker, cloud) is labor-intensive. Problems like hardcoded paths, `EACCES` on directories, port conflicts, and cross-platform builds add friction. +نصب، پیکربندی و نگهداری یک پروکسی هوش مصنوعی در محیط های مختلف (محلی، VPS، Docker، ابر) کار فشرده ای است. مشکلاتی مانند مسیرهای کدگذاری شده، `EACCES` در دایرکتوری‌ها، تداخل پورت‌ها، و ساخت‌های بین پلتفرمی باعث ایجاد اصطکاک می‌شوند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **npm global install** — `npm install -g omniroute && omniroute` — done -- **Docker Multi-Platform** — AMD64 + ARM64 native (Apple Silicon, AWS Graviton, Raspberry Pi) -- **Docker Compose Profiles** — `base` (no CLI tools) and `cli` (with Claude Code, Codex, OpenClaw) -- **Electron Desktop App** — Native app for Windows/macOS/Linux with system tray, auto-start, offline mode -- **Split-Port Mode** — API and Dashboard on separate ports for advanced scenarios (reverse proxy, container networking) -- **Cloud Sync** — Config synchronization across devices via Cloudflare Workers -- **DB Backups** — Automatic backup, restore, export and import of all settings, with `DISABLE_SQLITE_AUTO_BACKUP` for externally managed backups +- ** نصب جهانی npm ** — `npm install -g omniroute && omniroute` — انجام شد +- **Docker چند پلتفرم** — AMD64 + ARM64 بومی (Apple Silicon، AWS Graviton، Raspberry Pi) +- **پروفایل‌های Docker Compose** — `base` (بدون ابزار CLI) و `cli` (همراه Claude Code، Codex و OpenClaw) +- **برنامه Electron Desktop** - برنامه بومی برای Windows/macOS/Linux با سینی سیستم، شروع خودکار، حالت آفلاین +- ** حالت Split-Port ** - API و داشبورد در پورت های جداگانه برای سناریوهای پیشرفته (پراکسی معکوس، شبکه کانتینری) +- **Cloud Sync** - همگام سازی پیکربندی بین دستگاه ها از طریق Cloudflare Workers +- **پشتیبان گیری از DB** - پشتیبان گیری خودکار، بازیابی، صادرات و واردات تمام تنظیمات، با `DISABLE_SQLITE_AUTO_BACKUP` برای پشتیبان گیری های مدیریت شده خارجی
-🌍 12. "The interface is English-only and my team doesn't speak English" +🌍 ۱۲. «رابط فقط انگلیسی است و تیم من انگلیسی صحبت نمی‌کند» -Teams in non-English-speaking countries, especially in Latin America, Asia, and Europe, struggle with English-only interfaces. Language barriers reduce adoption and increase configuration errors. +تیم های کشورهای غیر انگلیسی زبان، به ویژه در آمریکای لاتین، آسیا و اروپا، با رابط های فقط انگلیسی مبارزه می کنند. موانع زبان پذیرش را کاهش می دهد و خطاهای پیکربندی را افزایش می دهد. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Dashboard i18n — 30 Languages** — All 500+ keys translated including Arabic, Bulgarian, Danish, German, Spanish, Finnish, French, Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Malay, Dutch, Norwegian, Polish, Portuguese (PT/BR), Romanian, Russian, Slovak, Swedish, Thai, Ukrainian, Vietnamese, Chinese, Filipino, English -- **RTL Support** — Right-to-left support for Arabic and Hebrew -- **Multi-Language READMEs** — 30 complete documentation translations -- **Language Selector** — Globe icon in header for real-time switching +- **داشبورد i18n - 30 زبان** - تمام 500+ کلید ترجمه شده از جمله عربی، بلغاری، دانمارکی، آلمانی، اسپانیایی، فنلاندی، فرانسوی، عبری، هندی، مجارستانی، اندونزیایی، ایتالیایی، ژاپنی، کره ای، مالایی، هلندی، نروژی، لهستانی، پرتغالی (PT/BR)، رومانیایی، روسی، اسلواکی، سوئدی، تایلندی، اوکراینی، ویتنامی، چینی، فیلیپینی، انگلیسی +- ** پشتیبانی RTL ** - پشتیبانی از راست به چپ برای عربی و عبری +- ** README های چند زبانه ** - 30 ترجمه مستند کامل +- ** انتخابگر زبان ** - نماد کره در هدر برای تغییر زمان واقعی
-🔄 13. "I need more than chat — I need embeddings, images, audio" +🔄 ۱۳. «فراتر از چت نیاز دارم؛ به جاسازی، تصویر و صوت نیاز دارم» -AI isn't just chat completion. Devs need to generate images, transcribe audio, create embeddings for RAG, rerank documents, and moderate content. Each API has a different endpoint and format. +هوش مصنوعی فقط تکمیل چت نیست. توسعه دهندگان نیاز به تولید تصاویر، رونویسی صدا، ایجاد جاسازی برای RAG، رتبه بندی مجدد اسناد، و تعدیل محتوا دارند. هر API نقطه پایانی و قالب متفاوتی دارد. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Embeddings** — `/v1/embeddings` with 6 providers and 9+ models -- **Image Generation** — `/v1/images/generations` with 10 providers and 20+ models (OpenAI, xAI, Together, Fireworks, Nebius, Hyperbolic, NanoBanana, Antigravity, SD WebUI, ComfyUI) -- **Text-to-Video** — `/v1/videos/generations` — ComfyUI (AnimateDiff, SVD) and SD WebUI -- **Text-to-Music** — `/v1/music/generations` — ComfyUI (Stable Audio Open, MusicGen) -- **Audio Transcription** — `/v1/audio/transcriptions` — Whisper + Nvidia NIM, HuggingFace, Qwen3 -- **Text-to-Speech** — `/v1/audio/speech` — ElevenLabs, Nvidia NIM, HuggingFace, Coqui, Tortoise, Qwen3, **Inworld**, **Cartesia**, **PlayHT**, + existing providers -- **Moderations** — `/v1/moderations` — Content safety checks -- **Reranking** — `/v1/rerank` — Document relevance reranking -- **Responses API** — Full `/v1/responses` support for Codex +- **جاسازی ها** — `/v1/embeddings` با 6 ارائه دهنده و 9+ مدل +- **تولید تصویر** — `/v1/images/generations` با 10 ارائه دهنده و 20+ مدل (OpenAI، xAI، Together، Fireworks، Nebius، Hyperbolic، NanoBanana، Antigravity، SD WebUI، ComfyUI) +- **تکست به ویدئو** - `/v1/videos/generations` - ComfyUI (AnimateDiff، SVD) و SD WebUI +- **تکست به موسیقی** - `/v1/music/generations` - ComfyUI (باز صدای پایدار، MusicGen) +- **رونویسی صوتی** - `/v1/audio/transcriptions` - Whisper + Nvidia NIM، HuggingFace، Qwen3 +- **تکست به گفتار** — `/v1/audio/speech` — ElevenLabs، Nvidia NIM، HuggingFace، Coqui، Tortoise، Qwen3، **Inworld**، **Cartesia**، **PlayHT**، + ارائه دهندگان موجود +- ** تعدیل ها ** - `/v1/moderations` - بررسی های ایمنی محتوا +- **رتبه‌بندی مجدد** - `/v1/rerank` - رتبه‌بندی مجدد مربوط به سند +- **پاسخ API** — پشتیبانی کامل از `/v1/responses` برای Codex
-🧪 14. "I have no way to test and compare quality across models" +🧪 ۱۴. «راهی برای آزمودن و مقایسهٔ کیفیت مدل‌ها ندارم» -Developers want to know which model is best for their use case — code, translation, reasoning — but comparing manually is slow. No integrated eval tools exist. +توسعه‌دهندگان می‌خواهند بدانند کدام مدل برای موارد استفاده آنها بهترین است - کد، ترجمه، استدلال - اما مقایسه دستی کند است. هیچ ابزار ارزیابی یکپارچه وجود ندارد. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **LLM Evaluations** — Golden set testing with 10 pre-loaded cases covering greetings, math, geography, code generation, JSON compliance, translation, markdown, safety refusal -- **4 Match Strategies** — `exact`, `contains`, `regex`, `custom` (JS function) -- **Translator Playground Test Bench** — Batch testing with multiple inputs and expected outputs, cross-provider comparison -- **Chat Tester** — Full round-trip with visual response rendering -- **Live Monitor** — Real-time stream of all requests flowing through the proxy +- ** ارزیابی های LLM ** - تست مجموعه طلایی با 10 مورد از پیش بارگذاری شده که احوالپرسی، ریاضی، جغرافیا، تولید کد، انطباق با JSON، ترجمه، علامت گذاری، امتناع ایمنی را پوشش می دهد +- **4 استراتژی مطابقت ** — `exact`، `contains`، `regex`، `custom` (عملکرد JS) +- **نیمکت تست مترجم زمین بازی** - تست دسته ای با ورودی های متعدد و خروجی های مورد انتظار، مقایسه بین ارائه دهندگان +- **تستر چت** - رفت و برگشت کامل با رندر پاسخ بصری +- ** مانیتور زنده ** - جریان بیدرنگ تمام درخواست هایی که از طریق پروکسی جریان می یابد
-📈 15. "I need to scale without losing performance" +📈 ۱۵. «باید بدون افت کارایی مقیاس‌پذیر شوم» -As request volume grows, without caching the same questions generate duplicate costs. Without idempotency, duplicate requests waste processing. Per-provider rate limits must be respected. +با افزایش حجم درخواست، بدون ذخیره سازی در حافظه پنهان، همان سوالات هزینه های تکراری ایجاد می کنند. بدون ناتوانی، درخواست های تکراری پردازش زباله. محدودیت های نرخ هر ارائه دهنده باید رعایت شود. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Semantic Cache** — Two-tier cache (signature + semantic) reduces cost and latency -- **Request Idempotency** — 5s deduplication window for identical requests -- **Rate Limit Detection** — Per-provider RPM, min gap, and max concurrent tracking -- **Request Queue & Pacing** — Configurable queue, pacing, and concurrency defaults in Settings → Resilience -- **API Key Validation Cache** — 3-tier cache for production performance -- **Health Dashboard with Telemetry** — p50/p95/p99 latency, cache stats, uptime +- ** کش معنایی ** - کش دو لایه (امضا + معنایی) هزینه و تأخیر را کاهش می دهد +- ** درخواست Idempotency ** - پنجره deduplication 5s برای درخواست های یکسان +- **تشخیص محدودیت نرخ** - RPM هر ارائه دهنده، حداقل فاصله و حداکثر ردیابی همزمان +- **درخواست صف و سرعت** - پیش فرض های صف، سرعت و همزمانی قابل تنظیم در تنظیمات → انعطاف پذیری +- ** حافظه پنهان اعتبارسنجی کلید API ** - حافظه نهان 3 لایه برای عملکرد تولید +- ** داشبورد سلامت با تله متری ** - تأخیر p50/p95/p99، آمار حافظه پنهان، زمان آپدیت
-🤖 16. "I want to control model behavior globally" +🤖 ۱۶. «می‌خواهم رفتار مدل را به‌صورت سراسری کنترل کنم» -Developers who want all responses in a specific language, with a specific tone, or want to limit reasoning tokens. Configuring this in every tool/request is impractical. +توسعه دهندگانی که همه پاسخ ها را به زبانی خاص، با لحن خاصی می خواهند یا می خواهند نشانه های استدلال را محدود کنند. پیکربندی این در هر ابزار/درخواست غیرعملی است. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **System Prompt Injection** — Global prompt applied to all requests -- **Thinking Budget Validation** — Reasoning token allocation control per request (passthrough, auto, custom, adaptive) -- **9 Routing Strategies** — Global strategies that determine how requests are distributed -- **Wildcard Router** — `provider/*` patterns route dynamically to any provider -- **Combo Enable/Disable Toggle** — Toggle combos directly from the dashboard -- **Manual Combo Ordering** — Drag combo cards by handle and persist the order in SQLite -- **Provider Toggle** — Enable/disable all connections for a provider with one click -- **Blocked Providers** — Exclude specific providers from `/v1/models` listing +- **تزریق سریع سیستم** - اعلان جهانی برای همه درخواست ها اعمال می شود +- ** اعتبارسنجی بودجه فکری ** - کنترل تخصیص رمز استدلال در هر درخواست (گذرا، خودکار، سفارشی، تطبیقی) +- **9 استراتژی مسیریابی** - استراتژی های جهانی که نحوه توزیع درخواست ها را تعیین می کند +- **مسیریاب Wildcard** - الگوهای `provider/*` به صورت پویا به هر ارائه دهنده ای می روند +- ** Combo Enable/Disable Toggle** - جابجایی ترکیبی به طور مستقیم از داشبورد +- ** سفارش دستی ترکیبی ** - کارت های ترکیبی را با دسته بکشید و سفارش را در SQLite ادامه دهید +- **تغییر ارائه دهنده** - همه اتصالات یک ارائه دهنده را با یک کلیک فعال/غیرفعال کنید +- **ارائه دهندگان مسدود شده** - ارائه دهندگان خاص را از لیست `/v1/models` حذف کنید
-🧰 17. "I need MCP tools as first-class product capabilities" +🧰 ۱۷. «به ابزارهای MCP به‌عنوان قابلیت‌های اصلی محصول نیاز دارم» -Many AI gateways expose MCP only as a hidden implementation detail. Teams need a visible, manageable operation layer. +بسیاری از دروازه‌های هوش مصنوعی MCP را تنها به عنوان یک جزئیات پیاده‌سازی پنهان نشان می‌دهند. تیم ها به یک لایه عملیاتی قابل کنترل و قابل مشاهده نیاز دارند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- MCP appears in the dashboard navigation and endpoint protocol tab -- Dedicated MCP management page with process, tools, scopes, and audit -- Built-in quick-start for `omniroute --mcp` and client onboarding +- MCP در برگه ناوبری داشبورد و پروتکل نقطه پایان ظاهر می شود +- صفحه مدیریت اختصاصی MCP با فرآیند، ابزار، دامنه و ممیزی +- راه اندازی سریع داخلی برای `omniroute --mcp` و نصب مشتری
-🧠 18. "I need A2A orchestration with sync + stream task paths" +🧠 ۱۸. «به ارکستراسیون A2A با مسیرهای وظیفهٔ همگام و جریانی نیاز دارم» -Agent workflows need both direct replies and long-running streamed execution with lifecycle control. +گردش کار عامل هم به پاسخ های مستقیم و هم به اجرای جریانی طولانی مدت با کنترل چرخه حیات نیاز دارد. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- A2A JSON-RPC endpoint (`POST /a2a`) with `message/send` and `message/stream` -- SSE streaming with terminal state propagation -- Task lifecycle APIs for `tasks/get` and `tasks/cancel` +- نقطه پایانی A2A JSON-RPC (`POST /a2a`) با `message/send` و `message/stream` +- پخش جریانی SSE با انتشار حالت ترمینال +- APIهای چرخه حیات وظیفه برای `tasks/get` و `tasks/cancel`
-🛰️ 19. "I need real MCP process health, not guessed status" +🛰️ ۱۹. «به سلامت واقعی فرایند MCP نیاز دارم، نه وضعیت حدسی» -Operational teams need to know if MCP is actually alive, not just whether an API is reachable. +تیم های عملیاتی باید بدانند که آیا MCP واقعاً زنده است یا نه، نه فقط اینکه آیا API قابل دسترسی است یا خیر. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- Runtime heartbeat file with PID, timestamps, transport, tool count, and scope mode -- MCP status API combining heartbeat + recent activity -- UI status cards for process/uptime/heartbeat freshness +- فایل ضربان قلب در زمان اجرا با PID، مهرهای زمانی، حمل و نقل، شمارش ابزار و حالت دامنه +- وضعیت MCP API ترکیبی از ضربان قلب + فعالیت اخیر +- کارت های وضعیت رابط کاربری برای تازگی فرآیند/تایم/ضربان قلب
-📋 20. "I need auditable MCP tool execution" +📋 ۲۰. «به اجرای قابل‌ممیزی ابزارهای MCP نیاز دارم» -When tools mutate config or trigger ops actions, teams need forensic traceability. +هنگامی که ابزارها پیکربندی را تغییر می دهند یا اقدامات عملیاتی را آغاز می کنند، تیم ها به قابلیت ردیابی قانونی نیاز دارند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- SQLite-backed audit logging for MCP tool calls -- Filters by tool, success/failure, API key, and pagination -- Dashboard audit table + stats endpoints for automation +- ثبت حسابرسی با پشتیبانی SQLite برای تماس های ابزار MCP +- فیلترها بر اساس ابزار، موفقیت/شکست، کلید API و صفحه بندی +- جدول حسابرسی داشبورد + نقاط پایانی آمار برای اتوماسیون
-🔐 21. "I need scoped MCP permissions per integration" +🔐 ۲۱. «برای هر یکپارچه‌سازی، به مجوزهای محدوده‌دار MCP نیاز دارم» -Different clients should have least-privilege access to tool categories. +مشتریان مختلف باید کمترین امتیاز را به دسته های ابزار داشته باشند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- 32 granular MCP scopes for controlled tool access -- Scope enforcement and visibility in MCP management UI -- Safe default posture for operational tooling +- 32 اسکوپ گرانول MCP برای دسترسی کنترل شده به ابزار +- اجرای محدوده و دید در رابط کاربری مدیریت MCP +- وضعیت پیش فرض ایمن برای ابزار عملیاتی
-⚙️ 22. "I need operational controls without redeploying" +⚙️ ۲۲. «به کنترل‌های عملیاتی بدون استقرار مجدد نیاز دارم» -Teams need quick runtime changes during incidents or cost events. +تیم ها به تغییرات سریع در زمان اجرا در طول حوادث یا رویدادهای هزینه نیاز دارند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- Switch combo activation directly from MCP dashboard -- Tune queue, cooldown, breaker, and wait settings from the dedicated Resilience page -- Review live provider breaker state from the Health dashboard +- فعال سازی ترکیبی را مستقیماً از داشبورد MCP تغییر دهید +- تنظیمات صف، خنک کننده، شکن و انتظار را از صفحه اختصاصی Resilience تنظیم کنید +- وضعیت قطع کننده ارائه دهنده زنده را از داشبورد Health مرور کنید
-🔄 23. "I need live A2A task lifecycle visibility and cancellation" +🔄 ۲۳. «به مشاهدهٔ زنده و لغو چرخهٔ حیات وظایف A2A نیاز دارم» -Without lifecycle visibility, task incidents become hard to triage. +بدون دید چرخه حیات، تریاژ حوادث کار سخت می شود. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- Task listing/filtering by state/skill with pagination -- Drill-down on task metadata, events, and artifacts -- Task cancellation endpoint and UI action with confirmation +- فهرست کار/فیلتر کردن بر اساس وضعیت/مهارت با صفحه بندی +- متادیتاهای وظیفه، رویدادها و مصنوعات را بررسی کنید +- نقطه پایانی لغو کار و اقدام UI با تأیید
-🌊 24. "I need active stream metrics for A2A load" +🌊 ۲۴. «برای بار A2A به معیارهای جریان فعال نیاز دارم» -Streaming workflows require operational insight into concurrency and live connections. +جریان کار مستلزم بینش عملیاتی در مورد همزمانی و اتصالات زنده است. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- Active stream counters integrated into A2A status -- Last task timestamp and per-state counts -- A2A dashboard cards for real-time ops monitoring +- شمارنده های جریان فعال در وضعیت A2A یکپارچه شده است +- آخرین مهر زمان کار و تعداد هر ایالت +- کارت های داشبورد A2A برای نظارت بر عملیات در زمان واقعی
-🪪 25. "I need standard agent discovery for clients" +🪪 ۲۵. «به کشف استاندارد عامل برای کلاینت‌ها نیاز دارم» -External clients and orchestrators need machine-readable metadata for onboarding. +مشتریان خارجی و ارکستراتورها برای سوار شدن به ابرداده قابل خواندن توسط ماشین نیاز دارند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- Agent Card exposed at `/.well-known/agent.json` -- Capabilities and skills shown in management UI -- A2A status API includes discovery metadata for automation +- کارت نماینده در `/.well-known/agent.json` در معرض دید قرار گرفت +- قابلیت ها و مهارت های نشان داده شده در رابط کاربری مدیریت +- وضعیت A2A API شامل ابرداده های کشف برای اتوماسیون است
-🧭 26. "I need protocol discoverability in the product UX" +🧭 ۲۶. «به کشف‌پذیری پروتکل در تجربهٔ کاربری محصول نیاز دارم» -If users cannot discover protocol surfaces, adoption and support quality drop. +اگر کاربران نتوانند سطوح پروتکل را کشف کنند، کیفیت پذیرش و پشتیبانی کاهش می یابد. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- Consolidated **Endpoints** page with tabs for Proxy, MCP, A2A, and API Endpoints -- Inline service status toggles (Online/Offline) for MCP and A2A -- Links from overview to dedicated management tabs +- صفحه تلفیقی **Endpoints** با برگه‌های Proxy، MCP، A2A، و API +- تغییر وضعیت سرویس درون خطی (آنلاین/آفلاین) برای MCP و A2A +- پیوند از نمای کلی به برگه های مدیریت اختصاصی
-🧪 27. "I need end-to-end protocol validation with real clients" +🧪 ۲۷. «به اعتبارسنجی سرتاسری پروتکل با کلاینت‌های واقعی نیاز دارم» -Mock tests are not enough to validate protocol compatibility before release. +تست های ساختگی برای تایید سازگاری پروتکل قبل از انتشار کافی نیستند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- E2E suite that boots app and uses real MCP SDK client transport -- A2A client tests for discovery, send, stream, get, and cancel flows -- Cross-check assertions against MCP audit and A2A tasks APIs +- مجموعه E2E که برنامه را بوت می کند و از حمل و نقل مشتری واقعی MCP SDK استفاده می کند +- تست مشتری A2A برای کشف، ارسال، پخش، دریافت و لغو جریان ها +- بررسی متقاطع ادعاها علیه MCP ممیزی و API وظایف A2A
-📡 28. "I need unified observability across all interfaces" +📡 ۲۸. «به مشاهده‌پذیری یکپارچه در همهٔ رابط‌ها نیاز دارم» -Splitting observability by protocol creates blind spots and longer MTTR. +تقسیم قابلیت مشاهده توسط پروتکل باعث ایجاد نقاط کور و MTTR طولانی تر می شود. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- Unified dashboards/logs/analytics in one product -- Health + audit + request telemetry across OpenAI, MCP, and A2A layers -- Operational APIs for status and automation +- داشبورد / سیاهههای مربوط / تجزیه و تحلیل یکپارچه در یک محصول +- سلامت + ممیزی + درخواست تله متری در لایه های OpenAI، MCP، و A2A +- API های عملیاتی برای وضعیت و اتوماسیون
-💼 29. "I need one runtime for proxy + tools + agent orchestration" +💼 ۲۹. «برای پروکسی، ابزارها و ارکستراسیون عامل به یک محیط اجرا نیاز دارم» -Running many separate services increases operational cost and failure modes. +اجرای بسیاری از خدمات جداگانه هزینه عملیاتی و حالت های خرابی را افزایش می دهد. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- OpenAI-compatible proxy, MCP server, and A2A server in one stack -- Shared auth, resilience, data store, and observability -- Consistent policy model across all interaction surfaces +- پروکسی سازگار با OpenAI، سرور MCP و سرور A2A در یک پشته +- اعتبار مشترک، انعطاف پذیری، ذخیره داده ها و قابلیت مشاهده +- مدل خط مشی سازگار در تمام سطوح تعامل
-🚀 30. "I need to ship agentic workflows without glue-code sprawl" +🚀 ۳۰. «می‌خواهم گردش‌کارهای عامل‌محور را بدون پراکندگی کدهای اتصال‌دهنده منتشر کنم» -Teams lose velocity when stitching multiple ad-hoc services and scripts. +تیم ها هنگام دوخت چندین سرویس ad-hoc و اسکریپت سرعت خود را از دست می دهند. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- Unified endpoint strategy for clients and agents -- Built-in protocol management UIs and smoke validation paths -- Production-ready foundations (security, logging, resilience, backup) +- استراتژی نقطه پایانی یکپارچه برای مشتریان و نمایندگان +- رابط های کاربری داخلی مدیریت پروتکل و مسیرهای اعتبارسنجی دود +- پایه های آماده تولید (امنیت، ورود به سیستم، انعطاف پذیری، پشتیبان گیری)
-📚 31. "My long sessions crash with 'context_length_exceeded' limits" +📚 ۳۱. «نشست‌های طولانی من با محدودیت «context_length_exceeded» متوقف می‌شوند» -During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. +در طول اشکال‌زدایی عمیق، تاریخچه‌های طولانی با نتایج ابزار به سرعت از پنجره‌های رمز ارائه‌دهنده فراتر می‌روند، که باعث درخواست‌های ناموفق و زمینه بی‌اطلاعی می‌شود. -**How OmniRoute solves it:** +** چگونه OmniRoute آن را حل می کند:** -- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. -- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. -- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. +- ** فشرده سازی متن پیشگیرانه ** - قبل از اینکه درخواست به بالادست برسد، بودجه توکن ها را ارزیابی می کند و به طور فعال تاریخچه مکالمات قدیمی را با مکانیزم جستجوی باینری هوشمند حذف می کند. +- ** محافظ یکپارچگی ساختاری** - تعاریف صریح `tool_use` را به طور خودکار ردیابی می کند و تضمین می کند که اگر ورودی ابزار کوتاه شود، `tool_result` مربوطه آن نیز با خیال راحت حذف می شود و از خطاهای اعتبارسنجی API جلوگیری می کند. +- **کاهش چند لایه** - به تدریج پیام های سیستمی، پیام های معمولی را حذف می کند و در نهایت محدودیت های طولانی مدت را بدون نقض منطق مکالمه اعمال می کند.
-### Example Playbooks (Integrated Use Cases) +### نمونه کتابهای راهنما (مورد استفاده یکپارچه) -**Playbook A: Maximize paid subscription + cheap backup** +**Playbook A: اشتراک پولی را به حداکثر برسانید + پشتیبان گیری ارزان** ```txt Combo: "maximize-claude" @@ -696,7 +696,7 @@ Monthly cost: $20 + small backup spend Outcome: higher quality, near-zero interruption ``` -**Playbook B: Zero-cost coding stack** +**راهنمای B: پشته کدگذاری بدون هزینه** ```txt Combo: "free-access" @@ -707,7 +707,7 @@ Monthly cost: $0 Outcome: broader free-access fallback; upstream availability is not guaranteed ``` -**Playbook C: 24/7 always-on fallback chain** +**کتاب راهنما C: زنجیره بازگشتی 24 ساعته همیشه فعال** ```txt Combo: "multi-layer-fallback" @@ -720,7 +720,7 @@ Combo: "multi-layer-fallback" Outcome: deep fallback depth for deadline-critical workloads ``` -**Playbook D: Agent ops with MCP + A2A** +**راهنمای D: عملیات عامل با MCP + A2A** ```txt 1) Start MCP transport (`omniroute --mcp`) for tool-driven operations @@ -731,57 +731,57 @@ Outcome: deep fallback depth for deadline-critical workloads --- -## 🆓 Start Free — Zero Configuration Cost +## 🆓 شروع رایگان — هزینه پیکربندی صفر -> Setup AI coding in minutes at **$0/month**. Connect these free accounts and use the built-in **Free Stack** combo. +> کدگذاری هوش مصنوعی را در چند دقیقه با **0 دلار در ماه** تنظیم کنید. این حساب‌های رایگان را متصل کنید و از ترکیب داخلی **پشته رایگان** استفاده کنید. -| Step | Action | Providers Unlocked | +| مرحله | اقدام | قفل ارائه دهندگان | | ---- | -------------------------------------------------- | ------------------------------------------------------------------ | -| 1 | Connect **Kiro** (AWS Builder ID OAuth) | Claude Sonnet 4.5, Haiku 4.5 — provider/account limits apply | -| 2 | Connect **Qoder** (Google OAuth) | kimi-k2-thinking, qwen3-coder-plus, deepseek-r1... — provider/account limits apply | -| 3 | Connect **Qwen** (Device Code) | qwen3-coder-plus, qwen3-coder-flash... — provider/account limits apply | -| 4 | `/dashboard/combos` → **Free Stack ($0)** template | Round-robin all free providers automatically | +| 1 | اتصال **Kiro** (شناسه سازنده AWS OAuth) | Claude Sonnet 4.5، Haiku 4.5 — محدودیت های ارائه دهنده/حساب اعمال می شود | +| 2 | اتصال **Qoder** (Google OAuth) | kimi-k2-thinking، qwen3-coder-plus، deepseek-r1... — محدودیت های ارائه دهنده/حساب اعمال می شود | +| 3 | اتصال **Qwen** (کد دستگاه) | qwen3-coder-plus، qwen3-coder-flash... — محدودیت های ارائه دهنده/حساب اعمال می شود | +| 4 | قالب `/dashboard/combos` → **پشته رایگان (0$)** | همه ارائه دهندگان رایگان را بصورت خودکار | -**Point any IDE/CLI to:** `http://localhost:20128/v1` · API Key: `any-string` · Done. +**کلید IDE/CLI را به:** `http://localhost:20128/v1` · API کلید: `any-string` · انجام شد. -> **Optional extra coverage (current terms apply):** Groq, NVIDIA NIM, Cerebras, LongCat and Cloudflare Workers AI can provide free access or signup credits where currently listed. Quotas, models, accounts, regions and provider terms can change; see [`FREE_TIERS.md`](../../reference/FREE_TIERS.md). +> **پوشش اضافی اختیاری (شرایط فعلی اعمال می‌شود):** Groq، NVIDIA NIM، Cerebras، LongCat و Cloudflare Workers AI می‌تواند اعتبار رایگان را در مکان‌هایی که در حال حاضر فهرست شده است ارائه دهد. سهمیه ها، مدل ها، حساب ها، مناطق و شرایط ارائه دهنده می توانند تغییر کنند. [`FREE_TIERS.md`](../../reference/FREE_TIERS.md) را ببینید. -## Inicio Rápido +## اینیسیو راپیدو -### 1) Install and run +### 1) نصب و اجرا کنید ```bash npm install -g omniroute omniroute ``` -> **pnpm users:** Pass `--allow-build` at install time to enable native build scripts required by `better-sqlite3` and `@swc/core` (the `approve-builds -g` command is not supported for global installs on pnpm v11): +> **کاربران pnpm:** در زمان نصب `--allow-build` را برای فعال کردن اسکریپت های ساخت بومی مورد نیاز `better-sqlite3` و `@swc/core` ارسال کنید (دستور `approve-builds -g` برای نصب های جهانی در pnpm v11 پشتیبانی نمی شود): > > ```bash > pnpm add -g omniroute@latest --allow-build=better-sqlite3 --allow-build=@swc/core > omniroute > ``` -Dashboard opens at `http://localhost:20128` and API base URL is `http://localhost:20128/v1`. +داشبورد در `http://localhost:20128` باز می شود و پایه API URL `http://localhost:20128/v1` است. -#### Arch Linux (AUR) +#### آرچ لینوکس (AUR) -Arch Linux users can install the [AUR package](https://aur.archlinux.org/packages/omniroute-bin), which installs OmniRoute and provides a systemd user service: +کاربران آرچ لینوکس می توانند [بسته AUR](https://aur.archlinux.org/packages/omniroute-bin) را نصب کنند که OmniRoute را نصب می کند و یک سرویس کاربر سیستمی ارائه می دهد: ```bash yay -S omniroute-bin systemctl --user enable --now omniroute.service ``` -| Command | Description | +| فرمان | توضیحات | | ----------------------- | ----------------------------------------------------------- | -| `omniroute` | Start server (`PORT=20128`, API and dashboard on same port) | -| `omniroute --port 3000` | Set canonical/API port to 3000 | -| `omniroute --mcp` | Start MCP server (stdio transport) | -| `omniroute --no-open` | Don't auto-open browser | -| `omniroute --help` | Show help | +| `omniroute` | راه اندازی سرور (`PORT=20128`، API و داشبورد در همان پورت) | +| `omniroute --port 3000` | پورت canonical/API را روی 3000 | تنظیم کنید +| `omniroute --mcp` | راه اندازی سرور MCP (stdio transport) | +| `omniroute --no-open` | مرورگر خودکار باز نشود | +| `omniroute --help` | نمایش کمک | -Optional split-port mode: +حالت اسپلیت پورت اختیاری: ```bash PORT=20128 DASHBOARD_PORT=20129 omniroute @@ -789,63 +789,63 @@ PORT=20128 DASHBOARD_PORT=20129 omniroute # Dashboard: http://localhost:20129 ``` -### 2) Uninstalling +### 2) حذف نصب -When you no longer need OmniRoute, we provide two quick scripts for a clean removal: +هنگامی که دیگر نیازی به OmniRoute ندارید، ما دو اسکریپت سریع برای حذف تمیز ارائه می دهیم: -| Command | Action | +| فرمان | اقدام | | ------------------------ | ----------------------------------------------------------------------------------- | -| `npm run uninstall` | Removes the system app but **keeps your DB and configurations** in `~/.omniroute`. | -| `npm run uninstall:full` | Removes the app AND permanently **erases all configurations, keys, and databases**. | +| `npm run uninstall` | برنامه سیستم را حذف می کند اما **DB و تنظیمات** شما را در `~/.omniroute` نگه می دارد. | +| `npm run uninstall:full` | برنامه را حذف می کند و برای همیشه **همه پیکربندی ها، کلیدها و پایگاه داده ها را پاک می کند**. | -> Note: To run these commands, navigate to the OmniRoute project folder (if you cloned it) and run them. Alternatively, if globally installed, you can simply run `npm uninstall -g omniroute`. +> توجه: برای اجرای این دستورات، به پوشه پروژه OmniRoute (اگر آن را شبیه سازی کرده اید) بروید و آنها را اجرا کنید. از طرف دیگر، اگر به صورت سراسری نصب شده باشد، می توانید به سادگی `npm uninstall -g omniroute` را اجرا کنید. -### Long-Running Streaming Timeouts +### وقفه های طولانی مدت استریم -For most deployments, you only need: +برای اکثر استقرارها، فقط نیاز دارید: -| Variable | Default | Purpose | +| متغیر | پیش فرض | هدف | | ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| `REQUEST_TIMEOUT_MS` | `600000` | خط پایه مشترک برای مهلت زمانی شروع پاسخ بالادست، مهلت زمانی پنهان Undici، درخواست‌های اثر انگشت TLS و زمان‌بندی درخواست پل/پراکسی API | +| `STREAM_IDLE_TIMEOUT_MS` | به ارث می برد `REQUEST_TIMEOUT_MS` | حداکثر فاصله بین تکه های جریان قبل از اینکه OmniRoute جریان SSE را لغو کند | -Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +سازگاری به عقب حفظ می شود: `FETCH_TIMEOUT_MS`، `API_BRIDGE_PROXY_TIMEOUT_MS`، و سایر متغیرهای وقفه زمانی هر لایه هنوز کار می کنند و خط پایه مشترک را لغو می کنند. -For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. +برای Claude بالادستی سازگار با کد (`anthropic-compatible-cc-*`)، OmniRoute همچنین سرصفحه خروجی `X-Stainless-Timeout` را از بازه زمانی واکشی حل‌شده استخراج می‌کند، بنابراین زمان‌بندی خواندن سمت ارائه‌دهنده با پیکربندی env شما همسو می‌شود. -For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default -`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, -only forwards client-provided `cache_control` markers. If the request does not include -`cache_control`, OmniRoute does not inject bridge-owned markers. +برای پراکسی های معکوس Claude شخص ثالث سازگار با کد، OmniRoute پیش فرض را نگه می دارد +`anthropic-beta` محافظه کارانه تنظیم می شود و وقتی `Client Cache Control` روی `Auto` باقی می ماند، +فقط نشانگرهای `cache_control` ارائه شده توسط مشتری را فوروارد می کند. اگر درخواست شامل نمی شود +`cache_control`، OmniRoute نشانگرهای متعلق به پل را تزریق نمی کند. -Advanced overrides are available if you need finer control: +در صورت نیاز به کنترل دقیق تر، لغو پیشرفته در دسترس است: -| Variable | Default | Purpose | +| متغیر | پیش فرض | هدف | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | -| `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | -| `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | -| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | -| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Undici idle keep-alive socket timeout | -| `TLS_CLIENT_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Timeout for TLS fingerprint requests made through `wreq-js` | -| `API_BRIDGE_PROXY_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` or `600000` | Timeout for `/v1` proxy forwarding from API port to dashboard port | -| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | Incoming request timeout on the API bridge server | -| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server | -| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | -| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +| `FETCH_TIMEOUT_MS` | به ارث می برد `REQUEST_TIMEOUT_MS` | زمان پایان پاسخ-شروع بالادستی تا رسیدن سرصفحه های پاسخ استفاده می شود | +| `FETCH_HEADERS_TIMEOUT_MS` | به ارث می برد `FETCH_TIMEOUT_MS` | محدودیت زمانی Undici برای دریافت سرصفحه های پاسخ بالادست | +| `FETCH_BODY_TIMEOUT_MS` | به ارث می برد `FETCH_TIMEOUT_MS` | محدودیت زمانی Undici بین تکه های بدنه بالادست (`0` آن را غیرفعال می کند) | +| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | زمان اتصال Undici TCP | +| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | مهلت زمانی سوکت بیکار نگه داشتن زنده Undici | +| `TLS_CLIENT_TIMEOUT_MS` | به ارث می برد `FETCH_TIMEOUT_MS` | مهلت زمانی برای درخواست های اثر انگشت TLS که از طریق `wreq-js` | +| `API_BRIDGE_PROXY_TIMEOUT_MS` | به ارث می برد `REQUEST_TIMEOUT_MS` یا `600000` | مهلت زمانی برای ارسال پروکسی `/v1` از پورت API به پورت داشبورد | +| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | زمان درخواست ورودی در سرور پل API | +| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | مهلت زمانی هدر ورودی در سرور پل API | +| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | وقفه ماندن زنده در سرور پل API | +| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | پایان زمان عدم فعالیت سوکت در سرور پل API (`0` آن را غیرفعال می کند) | -For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). +برای درخواست‌های پخش جریانی، `FETCH_TIMEOUT_MS` فقط راه‌اندازی اتصال / انتظار برای اولین پاسخ بالادستی را پوشش می‌دهد. پس از فعال شدن جریان، OmniRoute فقط در حالت توقف واقعی (`STREAM_IDLE_TIMEOUT_MS`) یا عدم فعالیت بدنه Undici (`FETCH_BODY_TIMEOUT_MS`) متوقف می شود. -If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy -timeouts are also higher than your OmniRoute stream/fetch timeouts. +اگر OmniRoute را پشت Nginx، Caddy، Cloudflare یا یک پراکسی معکوس دیگر اجرا می‌کنید، از پراکسی مطمئن شوید +زمان‌بندی‌ها نیز بیشتر از زمان‌های زمانی پخش/واکشی OmniRoute شما هستند. -### 2) Connect providers and create your API key +### 2) ارائه دهندگان را متصل کنید و کلید API خود را ایجاد کنید -1. Open Dashboard → `Providers` and connect at least one provider (OAuth or API key). -2. Open Dashboard → `Endpoints` and create an API key. -3. (Optional) Open Dashboard → `Combos` and set your fallback chain. +1. داشبورد → `Providers` را باز کنید و حداقل یک ارائه دهنده (کلید OAuth یا API) را وصل کنید. +2. داشبورد → `Endpoints` را باز کنید و یک کلید API ایجاد کنید. +3. (اختیاری) داشبورد → `Combos` را باز کنید و زنجیره بازگشتی خود را تنظیم کنید. -### 3) Point your coding tool to OmniRoute +### 3) ابزار کدنویسی خود را روی OmniRoute قرار دهید ```txt Base URL: http://localhost:20128/v1 @@ -853,20 +853,20 @@ API Key: [copy from Endpoint page] Model: if/kimi-k2-thinking (or any provider/model prefix) ``` -### 4) Enable and validate protocols (v2.0) +### 4) فعال کردن و اعتبارسنجی پروتکل ها (v2.0) -**MCP (for tool-driven operations):** +**MCP (برای عملیات ابزار محور):** ```bash omniroute --mcp ``` -Then connect your MCP client over `stdio` and test tools like: +سپس مشتری MCP خود را به `stdio` متصل کنید و ابزارهایی مانند: - `omniroute_get_health` - `omniroute_list_combos` -**A2A (for agent-to-agent workflows):** +**A2A (برای گردش کار عامل به عامل):** ```bash curl http://localhost:20128/.well-known/agent.json @@ -878,15 +878,15 @@ curl -X POST http://localhost:20128/a2a \ -d '{"jsonrpc":"2.0","id":"quickstart","method":"message/send","params":{"skill":"quota-management","messages":[{"role":"user","content":"Give me a short quota summary."}]}}' ``` -### 5) Validate everything end-to-end (recommended) +### 5) اعتبارسنجی همه چیز از انتها به انتها (توصیه می شود) ```bash npm run test:protocols:e2e ``` -This suite validates real MCP and A2A client flows against a running app. +این مجموعه، جریان های سرویس گیرنده MCP و A2A واقعی را در برابر یک برنامه در حال اجرا تأیید می کند. -### Alternative: run from source +### جایگزین: از منبع اجرا شود ```bash cp .env.example .env @@ -897,7 +897,7 @@ PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm
Void Linux (`xbps-src` template) -For Void Linux users, you can build a native package using `xbps-src`. Save this block as `srcpkgs/omniroute/template`: +برای کاربران Void Linux، می توانید یک بسته بومی با استفاده از `xbps-src` بسازید. این بلوک را به عنوان `srcpkgs/omniroute/template` ذخیره کنید: ```bash # Template file for 'omniroute' @@ -1005,9 +1005,9 @@ post_install() { ## 🐳 Docker -OmniRoute is available as a public Docker image on [Docker Hub](https://hub.docker.com/r/diegosouzapw/omniroute). +OmniRoute به عنوان یک تصویر عمومی Docker در [Docker Hub](https://hub.docker.com/r/diegosouzapw/omniroute) در دسترس است. -**Quick run:** +**اجرای سریع:** ```bash docker run -d \ @@ -1019,7 +1019,7 @@ docker run -d \ diegosouzapw/omniroute:latest ``` -**With environment file:** +**به همراه فایل محیطی:** ```bash # Copy and edit .env first @@ -1035,7 +1035,7 @@ docker run -d \ diegosouzapw/omniroute:latest ``` -**Using Docker Compose:** +**با استفاده از Docker نوشتن:** ```bash # Base profile (no CLI tools) @@ -1045,22 +1045,22 @@ docker compose --profile base up -d docker compose --profile cli up -d ``` -Dashboard support for Docker deployments now includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL. +پشتیبانی داشبورد برای استقرار Docker اکنون شامل یک **Cloudflare Quick Tunnel** با یک کلیک روی `Dashboard → Endpoints` است. اولین فعال `cloudflared` را فقط در صورت نیاز دانلود می‌کند، یک تونل موقت به نقطه پایانی فعلی `/v1` شما راه‌اندازی می‌کند و `https://*.trycloudflare.com/v1` URL تولید شده را مستقیماً زیر URL عمومی عادی شما نشان می‌دهد. Notes: -- Quick Tunnel URLs are temporary and change after every restart. -- Quick Tunnels are not auto-restored after an OmniRoute or container restart. Re-enable them from the dashboard when needed. -- Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`. -- Managed Quick Tunnels default to HTTP/2 transport to avoid noisy QUIC UDP buffer warnings in constrained container environments. Set `CLOUDFLARED_PROTOCOL=quic` or `auto` if you want a different transport. -- Docker images bundle system CA roots and pass them to managed `cloudflared`, which avoids TLS trust failures when the tunnel bootstraps inside the container. -- SQLite runs in WAL mode. `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. -- The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40` (or similar) so manual stops do not cut off shutdown cleanup. -- Set `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` if you want OmniRoute to use an existing binary instead of downloading one. +- URL های تونل سریع موقتی هستند و پس از هر بار راه اندازی مجدد تغییر می کنند. +- تونل های سریع پس از راه اندازی مجدد OmniRoute یا کانتینر به طور خودکار بازیابی نمی شوند. در صورت نیاز دوباره آنها را از داشبورد فعال کنید. +- نصب مدیریت شده در حال حاضر از لینوکس، macOS و ویندوز در `x64` / `arm64` پشتیبانی می کند. +- تونل های سریع مدیریت شده به طور پیش فرض برای حمل و نقل HTTP/2 برای جلوگیری از هشدارهای بافر QUIC UDP پر سر و صدا در محیط های کانتینری محدود. اگر می خواهید حمل و نقل متفاوتی داشته باشید، `CLOUDFLARED_PROTOCOL=quic` یا `auto` را تنظیم کنید. +- تصاویر Docker ریشه های CA سیستم را بسته بندی می کند و آنها را به `cloudflared` مدیریت شده ارسال می کند، که از خرابی اعتماد TLS در هنگام بوت استرپ تونل در داخل ظرف جلوگیری می کند. +- SQLite در حالت WAL اجرا می شود. باید به `docker stop` اجازه داده شود تا پایان یابد تا OmniRoute بتواند آخرین تغییرات را به `storage.sqlite` بازرسی کند. +- فایل های Compose همراه از قبل یک دوره مهلت توقف 40 ثانیه تعیین کرده اند. اگر مستقیماً تصویر را اجرا می‌کنید، `--stop-timeout 40` (یا مشابه) را نگه دارید تا توقف‌های دستی پاکسازی خاموش را قطع نکنند. +- اگر می خواهید OmniRoute از یک باینری موجود به جای دانلود استفاده کند، `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` را تنظیم کنید. -**Using Docker Compose with Caddy (HTTPS Auto-TLS):** +**استفاده از Docker Compose with Caddy (HTTPS Auto-TLS):** -OmniRoute can be securely exposed using Caddy's automatic SSL provisioning. Ensure your domain's DNS A record points to your server's IP. +OmniRoute را می توان با استفاده از تهیه خودکار SSL Caddy به طور ایمن در معرض دید قرار داد. مطمئن شوید که رکورد DNS A دامنه شما به IP سرور شما اشاره دارد. ```yaml services: @@ -1087,26 +1087,26 @@ volumes: omniroute-data: ``` -| Image | Tag | Size | Description | +| تصویر | برچسب | اندازه | توضیحات | | ------------------------ | -------- | ------ | --------------------- | -| `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `latest` | ~250 مگابایت | آخرین نسخه پایدار | +| `diegosouzapw/omniroute` | `3.6.2` | ~250 مگابایت | نسخه فعلی | --- -## 🖥️ Desktop App — Offline & Always-On +## 🖥️ برنامه دسکتاپ — آفلاین و همیشه روشن -> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux. +> 🆕 **جدید!** OmniRoute اکنون به عنوان یک **برنامه دسکتاپ بومی** برای Windows، macOS و Linux در دسترس است. -Run OmniRoute as a standalone desktop app — no terminal, no browser, no internet required for local models. The Electron-based app includes: +OmniRoute را به عنوان یک برنامه دسکتاپ مستقل اجرا کنید - بدون پایانه، بدون مرورگر، بدون نیاز به اینترنت برای مدل‌های محلی. برنامه مبتنی بر الکترون شامل موارد زیر است: -- 🖥️ **Native Window** — Dedicated app window with system tray integration -- 🔄 **Auto-Start** — Launch OmniRoute on system login -- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues -- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux) -- 🌐 **Offline Mode** — Works fully offline with bundled server +- 🖥️ **پنجره بومی** - پنجره برنامه اختصاصی با ادغام سینی سیستم +- 🔄 **شروع خودکار** — راه اندازی OmniRoute در ورود به سیستم +- 🔔 ** اعلان های بومی ** - هشدارهایی را برای اتمام سهمیه یا مشکلات ارائه دهنده دریافت کنید +- ⚡ **نصب با یک کلیک** - NSIS (ویندوز)، DMG (macOS)، AppImage (لینوکس) +- 🌐 ** حالت آفلاین ** - با سرور همراه به طور کامل آفلاین کار می کند -### Inicio Rápido +### اینیسیو راپیدو ```bash # Development mode @@ -1119,50 +1119,50 @@ npm run electron:build:mac # macOS (.dmg) — x64 & arm64 npm run electron:build:linux # Linux (.AppImage) ``` -### System Tray +### سینی سیستم -When minimized, OmniRoute lives in your system tray with quick actions: +هنگامی که به حداقل می رسد، OmniRoute با اقدامات سریع در سینی سیستم شما زندگی می کند: -- Open dashboard -- Change server port -- Quit application +- داشبورد را باز کنید +- تغییر پورت سرور +- برنامه را ترک کنید -📖 Full documentation: [`electron/README.md`](electron/README.md) +📖 مستندات کامل: [`electron/README.md`](electron/README.md) --- -## 💰 Pricing at a Glance +## 💰 قیمت در یک نگاه -| Tier | Provider | Cost | Quota Reset | Best For | +| ردیف | ارائه دهنده | هزینه | بازنشانی سهمیه | بهترین برای | | ------------------- | --------------------------- | ------------------------------------- | --------------------- | ---------------------------------- | -| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed | -| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | -| | GitHub Copilot | $10-19/mo | Monthly | GitHub users | -| **🔑 API KEY** | NVIDIA NIM | **FREE ACCESS** (current terms apply) | ~40 RPM | 70+ open models | -| | Cerebras | **FREE** (1M tok/day) | 60K TPM / 30 RPM | World's fastest | -| | Groq | **FREE** (30 RPM) | 14.4K RPD | Ultra-fast Llama/Gemma | -| | DeepSeek V3.2 | $0.27/$1.10 per 1M | None | Best price/quality reasoning | -| | xAI Grok-4 Fast | **$0.20/$0.50 per 1M** 🆕 | None | Fastest + tool calling, ultralow | -| | xAI Grok-4 (standard) | $0.20/$1.50 per 1M 🆕 | None | Reasoning flagship from xAI | -| | Mistral | Free trial + paid | Rate limited | European AI | -| | OpenRouter | Pay-per-use | None | 100+ models aggr. | -| **💰 CHEAP** | GLM-5 (via Z.AI) 🆕 | $0.5/1M | Daily 10AM | 128K output, newest flagship | -| | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | -| | MiniMax M2.5 🆕 | $0.3/1M input | 5-hour rolling | Reasoning + agentic tasks | -| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option | -| | Kimi K2.5 (Moonshot API) 🆕 | Pay-per-use | None | Direct Moonshot API access | -| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost | -| **🆓 FREE ACCESS** | Qoder | **$0** | Limits apply | Selected models; terms apply | -| | Qwen | **$0** | Limits apply | Selected models; terms apply | -| | Kiro | **$0** | Credit/account limits | Claude access; current terms apply | -| | LongCat signup credit | **$0** (10M one-time; KYC) | One-time | Signup grant; not recurring | -| | Pollinations AI 🆕 | **$0** (no key needed) | 1 req/15s | GPT-5, Claude, DeepSeek, Llama 4 | -| | Cloudflare Workers AI 🆕 | **$0** (10K Neurons/day) | ~150 resp/day | 50+ models, global edge | -| | Scaleway AI 🆕 | **$0** (1M tokens total) | Rate limited | EU/GDPR, Qwen3 235B, Llama 70B | +| **💳 اشتراک ** | کد Claude (Pro) | 20 دلار در ماه | 5 ساعت + هفتگی | قبلاً مشترک شده است | +| | Codex (Plus/Pro) | 20-200 دلار در ماه | 5 ساعت + هفتگی | کاربران OpenAI | +| | GitHub Copilot | 10-19 دلار در ماه | ماهانه | کاربران GitHub | +| **🔑 کلید API ** | NVIDIA NIM | **دسترسی رایگان** (شرایط فعلی اعمال می شود) | ~40 دور در دقیقه | 70+ مدل باز | +| | Cerebras | **رایگان** (1 میلیون توک در روز) | 60K TPM / 30 RPM | سریعترین جهان | +| | Groq | **رایگان** (30 دور در دقیقه) | 14.4K RPD | Llama/Gemma فوق العاده سریع | +| | DeepSeek V3.2 | 0.27 دلار / 1.10 دلار در هر 1 میلیون | هیچکدام | بهترین استدلال قیمت/کیفیت | +| | xAI Grok-4 Fast | **0.20$/0.50$ در هر 1 میلیون** 🆕 | هیچکدام | سریعترین + فراخوانی ابزار، فوق العاده کم | +| | xAI Grok-4 (استاندارد) | 0.20 دلار / 1.50 دلار به ازای هر 1 میلیون 🆕 | هیچکدام | گل سرسبد استدلال از xAI | +| | میسترال | آزمایشی رایگان + پولی | نرخ محدود | هوش مصنوعی اروپایی | +| | OpenRouter | پرداخت به ازای استفاده | هیچکدام | 100+ مدل aggr. | +| **💰 ارزان ** | GLM-5 (از طریق Z.AI) 🆕 | 0.5/1 میلیون دلار | روزانه 10 صبح | خروجی 128K، جدیدترین پرچمدار | +| | GLM-4.7 | 0.6/1 میلیون دلار | روزانه 10 صبح | پشتیبان بودجه | +| | MiniMax M2.5 🆕 | 0.3/1 میلیون دلار ورودی | نورد 5 ساعته | استدلال + وظایف نمایندگی | +| | MiniMax M2.1 | 0.2/1 میلیون دلار | نورد 5 ساعته | ارزان ترین گزینه | +| | Kimi K2.5 (Moonshot API) 🆕 | پرداخت به ازای استفاده | هیچکدام | دسترسی مستقیم Moonshot API | +| | Kimi K2 | 9 دلار در ماه آپارتمان | 10 میلیون توکن در ماه | هزینه قابل پیش بینی | +| **🆓 دسترسی رایگان ** | Qoder | **0$** | محدودیت اعمال می شود | مدل های منتخب؛ شرایط اعمال می شود | +| | Qwen | **0$** | محدودیت اعمال می شود | مدل های منتخب؛ شرایط اعمال می شود | +| | Kiro | **0$** | محدودیت اعتبار/حساب | دسترسی Claude؛ شرایط فعلی اعمال می شود | +| | اعتبار ثبت نام LongCat | **0$** (10 میلیون یکبار مصرف؛ KYC) | یکبار مصرف | کمک هزینه ثبت نام؛ تکرار نشدن | +| | Pollinations AI 🆕 | **0$** (بدون نیاز به کلید) | 1 req/15s | GPT-5، Claude، DeepSeek، Llama 4 | +| | Cloudflare Workers AI 🆕 | **0$** (10 هزار نورون در روز) | ~150 بار در روز | 50+ مدل، لبه جهانی | +| | Scaleway AI 🆕 | **0$** (مجموع 1 میلیون توکن) | نرخ محدود | EU/GDPR، Qwen3 235B، Llama 70B | -> 🆕 **New models added (Mar 2026):** Grok-4 Fast family at $0.20/$0.50/M (benchmarked at 1143ms — 30% faster than Gemini 2.5 Flash), GLM-5 via Z.AI with 128K output, MiniMax M2.5 reasoning, DeepSeek V3.2 updated pricing, Kimi K2.5 via Moonshot direct API. +> 🆕 **مدل‌های جدید اضافه شده (مارس 2026):** خانواده Grok-4 Fast با 0.20 دلار/0.50 دلار در میلیون دلار (معیار شده در 1143 میلی‌ثانیه — 30 درصد سریع‌تر از فلش Gemini 2.5)، GLM-5 از طریق Z.AI، خروجی Z.AI، Z.AI، Z. DeepSeek V3.2 قیمت به روز شده، Kimi K2.5 از طریق Moonshot مستقیم API. -**💡 $0 Combo Stack — The Complete Free Setup:** +**💡 $0 Combo Stack — راه اندازی کامل رایگان:** ``` # 🆓 Free-access examples — provider limits and terms apply @@ -1179,138 +1179,138 @@ NVIDIA NIM (nvidia/) → selected models — current rate limits apply Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day ``` -**Current $0 access where listed; availability is not guaranteed.** A combo can try the next eligible route when a quota or upstream fails. +**دسترسی کنونی $0 در جایی که فهرست شده است. در دسترس بودن تضمین نشده است.** یک ترکیبی می‌تواند مسیر واجد شرایط بعدی را در صورت شکست سهمیه یا بالادستی امتحان کند. --- --- -## 🆓 Free Models — What You Actually Get +## 🆓 مدل های رایگان - آنچه در واقع به دست می آورید -> The entries below summarize access that was listed as free when audited. Provider quotas, card/account/KYC requirements, models, regions and terms can change. A combo broadens fallback coverage but does not guarantee uninterrupted $0 access. +> ورودی‌های زیر دسترسی‌هایی را که هنگام ممیزی رایگان فهرست شده‌اند، خلاصه می‌کنند. سهمیه‌های ارائه‌دهنده، کارت/حساب/ الزامات KYC، مدل‌ها، مناطق و شرایط می‌توانند تغییر کنند. ترکیبی پوشش بازگشتی را گسترش می دهد اما دسترسی بدون وقفه $0 را تضمین نمی کند. -### 🔵 CLAUDE MODELS (via Kiro — AWS Builder ID) +### 🔵 CLAUDE MODELS (از طریق Kiro — AWS Builder ID) -| Model | Prefix | Limit | Rate Limit | +| مدل | پیشوند | محدود | محدودیت نرخ | | ------------------- | ------ | ------------- | --------------------- | -| `claude-sonnet-4.5` | `kr/` | No published token cap | Provider/account limits may apply | -| `claude-haiku-4.5` | `kr/` | No published token cap | Provider/account limits may apply | -| `claude-opus-4.6` | `kr/` | No published token cap | Latest Opus; provider/account limits apply | +| `claude-sonnet-4.5` | `kr/` | بدون درپوش توکن منتشر شده | ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | +| `claude-haiku-4.5` | `kr/` | بدون درپوش توکن منتشر شده | ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | +| `claude-opus-4.6` | `kr/` | بدون درپوش توکن منتشر شده | آخرین Opus; محدودیت های ارائه دهنده/حساب اعمال می شود | -### 🟢 QODER MODELS (Free PAT via qodercli) +### مدل QODER (رایگان PAT از طریق qodercli) -| Model | Prefix | Limit | Rate Limit | +| مدل | پیشوند | محدود | محدودیت نرخ | | ------------------ | ------ | ------------- | --------------- | -| `kimi-k2-thinking` | `if/` | No published token cap | Provider/account limits may apply | -| `qwen3-coder-plus` | `if/` | No published token cap | Provider/account limits may apply | -| `deepseek-r1` | `if/` | No published token cap | Provider/account limits may apply | -| `minimax-m2.1` | `if/` | No published token cap | Provider/account limits may apply | -| `kimi-k2` | `if/` | No published token cap | Provider/account limits may apply | +| `kimi-k2-thinking` | `if/` | بدون درپوش توکن منتشر شده | ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | +| `qwen3-coder-plus` | `if/` | بدون درپوش توکن منتشر شده | ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | +| `deepseek-r1` | `if/` | بدون درپوش توکن منتشر شده | ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | +| `minimax-m2.1` | `if/` | بدون درپوش توکن منتشر شده | ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | +| `kimi-k2` | `if/` | بدون درپوش توکن منتشر شده | ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | -> Recommended connection method: **Personal Access Token + `qodercli`**. Browser OAuth is -> experimental and disabled by default unless `QODER_OAUTH_*` environment variables are configured. +> روش اتصال پیشنهادی: ** رمز دسترسی شخصی + `qodercli`**. مرورگر OAuth است +> آزمایشی و به طور پیش فرض غیرفعال است مگر اینکه متغیرهای محیطی `QODER_OAUTH_*` پیکربندی شده باشند. -### 🟡 QWEN MODELS (Device Code Auth) +### 🡑 مدل‌های QWEN (تأیید کد دستگاه) -| Model | Prefix | Limit | Rate Limit | +| مدل | پیشوند | محدود | محدودیت نرخ | | ------------------- | ------ | ------------- | ------------------- | -| `qwen3-coder-plus` | `qw/` | No published token cap | Provider/account limits may apply | -| `qwen3-coder-flash` | `qw/` | No published token cap | Provider/account limits may apply | -| `qwen3-coder-next` | `qw/` | No published token cap | Provider/account limits may apply | -| `vision-model` | `qw/` | No published token cap | Multimodal; provider/account limits may apply | +| `qwen3-coder-plus` | `qw/` | بدون درپوش توکن منتشر شده | ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | +| `qwen3-coder-flash` | `qw/` | بدون درپوش توکن منتشر شده | ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | +| `qwen3-coder-next` | `qw/` | بدون درپوش توکن منتشر شده | ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | +| `vision-model` | `qw/` | بدون درپوش توکن منتشر شده | چند وجهی؛ ممکن است محدودیت های ارائه دهنده/حساب اعمال شود | -### ⚫ NVIDIA NIM (Free API Key — build.nvidia.com) +### ⚫ NVIDIA NIM (کلید رایگان API — build.nvidia.com) -| Tier | Daily Limit | Rate Limit | Notes | +| ردیف | محدودیت روزانه | محدودیت نرخ | یادداشت ها | | ---------- | ------------ | ----------- | ------------------------------------------------------ | -| Free (Dev) | No token cap | **~40 RPM** | 70+ models; transitioning to pure rate limits mid-2025 | +| رایگان (Dev) | بدون درپوش رمزی | **~40 دور در دقیقه** | 70+ مدل؛ انتقال به محدودیت های نرخ خالص اواسط سال 2025 | -Popular free models: `moonshotai/kimi-k2.5` (Kimi K2.5), `z-ai/glm4.7` (GLM 4.7), `deepseek-ai/deepseek-v3.2` (DeepSeek V3.2), `nvidia/llama-3.3-70b-instruct`, `deepseek/deepseek-r1` +مدل‌های رایگان پرطرفدار: `moonshotai/kimi-k2.5` (Kimi K2.5)، `z-ai/glm4.7` (GLM 4.7)، `deepseek-ai/deepseek-v3.2` (DeepSeek V3.2)، KimiTXQ3. -### ⚪ CEREBRAS (Free API Key — inference.cerebras.ai) +### ⚪ CEREBRAS (کلید رایگان API — inference.cerebras.ai) -| Tier | Daily Limit | Rate Limit | Notes | +| ردیف | محدودیت روزانه | محدودیت نرخ | یادداشت ها | | ---- | ----------------- | ---------------- | ------------------------------------------- | -| Free | **1M tokens/day** | 60K TPM / 30 RPM | World's fastest LLM inference; resets daily | +| رایگان | **1 میلیون توکن در روز** | 60K TPM / 30 RPM | سریعترین استنتاج LLM در جهان. تنظیم مجدد روزانه | -Available free: `llama-3.3-70b`, `llama-3.1-8b`, `deepseek-r1-distill-llama-70b` +رایگان موجود: `llama-3.3-70b`، `llama-3.1-8b`، `deepseek-r1-distill-llama-70b` -### 🔴 GROQ (Free API Key — console.groq.com) +### 🔴 GROQ (کلید رایگان API — console.groq.com) -| Tier | Daily Limit | Rate Limit | Notes | +| ردیف | محدودیت روزانه | محدودیت نرخ | یادداشت ها | | ---- | ------------- | ---------------- | ----------------------------------------- | -| Free | **14.4K RPD** | 30 RPM per model | No credit card; 429 on limit, not charged | +| رایگان | **14.4K RPD** | 30 دور در دقیقه در هر مدل | بدون کارت اعتباری؛ 429 محدود، شارژ نشده | -Available free: `llama-3.3-70b-versatile`, `gemma2-9b-it`, `mixtral-8x7b`, `whisper-large-v3` +رایگان موجود: `llama-3.3-70b-versatile`، `gemma2-9b-it`، `mixtral-8x7b`، `whisper-large-v3` -### 🔴 LONGCAT AI (Signup credit — KYC required) +### 🔴 LONGCAT AI (اعتبار ثبت نام — KYC مورد نیاز است) -| Model | Prefix | Current catalog grant | Notes | +| مدل | پیشوند | کمک هزینه کاتالوگ فعلی | یادداشت ها | | ------------- | ------ | ----------------------- | --------------------------------------------------- | -| `LongCat-2.0` | `lc/` | **10M tokens one-time** | Signup grant; not a recurring monthly or daily pool | +| `LongCat-2.0` | `lc/` | **10 میلیون توکن یکبار مصرف** | کمک هزینه ثبت نام؛ نه یک استخر تکراری ماهانه یا روزانه | -> Provider terms, eligibility and model availability can change. See [`FREE_TIERS.md`](../../reference/FREE_TIERS.md) for the audited catalog entry. +> شرایط ارائه دهنده، واجد شرایط بودن و در دسترس بودن مدل می تواند تغییر کند. [`FREE_TIERS.md`](../../reference/FREE_TIERS.md) را برای ورودی کاتالوگ ممیزی شده ببینید. -### 🟢 POLLINATIONS AI (No API Key Required) 🆕 +### AI POLLINATIONS (بدون نیاز به کلید API) 🆕 -| Model | Prefix | Rate Limit | Provider Behind | +| مدل | پیشوند | محدودیت نرخ | ارائه دهنده پشت | | ---------- | ------ | ---------- | ------------------ | -| `openai` | `pol/` | 1 req/15s | GPT-5 | -| `claude` | `pol/` | 1 req/15s | Anthropic Claude | -| `gemini` | `pol/` | 1 req/15s | Google Gemini | -| `deepseek` | `pol/` | 1 req/15s | DeepSeek V3 | -| `llama` | `pol/` | 1 req/15s | Meta Llama 4 Scout | -| `mistral` | `pol/` | 1 req/15s | Mistral AI | +| `openai` | `pol/` | 1 req/15s | GPT-5 | +| `claude` | `pol/` | 1 req/15s | آنتروپیک Claude | +| `gemini` | `pol/` | 1 req/15s | گوگل جمینی | +| `deepseek` | `pol/` | 1 req/15s | DeepSeek V3 | +| `llama` | `pol/` | 1 req/15s | Meta Llama 4 Scout | +| `mistral` | `pol/` | 1 req/15s | Mistral AI | -> ✨ **Zero friction:** No signup, no API key. Add the Pollinations provider with an empty key field and it works immediately. +> ✨ **اصطکاک صفر:** بدون ثبت نام، بدون کلید API. ارائه دهنده Pollinations را با یک فیلد کلید خالی اضافه کنید و بلافاصله کار می کند. -### 🟠 CLOUDFLARE WORKERS AI (Free API Key — cloudflare.com) 🆕 +### CLOUDFLARE WORKERS AI (کلید رایگان API — cloudflare.com) 🆕 -| Tier | Daily Neurons | Equivalent Usage | Notes | +| ردیف | نورون های روزانه | استفاده معادل | یادداشت ها | | ---- | ------------- | --------------------------------------- | ----------------------- | -| Free | **10,000** | ~150 LLM resp / 500s audio / 15K embeds | Global edge, 50+ models | +| رایگان | **10000** | ~150 LLM resp / صدای 500s / 15K جاسازی | لبه جهانی، 50+ مدل | -Popular free models: `@cf/meta/llama-3.3-70b-instruct`, `@cf/google/gemma-3-12b-it`, `@cf/openai/whisper-large-v3-turbo` (free audio!), `@cf/qwen/qwen2.5-coder-15b-instruct` +مدل های رایگان محبوب: `@cf/meta/llama-3.3-70b-instruct`، `@cf/google/gemma-3-12b-it`، `@cf/openai/whisper-large-v3-turbo` (صدای رایگان!)، `@cf/qwen/qwen2.5-coder-15b-instruct` -> Requires API Token + Account ID from [dash.cloudflare.com](https://dash.cloudflare.com). Store Account ID in provider settings. +> به رمز API + شناسه حساب از [dash.cloudflare.com](https://dash.cloudflare.com) نیاز دارد. شناسه حساب را در تنظیمات ارائه دهنده ذخیره کنید. -### 🟣 SCALEWAY AI (1M Free Tokens — scaleway.com) 🆕 +### SCALEWAY AI (1 میلیون توکن رایگان — scaleway.com) 🆕 -| Tier | Free Quota | Location | Notes | +| ردیف | سهمیه آزاد | مکان | یادداشت ها | | ---- | ------------- | ------------ | ----------------------------------- | -| Free | **1M tokens** | 🇫🇷 Paris, EU | No credit card needed within limits | +| رایگان | **1 میلیون توکن** | 🇫🇷 پاریس، اتحادیه اروپا | بدون نیاز به کارت اعتباری در محدوده | -Available free: `qwen3-235b-a22b-instruct-2507` (Qwen3 235B!), `llama-3.1-70b-instruct`, `mistral-small-3.2-24b-instruct-2506`, `deepseek-v3-0324` +رایگان موجود: `qwen3-235b-a22b-instruct-2507` (Qwen3 235B!)، `llama-3.1-70b-instruct`، `mistral-small-3.2-24b-instruct-2506`، `deepseek-v3-0324` -> EU/GDPR compliant. Get API key at [console.scaleway.com](https://console.scaleway.com). +> مطابق با اتحادیه اروپا/GDPR. کلید API را در [console.scaleway.com](https://console.scaleway.com) دریافت کنید. -> **💡 Free-access examples (provider limits and terms apply):** +> **💡 نمونه های دسترسی رایگان (محدودیت ها و شرایط ارائه دهنده اعمال می شود):** > > ``` -> Kiro (kr/) → Claude access — account/credit limits apply -> Qoder (if/) → selected models — no published token cap; limits apply -> LongCat (lc/) → LongCat-2.0 — 10M one-time signup credit; KYC required -> Pollinations (pol/) → GPT-5, Claude, DeepSeek, Llama 4 — no key needed -> Qwen (qw/) → selected models — no published token cap; limits apply -> Gemini (gemini/) → selected free-tier models — current quotas apply -> Cloudflare AI (cf/) → 50+ models — 10K Neurons/day -> Scaleway (scw/) → Qwen3 235B, Llama 70B — 1M free tokens (EU) -> Groq (groq/) → selected models — current per-model rate limits apply -> NVIDIA NIM (nvidia/) → selected models — current rate limits apply -> Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day +> Kiro (kr/) → دسترسی Claude — محدودیت های حساب/اعتبار اعمال می شود +> Qoder (اگر/) ← مدل های انتخابی — بدون پوشش توکن منتشر شده. محدودیت اعمال می شود +> LongCat (lc/) → LongCat-2.0 — 10 میلیون اعتبار ثبت نام یکباره؛ KYC مورد نیاز است +> Pollinations (pol/) → GPT-5، Claude، DeepSeek، Llama 4 — بدون نیاز به کلید +> Qwen (qw/) ← مدل‌های انتخابی — بدون درپوش توکن منتشر شده. محدودیت اعمال می شود +> جمینی (جمینی/) ← مدل های سطح آزاد انتخاب شده — سهمیه های فعلی اعمال می شود +> Cloudflare AI (cf/) → بیش از 50 مدل — 10 هزار نورون در روز +> Scaleway (scw/) → Qwen3 235B، Llama 70B — 1 میلیون توکن رایگان (EU) +> Groq (groq/) ← مدل‌های انتخابی - محدودیت‌های نرخ فعلی برای هر مدل اعمال می‌شود +> NVIDIA NIM (nvidia/) → مدل های انتخابی — محدودیت های نرخ فعلی اعمال می شود +> Cerebras (مغز/) → لاما/Qwen سریعترین جهان — 1 میلیون توک در روز > ``` -## 🎙️ Free Transcription Combo +## 🎙️ ترکیب رونویسی رایگان -> Transcription access depends on each upstream allowance — Deepgram and AssemblyAI signup credits can lead, with Groq Whisper as a rate-limited fallback. +> دسترسی به رونویسی به هر کمک هزینه بالادستی بستگی دارد - اعتبارات ثبت نام Deepgram و AssemblyAI می توانند منجر شوند، با Groq Whisper به عنوان یک بازگشت با نرخ محدود. -| Provider | Free Credits | Best Model | Rate Limit | +| ارائه دهنده | اعتبار رایگان | بهترین مدل | محدودیت نرخ | | ----------------- | --------------------------- | -------------------------------------------- | ---------------------------------------- | -| 🟢 **Deepgram** | **$200 free** (signup) | `nova-3` — best accuracy, 30+ languages | No RPM limit on free credits | -| 🔵 **AssemblyAI** | **$50 free** (signup) | `universal-3-pro` — chapters, sentiment, PII | No RPM limit on free credits | -| 🔴 **Groq** | **Free tier; limits apply** | `whisper-large-v3` — OpenAI Whisper | Current model-specific rate limits apply | +| 🢢 **دیپگرام** | **200 دلار رایگان** (ثبت نام) | `nova-3` — بهترین دقت، بیش از 30 زبان | بدون محدودیت RPM در اعتبارات رایگان | +| 🔵 **AssemblyAI** | **50 دلار رایگان ** (ثبت نام) | `universal-3-pro` — فصول، احساسات، PII | بدون محدودیت RPM در اعتبارات رایگان | +| 🔴 **Groq** | ** ردیف آزاد؛ محدودیت اعمال می شود** | `whisper-large-v3` — OpenAI Whisper | محدودیت‌های نرخ ویژه مدل فعلی اعمال می‌شود | -**Suggested combo in `/dashboard/combos`:** +** ترکیب پیشنهادی در `/dashboard/combos`:** ``` Name: free-transcription @@ -1321,180 +1321,180 @@ Nodes: [3] groq/whisper-large-v3 → free access; rate limits apply ``` -Then in `/dashboard/media` → **Transcription** tab: upload any audio or video file → select your combo endpoint → get transcription in supported formats. +سپس در زبانه `/dashboard/media` → **رونویسی**: هر فایل صوتی یا تصویری را آپلود کنید → نقطه پایانی ترکیبی خود را انتخاب کنید → رونویسی را در قالب های پشتیبانی شده دریافت کنید. -## 💡 Key Features +## 💡 ویژگی های کلیدی -OmniRoute v3.6 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 به عنوان یک پلت فرم عملیاتی ساخته شده است، نه فقط یک پروکسی رله. -### 🆕 New — v3.6.x Highlights (Apr 2026) +### 🆕 جدید — نسخه های برجسته نسخه 3.6.x (آوریل 2026) -| Feature | What It Does | +| ویژگی | چه می کند | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | -| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | -| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | -| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | -| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | -| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | -| ⏳ **Wait For Cooldown** | Server-side chat retries when every candidate connection is cooling down; configurable `enabled`, `maxRetries`, and `maxRetryWaitSec` | -| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | -| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | -| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | -| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | -| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | -| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | -| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | -| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | -| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | -| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| 🌐 ** پل V1 WebSocket ** | ترافیک OpenAI سازگار با WebSocket ارتقا یافته و از طریق `/v1/ws` پروکسی شده است — پخش جریانی کامل از طریق WS با تأیید جلسه (کلید API یا کوکی جلسه) | +| 🔑 **همگام سازی نشانه ها و بسته پیکربندی** | برای نقاط پایانی همگام سازی پیکربندی، نشانه های همگام سازی را صادر/لغو کنید. بسته‌های پیکربندی با ETag برای نظرسنجی با پهنای باند کارآمد نسخه‌بندی شده‌اند | +| 🧠 **GLM از پیش تنظیم تفکر (glmt)** | GLM Thinking ثبت شده درجه یک: 65 536 حداکثر توکن، 24 576 بودجه فکری، مهلت زمانی 900s، همگام سازی استفاده و قیمت گذاری — Claude سازگار API | +| 🔢 **شمارش توکن هیبریدی** | در صورت موجود بودن از `/messages/count_tokens` سمت ارائه دهنده استفاده می کند. بازگشت به تخمین - ردیابی دقیق استفاده بدون حدس زدن | +| 🌱 **مدل مستعار خودکار بذر ** | بیش از 30 نام مستعار گویش متقابل پروکسی در هنگام راه‌اندازی عادی شدند — دیگر عدم تطابق مسیریابی | +| 🛡️ **واکشی ایمن خروجی** | تمام اعتبار سنجی ارائه دهنده و کشف مدل از طریق یک لایه واکشی محافظت شده انجام می شود که URL های خصوصی/محلی را با تلاش مجدد، مهلت زمانی و حفاظت SSRF مسدود می کند | +| ⏳ **منتظر خنک شدن باشید** | هنگامی که هر اتصال نامزد در حال خنک شدن است، چت سمت سرور دوباره تکرار می شود. قابل تنظیم `enabled`، `maxRetries`، و `maxRetryWaitSec` | +| 🔍 **Runtime Env Validation** | راه‌اندازی تمام env vars را با طرحواره‌های Zod تأیید می‌کند - خطاهای پاک برای اسرار گم‌شده، URL‌های نامعتبر یا انواع اشتباه | +| 📋 **توسعه حسابرسی انطباق** | گزارش‌های حسابرسی ساختاریافته با صفحه‌بندی، زمینه درخواست، رویدادهای احراز هویت، رویدادهای CRUD ارائه‌دهنده، و ثبت اعتبارسنجی مسدود شده با SSRF | +| 🔐 **TPS Log Metric** | جزئیات گزارش مودال نشان می دهد نشانه ها در هر ثانیه (TPS) — عملکرد سریع در یک نگاه برای هر درخواست | +| 🗑️ **حذف نصب / حذف کامل ** | `npm run uninstall` داده ها را نگه می دارد، `npm run uninstall:full` همه چیز را حذف می کند — حذف تمیز برای همه روش های نصب | +| 🔧 **OAuth Env Repair** | اقدام "Repair env" با یک کلیک برای ارائه دهندگان OAuth، vars env گم شده را بازیابی می کند و وضعیت احراز هویت شکسته را رفع می کند | +| 🔒 **خاموشی برازنده الکترون** | Electron `before-quit` Next.js را با زیبایی خاموش می کند و از قفل شدن پایگاه داده SQLite WAL در دسکتاپ جلوگیری می کند | +| 👁️ **تغییر قابلیت مشاهده مدل** | جابجایی نمای هر مدل (نماد 👁) با فیلتر جستجو و نشان شمارش فعال (`N/M active`) در صفحات ارائه دهنده | +| 📧 **پوشش حریم خصوصی ایمیل** | ایمیل های حساب OAuth پوشانده شده است (`di*****@g****.com`)، آدرس کامل قابل مشاهده در شناور | +| 🔗 **استراتژی رله زمینه** | استراتژی ترکیبی حفظ تداوم جلسه از طریق خلاصه‌های دستیابی ساختاریافته هنگامی که حساب‌ها در میانه مکالمه می‌چرخند | +| 🛡️ **سخت شدن پروکسی** | بررسی سلامت توکن، اعتبار سنجی کلید API و undici dispatcher تمام پیکربندی پروکسی افتخاری | +| ⚠️ **اخطار ورود به سیستم Node.js 24** | صفحه ورود به طور فعال نسخه های ناسازگار Node.js را شناسایی می کند و یک بنر هشدار واضح را نشان می دهد | +| 📎 **ضمیمه های PDF Gemini** | پیوست‌های پی‌دی‌اف به‌درستی به Gemini از طریق `inline_data` و تشخیص عمومی base64 هدایت شدند | +| 🔒 **CodeQL Security Hardening** | SSRF حل‌شده، تصادفی ناامن، چندجمله‌ای ReDoS و هشدارهای بهداشتی ناقص URL | -### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) +### 🆕 جدید - بهبودهای الهام گرفته از ClawRouter (مارس 2026) -| Feature | What It Does | +| ویژگی | چه می کند | | ------------------------------------ | ------------------------------------------------------------------------------------------- | -| ⚡ **Grok-4 Fast Family** | xAI models at $0.20/$0.50/M — benchmarked 1143ms (30% faster than Gemini 2.5 Flash) | -| 🧠 **GLM-5 via Z.AI** | 128K output context, $0.5/1M — newest flagship from the GLM family | -| 🔮 **MiniMax M2.5** | Reasoning + agentic tasks at $0.30/1M — significant upgrade from M2.1 | -| 🎯 **toolCalling Flag per Model** | Per-model `toolCalling: true/false` in registry — AutoCombo skips non-tool-capable models | -| 🌍 **Multilingual Intent Detection** | PT/ZH/ES/AR keywords in AutoCombo scoring — better model selection for non-English content | -| 📊 **Benchmark-Driven Fallbacks** | Real p95 latency from live requests feeds combo scoring — AutoCombo learns from actual data | -| 🔁 **Request Deduplication** | Content-hash based dedup window — multi-agent safe, prevents duplicate charges | -| 🔌 **Pluggable RouterStrategy** | Extensible `RouterStrategy` interface — add custom routing logic as plugins | +| ⚡ **Grok-4 Fast Family** | مدل‌های xAI با قیمت 0.20 دلار / 0.50 دلار در میلیون - 1143 میلی‌ثانیه (30 درصد سریع‌تر از فلش Gemini 2.5) محک‌گذاری شده | +| 🧠 **GLM-5 از طریق Z.AI** | زمینه خروجی 128 هزار دلاری، 0.5/1 میلیون دلار — جدیدترین پرچمدار خانواده GLM | +| 🔮 **MiniMax M2.5** | استدلال + وظایف نمایندگی با 0.30 دلار / 1 میلیون دلار - ارتقاء قابل توجه از M2.1 | +| 🎯 **ابزار Calling Flag در هر مدل** | هر مدل `toolCalling: true/false` در رجیستری — AutoCombo مدل‌های غیرقابل ابزار را حذف می‌کند | +| 🌍 **تشخیص قصد چند زبانه** | کلمات کلیدی PT/ZH/ES/AR در امتیازدهی AutoCombo — انتخاب مدل بهتر برای محتوای غیر انگلیسی | +| 📊 **معیارهای بازگشتی** | تأخیر واقعی p95 از درخواست‌های زنده امتیازدهی ترکیبی — AutoCombo از داده‌های واقعی یاد می‌گیرد | +| 🔁 **درخواست حذف مجدد ** | پنجره dedup مبتنی بر هش محتوا — ایمن چند عاملی، از هزینه های تکراری جلوگیری می کند | +| 🔌 **استراتژی روتر قابل اتصال** | رابط توسعه پذیر `RouterStrategy` — اضافه کردن منطق مسیریابی سفارشی به عنوان پلاگین | -### 🚀 Previous v2.0.9+ — Playground, CLI Fingerprints & ACP +### 🚀 قبلی نسخه 2.0.9+ — زمین بازی، اثر انگشت CLI و ACP -| Feature | What It Does | +| ویژگی | چه می کند | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🎮 **Model Playground** | Dashboard page to test any model directly — provider/model/endpoint selectors, Monaco Editor, streaming, abort, timing | -| 🔏 **CLI Fingerprint Matching** | Per-provider header/body ordering to match native CLI signatures — toggle per provider in Settings > Security. **Your proxy IP is preserved** | -| 🤖 **ACP Agents Dashboard** | Debug › Agents page — grid of 14 agents with install status, version, custom agent form for any CLI tool. **OpenCode** users get a "Download opencode.json" button that auto-generates a ready-to-use config with all available models. | -| 🔧 **Custom Model `apiFormat` Routing** | Custom models with `apiFormat: "responses"` now correctly route to the Responses API translator | -| 🏢 **Codex Workspace Isolation** | Multiple Codex workspaces per email — OAuth correctly separates connections by workspace ID | -| 🔄 **Electron Auto-Update** | Desktop app checks for updates + auto-install on restart | +| 🎮 **مدل زمین بازی** | صفحه داشبورد برای آزمایش مستقیم هر مدل — ارائه دهنده/مدل/انتخاب کننده نقطه پایانی، ویرایشگر موناکو، پخش جریانی، لغو، زمان بندی | +| 🔏 **تطابق اثر انگشت CLI** | سفارش سرصفحه/بدنه هر ارائه دهنده برای مطابقت با امضاهای بومی CLI — در تنظیمات > امنیت، هر ارائه دهنده را تغییر دهید. **IP پروکسی شما حفظ می شود** | +| 🤖 **داشبورد نمایندگان ACP** | اشکال زدایی › صفحه نمایندگان - شبکه ای از 14 عامل با وضعیت نصب، نسخه، فرم عامل سفارشی برای هر ابزار CLI. **کاربران OpenCode** دکمه "دانلود opencode.json" را دریافت می کنند که به طور خودکار یک پیکربندی آماده برای استفاده را با همه مدل های موجود ایجاد می کند. | +| 🔧 **مدل سفارشی `apiFormat` مسیریابی** | مدل‌های سفارشی با `apiFormat: "responses"` اکنون به درستی به مترجم Responses API می‌روند | +| 🏢 **Codex جداسازی فضای کاری** | چندین فضای کاری Codex در هر ایمیل — OAuth به درستی اتصالات را با شناسه فضای کاری جدا می کند | +| 🔄 **به روز رسانی خودکار الکترونیک** | برنامه دسکتاپ برای به روز رسانی ها + نصب خودکار در راه اندازی مجدد | -### 🤖 Agent & Protocol Operations (v2.0) +### 🤖 عملیات عامل و پروتکل (نسخه 2.0) -| Feature | What It Does | +| ویژگی | چه می کند | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🔧 **MCP Server (107 tools)** | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`). 107 unique tools across the registered tool families; enabled skills may add dynamic tools at runtime | -| 🤝 **A2A Server (JSON-RPC + SSE)** | Agent-to-agent task execution with sync and streaming flows | -| 🧭 **Consolidated Endpoints Page** | Tabbed management page with Endpoint Proxy, MCP, A2A, and API Endpoints tabs | -| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) | -| 🛰️ **MCP Runtime Heartbeat** | Real process status (pid, uptime, heartbeat age, transport, scope mode) | -| 📋 **MCP Audit Trail** | Filterable audit logs with success/failure and key attribution | -| 🔐 **MCP Scope Enforcement** | 32 granular scope permissions for controlled tool access | -| 📡 **A2A Task Lifecycle Management** | List/filter tasks, inspect events/artifacts, cancel running tasks | -| 📋 **Agent Card Discovery** | `/.well-known/agent.json` for client auto-discovery | -| 🧪 **Protocol E2E Test Harness** | Real MCP SDK + A2A client flows in `test:protocols:e2e` | -| ⚙️ **Operational Controls** | Switch combos, tune resilience settings, and review breaker state from dedicated Health and Settings surfaces | +| 🔧 **سرور MCP (107 ابزار)** | ابزار IDE/عامل از طریق 3 انتقال: stdio، SSE (`/api/mcp/sse`)، HTTP قابل جریان (`/api/mcp/stream`). 107 ابزار منحصر به فرد در میان خانواده ابزارهای ثبت شده؛ مهارت‌های فعال ممکن است ابزارهای پویا را در زمان اجرا اضافه کنند | +| 🤝 **سرور A2A (JSON-RPC + SSE)** | اجرای کار عامل به عامل با همگام سازی و جریان های جریانی | +| 🧭 **صفحه نقاط پایانی تلفیقی** | صفحه مدیریت زبانه‌دار با برگه‌های Endpoint Proxy، MCP، A2A، و API Endpoints | +| 🎚️ **سرویس فعال/غیرفعال کردن ضامن ها** | کلیدهای روشن/خاموش برای MCP و A2A با تداوم تنظیمات (پیش‌فرض: OFF) | +| 🛰️ **MCP زمان اجرا Heartbeat** | وضعیت واقعی فرآیند (pid، uptime، سن ضربان قلب، حمل و نقل، حالت دامنه) | +| 📋 **MCP مسیر حسابرسی** | گزارش های حسابرسی قابل فیلتر با موفقیت/شکست و اسناد کلیدی | +| 🔐 ** MCP Scope Enforcement ** | 32 مجوز granular scope برای دسترسی به ابزار کنترل شده | +| 📡 **A2A مدیریت چرخه حیات وظیفه** | فهرست/فیلتر کردن وظایف، بازرسی رویدادها/مصنوعات، لغو وظایف در حال اجرا | +| 📋 **کشف کارت نماینده** | `/.well-known/agent.json` برای کشف خودکار مشتری | +| 🧪 **آرنج تست پروتکل E2E** | Real MCP SDK + A2A کلاینت در `test:protocols:e2e` جریان می یابد | +| ⚙️ **کنترل های عملیاتی** | جابجایی ترکیب‌ها، تنظیم تنظیمات انعطاف‌پذیری و بررسی وضعیت شکن از سطوح اختصاصی Health و Settings | -### 🧠 Routing & Intelligence +### 🧠 مسیریابی و هوشمندی -| Feature | What It Does | +| ویژگی | چه می کند | | ---------------------------------- | ------------------------------------------------------------------------ | -| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free | -| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider | -| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Responses with schema-safe conversions | -| 👥 **Multi-Account Support** | Multiple accounts per provider with intelligent selection | -| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry | -| 🎨 **Custom Combos** | 13 balancing strategies + fallback chain control | -| 🔗 **Context Relay** | Session continuity handoffs when account rotation happens mid-session | -| 🌐 **Wildcard Router** | `provider/*` dynamic routing | -| 🧠 **Thinking Budget Controls** | Passthrough, auto, custom, and adaptive reasoning limits | -| 🔀 **Model Aliases** | Built-in + custom model aliasing and migration safety | -| ⚡ **Background Degradation** | Route low-priority background tasks to cheaper models | -| 🧪 **Task-Aware Smart Routing** | Auto-select model by content type (coding/vision/analysis/summarization) | -| 🔄 **A2A Agent Workflows** | Deterministic FSM orchestrator for stateful multi-step agent executions | -| 🔀 **Adaptive Routing** | Dynamic strategy override based on token volume and prompt complexity | -| 🎲 **Provider Diversity** | Shannon entropy scoring balancing auto-combo traffic distribution | -| 💬 **System Prompt Injection** | Global behavior controls applied consistently | -| 📄 **Responses API Compatibility** | Full `/v1/responses` support for Codex and advanced agentic workflows | +| 🎯 **هوشمند 4 طبقه بازگشتی ** | مسیر خودکار: اشتراک → کلید API → ارزان → رایگان | +| 📊 **ردیابی سهمیه بی درنگ** | تعداد توکن زنده + بازنشانی شمارش معکوس برای هر ارائه دهنده | +| 🔄 **ترجمه فرمت** | OpenAI ↔ Claude ↔ Gemini ↔ پاسخ با تبدیل های ایمن طرحواره | +| 👥 **پشتیبانی چند حساب ** | چندین حساب در هر ارائه دهنده با انتخاب هوشمند | +| 🔄 **بازسازی خودکار توکن** | توکن های OAuth به طور خودکار با تلاش مجدد | +| 🎨 **ترکیب های سفارشی** | 13 استراتژی متعادل سازی + کنترل زنجیره ای بازگشتی | +| 🔗 **رله زمینه** | وقتی چرخش حساب در اواسط جلسه اتفاق می‌افتد، تداوم جلسه ارسال می‌شود | +| 🌐 **روتر Wildcard** | مسیریابی پویا `provider/*` | +| 🧠 **تفکر کنترل های بودجه ** | محدودیت های استدلال گذرا، خودکار، سفارشی و تطبیقی ​​| +| 🔀 **نام مستعار مدل** | داخلی + مدل سفارشی نامگذاری و امنیت مهاجرت | +| ⚡ **تخریب پس زمینه** | مسیریابی وظایف پس زمینه با اولویت پایین به مدل های ارزان تر | +| 🧪 **مسیریابی هوشمند Task-Aware** | انتخاب خودکار مدل بر اساس نوع محتوا (کدگذاری/دید/تحلیل/خلاصه) | +| 🔄 ** گردش کار نماینده A2A ** | ارکستراتور قطعی FSM برای اجرای چند مرحله ای عامل | +| 🔀 **مسیریابی تطبیقی** | نادیده گرفتن استراتژی پویا بر اساس حجم توکن و پیچیدگی سریع | +| 🎲 **تنوع ارائه دهنده** | توزیع ترافیک خودکار ترکیبی متعادل کننده امتیاز آنتروپی شانون | +| 💬 **تزریق سریع سیستم** | کنترل های رفتار جهانی به طور مداوم اعمال می شود | +| 📄 **پاسخ ها سازگاری API ** | پشتیبانی کامل از `/v1/responses` برای Codex و گردش کار نمایندگی پیشرفته | -### 🎵 Multi-Modal APIs +### 🎵 API های چند وجهی -| Feature | What It Does | +| ویژگی | چه می کند | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🖼️ **Image Generation** | `/v1/images/generations` with cloud and local backends | -| 📐 **Embeddings** | `/v1/embeddings` for search and RAG pipelines | -| 🎤 **Audio Transcription** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support | -| 🔊 **Text-to-Speech** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) with correct error messages | -| 🎬 **Video Generation** | `/v1/videos/generations` (ComfyUI + SD WebUI workflows) | -| 🎵 **Music Generation** | `/v1/music/generations` (ComfyUI workflows) | -| 🛡️ **Moderations** | `/v1/moderations` safety checks | -| 🔀 **Reranking** | `/v1/rerank` for relevance scoring | -| 🔍 **Web Search** 🆕 | `/v1/search` — 5 providers (Serper, Brave, Perplexity, Exa, Tavily), 6,500+ free/month, auto-failover, cache | +| 🖼️ **تولید تصویر** | `/v1/images/generations` با ابر و باطن محلی | +| 📐 **جاسازی ها** | `/v1/embeddings` برای جستجو و خطوط لوله RAG | +| 🎤 **رونویسی صوتی** | `/v1/audio/transcriptions` — 7 ارائه دهنده (Deepgram Nova 3، AssemblyAI، Groq Whisper، HuggingFace، ElevenLabs، OpenAI، Azure)، تشخیص خودکار زبان، پشتیبانی MP4/MP3/WAV | +| 🔊 **متن به گفتار** | `/v1/audio/speech` — 10 ارائه دهنده (ElevenLabs، OpenAI، Deepgram، Cartesia، PlayHT، HuggingFace، Nvidia NIM، Inworld، Coqui، Tortoise) با پیام های خطای صحیح | +| 🎬 **نسل ویدیو** | `/v1/videos/generations` (گردش های کاری ComfyUI + SD WebUI) | +| 🎵 **نسل موسیقی** | `/v1/music/generations` (جریان کاری ComfyUI) | +| 🛡️ **اعتدال ** | `/v1/moderations` بررسی های ایمنی | +| 🔀 **رتبه بندی مجدد ** | `/v1/rerank` برای امتیازدهی مرتبط | +| 🔍 **جستجوی وب** 🆕 | `/v1/search` — 5 ارائه دهنده (Serper، Brave، Perplexity، Exa، Tavily)، بیش از 6500 رایگان در ماه، خودکار failover، کش | -### 🛡️ Resilience, Security & Governance +### 🛡️ تاب آوری، امنیت و حکمرانی -| Feature | What It Does | +| ویژگی | چه می کند | | ----------------------------------- | ------------------------------------------------------------------------------------------------------- | -| 🔌 **Provider Circuit Breakers** | Provider-wide trip/recover after fallback exhaustion with configurable thresholds | -| 🔒 **Daily Quota Lock** 🆕 | Detects exhaustion signals and locks routing for the specific model until midnight | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 🚦 **Request Queue & Pacing** | Configurable per-connection request buckets for RPM, spacing, concurrency, and max wait | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| ❄️ **Connection Cooldown** | Retryable 408/429/5xx failures cool down a single connection with optional upstream hints | -| 🚪 **Auto-Disable Banned Accounts** | Permanently blocked token accounts can be disabled automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | -| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | -| ⏳ **Wait For Cooldown** 🆕 | Auto-retry chat after connection cooldowns; configurable `enabled`, `maxRetries`, and `maxRetryWaitSec` | -| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | -| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | +| 🔌 **ارائه دهنده مدار شکن** | سفر/بازیابی در سراسر ارائه دهنده پس از خستگی مجدد با آستانه های قابل تنظیم | +| 🔒 **قفل سهمیه روزانه** 🆕 | سیگنال های خستگی را تشخیص می دهد و مسیر را برای مدل خاص تا نیمه شب قفل می کند | +| 🎯 **مدل های آگاه از نقطه پایانی** | مدل های سفارشی نقاط پایانی پشتیبانی شده + قالب API | +| 🛡️ **گله ضد رعد** | محافظت‌های Mutex + سمافور در رویدادهای امتحان مجدد/نرخ دادن | +| 🧠 ** کش معنایی + امضا ** | کاهش هزینه/تأخیر با دو لایه کش | +| ⚡ **درخواست عدم توانمندی** | پنجره حفاظتی تکراری | +| 🔒 **تقلب اثر انگشت TLS** | اثر انگشت TLS شبیه مرورگر — **تشخیص ربات و پرچم گذاری حساب را کاهش می دهد** | +| 🔏 **تطابق اثر انگشت CLI** | با امضاهای درخواستی CLI بومی مطابقت دارد — **با حفظ IP پروکسی خطر ممنوعیت را کاهش می دهد** | +| 🌐 **فیلتر IP** | کنترل لیست مجاز/فهرست مسدود برای استقرارهای در معرض | +| 🚦 **درخواست صف و سرعت** | قابل تنظیم سطل درخواست در هر اتصال برای RPM، فاصله، همزمانی، و حداکثر انتظار | +| 📉 **تحقیر برازنده** | قابلیت چندلایه بازگشتی برای محافظت از عملیات دروازه اصلی | +| 📜 **پیکربندی مسیر حسابرسی** | ردیابی تغییر مبتنی بر تفاوت که از رانش عملیاتی با برگشت‌های ساده جلوگیری می‌کند | +| ⏳ **همگام سازی ارائه دهنده سلامت** | پیشگیرانه نظارت بر انقضای توکن هشدارهای راه اندازی قبل از خرابی مجوز | +| ❄️ **خنک کننده اتصال** | خطاهای 408/429/5xx قابل امتحان مجدد یک اتصال را با نکات بالادستی اختیاری خنک می کند | +| 🚪 ** غیرفعال کردن خودکار حساب های ممنوعه ** | حساب‌های توکن مسدود شده برای همیشه به‌طور خودکار غیرفعال می‌شوند | +| 🔑 **API کلید مدیریت + محدوده** | کنترل ایمن صدور/چرخش کلید و مدل/ارائه دهنده | +| 👁️ **نمایش کلید API با محدوده** 🆕 | بازیابی کلیدهای API از طریق `ALLOW_API_KEY_REVEAL` | +| 🛡️ **محافظت شده `/models`** | درگاه تأیید اختیاری و مخفی کردن ارائه دهنده برای کاتالوگ مدل | +| 🛡️ ** واکشی ایمن خروجی** 🆕 | واکشی محافظت شده برای تماس های ارائه دهنده — URL های خصوصی/محلی را مسدود می کند، تلاش های مجدد، حفاظت SSRF | +| ⏳ **منتظر خنک شدن باشید** 🆕 | سعی مجدد خودکار چت پس از خنک شدن اتصال. قابل تنظیم `enabled`، `maxRetries`، و `maxRetryWaitSec` | +| 🔍 **Runtime Env Validation** 🆕 | اعتبار سنجی طرحواره env مبتنی بر Zod در هنگام راه اندازی با پیام های خطای قابل اجرا | +| 📋 **ممیزی انطباق v2** 🆕 | صفحه بندی، زمینه درخواست، رویدادهای احراز هویت، ارائه دهنده CRUD، و ورود به سیستم مسدود شده با SSRF | -### 📊 Observability & Analytics +### 📊 قابلیت مشاهده و تجزیه و تحلیل -| Feature | What It Does | +| ویژگی | چه می کند | | -------------------------------- | ----------------------------------------------------- | -| 📝 **Request + Proxy Logging** | Full request/response and proxy logging | -| 📉 **Streamed Detailed Logs** | Reconstructs SSE payload streams cleanly into the UI | -| 🏷️ **Real-Time Model Badges** 🆕 | Live model status and daily quota countdown timers | -| 📋 **Unified Logs Dashboard** | Request, proxy, audit, and console views in one page | -| 🔍 **Request Telemetry** | p50/p95/p99 latency and request tracing | -| 🏥 **Health Dashboard** | Uptime, breaker states, lockouts, cache stats | -| 💰 **Cost Tracking** | Budget controls and per-model pricing visibility | -| 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | -| 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | -| 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | -| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | +| 📝 **درخواست + ثبت پروکسی** | درخواست/پاسخ کامل و ثبت پروکسی | +| 📉 **گزارشهای تفصیلی جریانی** | جریان های بار محموله SSE را به طور تمیز در UI بازسازی می کند | +| 🏷️ **نشان های مدل بلادرنگ ** 🆕 | وضعیت مدل زنده و تایمرهای شمارش معکوس سهمیه روزانه | +| 📋 **داشبورد گزارش های یکپارچه** | نماهای درخواست، پروکسی، ممیزی و کنسول در یک صفحه | +| 🔍 **درخواست تله متری** | تأخیر p50/p95/p99 و ردیابی درخواست | +| 🏥 **داشبورد سلامت** | Uptime، حالت های قطع کننده، قفل ها، آمار حافظه پنهان | +| 💰 **پیگیری هزینه** | کنترل های بودجه و مشاهده قیمت گذاری هر مدل | +| 📈 **تجسم های تحلیلی** | بینش استفاده از مدل/ارائه دهنده و نماهای روند | +| 🧪 **چارچوب ارزشیابی** | تست مجموعه طلایی با استراتژی های تطبیق قابل تنظیم | +| 📡 **تشخیص زنده** 🆕 | دور زدن حافظه پنهان معنایی برای آزمایش زنده ترکیبی دقیق | +| 🔐 **TPS Log Metric** 🆕 | نشان توکن در ثانیه در جزئیات گزارش معین | -### ☁️ Deployment & Platform +### ☁️ استقرار و پلتفرم -| Feature | What It Does | +| ویژگی | چه می کند | | ------------------------------ | --------------------------------------------------------------------- | -| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloud environments | -| 🚇 **Cloudflare Tunnel** 🆕 | One-click Quick Tunnel integration from the dashboard | -| 🔑 **API Key Model Filtering** | Native /v1/models response filtered via assigned Bearer context roles | -| ⚡ **Smart Cache Bypass** | Configurable TTL heuristics and forced refetch controls | -| 🔄 **Backup/Restore** | Export/import and disaster recovery flows | -| 🧙 **Onboarding Wizard** | First-run guided setup | -| 🔧 **CLI Tools Dashboard** | One-click setup for popular coding tools | -| 🎮 **Model Playground** | Test any provider/model/endpoint from the dashboard | -| 🔏 **CLI Fingerprint Toggle** | Per-provider fingerprint matching in Settings > Security | -| 🌐 **i18n (30 languages)** | Full dashboard + docs language support with RTL coverage | -| 🧹 **Clear All Models** | One-click model list clearing in provider details | -| 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | -| 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | -| 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | -| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | -| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | +| 🌐 **استقرار در هر کجا ** | Localhost، VPS، Docker، محیط های ابری | +| 🚇 **Cloudflare تونل** 🆕 | ادغام Quick Tunnel با یک کلیک از داشبورد | +| 🔑 ** فیلتر مدل کلید API ** | پاسخ بومی /v1/models از طریق نقش‌های زمینه حامل اختصاص داده شده فیلتر شده است +| ⚡ **عملیات کش هوشمند** | اکتشافی TTL قابل تنظیم و کنترل های بازیابی اجباری | +| 🔄 **پشتیبان گیری/بازیابی** | صادرات/واردات و جریان های بازیابی بلایا | +| 🧙 **جادوگر سوار شدن** | راه اندازی هدایت شده در اولین اجرا | +| 🔧 **CLI Tools Dashboard ** | راه اندازی با یک کلیک برای ابزارهای کدنویسی محبوب | +| 🎮 **مدل زمین بازی** | هر ارائه دهنده/مدل/نقطه پایانی را از داشبورد تست کنید | +| 🔏 **ضامن اثر انگشت CLI** | مطابقت اثر انگشت هر ارائه دهنده در تنظیمات > امنیت | +| 🌐 **i18n (30 زبان)** | داشبورد کامل + پشتیبانی از زبان اسناد با پوشش RTL | +| 🧹 **پاک کردن همه مدل ها** | پاک کردن لیست مدل با یک کلیک در جزئیات ارائه دهنده | +| 👁️ **کنترل های نوار کناری** 🆕 | مخفی کردن اجزا و ادغام ها از تنظیمات ظاهر | +| 📋 **نمونه های شماره** | الگوهای استاندارد GitHub برای اشکالات و ویژگی ها | +| 📂 **دایرکتوری داده های سفارشی** | لغو `DATA_DIR` برای مکان ذخیره سازی | +| 🌐 **V1 WebSocket Bridge** 🆕 | ترافیک OpenAI سازگار با WebSocket پروکسی شده از طریق `/v1/ws` | +| 🔑 **همگام سازی توکن ها و بسته ها** 🆕 | پیکربندی نشانه‌های همگام‌سازی + نسخه نهایی بسته نرم‌افزاری با پشتیبانی ETag | -### Feature Deep Dive +### ویژگی Deep Dive -#### Smart fallback with practical cost control +#### بازگشت هوشمند با کنترل هزینه عملی ```txt Combo: "my-coding-stack" @@ -1504,73 +1504,73 @@ Combo: "my-coding-stack" 4. if/kimi-k2-thinking ``` -When quota, rate, or health fails, OmniRoute automatically moves to the next candidate without manual switching. +هنگامی که سهمیه، نرخ، یا سلامت ناموفق باشد، OmniRoute به طور خودکار بدون تغییر دستی به نامزد بعدی منتقل می شود. -#### Protocol management that is visible and operable +#### مدیریت پروتکل قابل مشاهده و قابل اجرا -- MCP + A2A are discoverable in UI and docs (not hidden) -- Protocol status APIs expose live operational data (`/api/mcp/*`, `/api/a2a/*`) -- Dashboards include actions for day-2 ops (combo toggles, breaker resets, task cancellation) +- MCP + A2A در رابط کاربری و اسناد قابل کشف هستند (پنهان نمی شوند) +- APIهای وضعیت پروتکل داده‌های عملیاتی زنده را در معرض دید قرار می‌دهند (`/api/mcp/*`، `/api/a2a/*`) +- داشبوردها شامل اقداماتی برای عملیات روز دوم هستند (تغییرهای ترکیبی، بازنشانی قطع کننده، لغو کار) -#### Translator + validation workflow +#### مترجم + گردش کار اعتبار سنجی -The Translator area includes: +منطقه مترجم شامل: -- **Playground**: request transformation checks -- **Chat Tester**: full request/response round-trip -- **Test Bench**: multiple cases in one run -- **Live Monitor**: real-time traffic view +- **زمین بازی**: درخواست بررسی تبدیل +- **تستر چت**: درخواست/پاسخ کامل رفت و برگشت +- **میز تست **: موارد متعدد در یک اجرا +- ** مانیتور زنده **: نمایش ترافیک در زمان واقعی -Plus protocol validation with real clients via `npm run test:protocols:e2e`. +اعتبار سنجی پروتکل پلاس با مشتریان واقعی از طریق `npm run test:protocols:e2e`. -> 📖 **[MCP Server README](open-sse/mcp-server/README.md)** — Tool reference, IDE configs, and client examples +> 📖 **[MCP سرور README](open-sse/mcp-server/README.md)** — مرجع ابزار، تنظیمات IDE و نمونه های مشتری > -> 📖 **[A2A Server README](src/lib/a2a/README.md)** — Skills, JSON-RPC methods, streaming, and task lifecycle +> 📖 **[A2A سرور README](src/lib/a2a/README.md)** — مهارت ها، روش های JSON-RPC، جریان و چرخه عمر کار -## 🧪 Evaluations (Evals) +## 🧪 ارزیابی ها (ارزیابی ها) -OmniRoute includes a built-in evaluation framework to test LLM response quality against a golden set. Access it via **Analytics → Evals** in the dashboard. +OmniRoute شامل یک چارچوب ارزیابی داخلی برای آزمایش کیفیت پاسخ LLM در برابر یک مجموعه طلایی است. از طریق **Analytics → Evals** در داشبورد به آن دسترسی داشته باشید. -### Built-in Golden Set +### ست طلایی توکار -The pre-loaded "OmniRoute Golden Set" contains test cases for: +«مجموعه طلایی OmniRoute» از پیش بارگذاری شده حاوی موارد آزمایشی برای موارد زیر است: -- Greetings, math, geography, code generation -- JSON format compliance, translation, markdown generation -- Safety refusal (harmful content), counting, boolean logic +- با سلام، ریاضی، جغرافیا، تولید کد +- مطابقت با فرمت JSON، ترجمه، تولید علامت گذاری +- امتناع ایمنی (محتوای مضر)، شمارش، منطق بولی -### Evaluation Strategies +### استراتژی های ارزیابی -| Strategy | Description | Example | +| استراتژی | توضیحات | مثال | | ---------- | ------------------------------------------------ | -------------------------------- | -| `exact` | Output must match exactly | `"4"` | -| `contains` | Output must contain substring (case-insensitive) | `"Paris"` | -| `regex` | Output must match regex pattern | `"1.*2.*3"` | -| `custom` | Custom JS function returns true/false | `(output) => output.length > 10` | +| `exact` | خروجی باید دقیقاً مطابقت داشته باشد | `"4"` | +| `contains` | خروجی باید حاوی رشته فرعی (بدون حساس به بزرگی) | `"Paris"` | +| `regex` | خروجی باید با الگوی regex مطابقت داشته باشد | `"1.*2.*3"` | +| `custom` | تابع JS سفارشی true/false | `(output) => output.length > 10` | --- -## 📖 Setup Guide +## 📖 راهنمای راه اندازی -### Protocol Setup (MCP + A2A) +### راه اندازی پروتکل (MCP + A2A)
🧩 MCP Setup (Model Context Protocol) -Start MCP transport in stdio mode: +حمل و نقل MCP را در حالت stdio شروع کنید: ```bash omniroute --mcp ``` -Recommended validation flow: +جریان اعتبارسنجی توصیه شده: -1. Connect your MCP client over stdio. -2. Run `omniroute_get_health`. -3. Run `omniroute_list_combos`. -4. Open `/dashboard/mcp` to confirm heartbeat, activity, and audit. +1. مشتری MCP خود را از طریق stdio وصل کنید. +2. `omniroute_get_health` را اجرا کنید. +3. `omniroute_list_combos` را اجرا کنید. +4. `/dashboard/mcp` را برای تأیید ضربان قلب، فعالیت و ممیزی باز کنید. -Useful APIs for automation: +APIهای مفید برای اتوماسیون: - `GET /api/mcp/status` - `GET /api/mcp/tools` @@ -1582,13 +1582,13 @@ Useful APIs for automation:
🤝 A2A Setup (Agent2Agent) -Discover the agent: +عامل را کشف کنید: ```bash curl http://localhost:20128/.well-known/agent.json ``` -Send a task: +ارسال یک کار: ```bash curl -X POST http://localhost:20128/a2a \ @@ -1596,40 +1596,40 @@ curl -X POST http://localhost:20128/a2a \ -d '{"jsonrpc":"2.0","id":"setup-a2a","method":"message/send","params":{"skill":"quota-management","messages":[{"role":"user","content":"Summarize quota status."}]}}' ``` -Manage lifecycle: +مدیریت چرخه عمر: - `GET /api/a2a/status` - `GET /api/a2a/tasks` - `GET /api/a2a/tasks/:id` - `POST /api/a2a/tasks/:id/cancel` -Operational UI: +رابط کاربری عملیاتی: -- `/dashboard/a2a` for task/state/stream observability and smoke actions +- `/dashboard/a2a` برای قابلیت مشاهده وظیفه/وضعیت/جریان و اقدامات دود
🧪 End-to-end protocol validation -Validate both protocols with real clients: +هر دو پروتکل را با کلاینت های واقعی اعتبار سنجی کنید: ```bash npm run test:protocols:e2e ``` -This verifies: +این تأیید می کند: -- MCP SDK client connect/list/call -- A2A discovery/send/stream/get/cancel -- Cross-check data in MCP audit and A2A task management APIs +- اتصال/لیست/تماس کلاینت MCP SDK +- A2A کشف / ارسال / جریان / دریافت / لغو +- بررسی متقاطع داده ها در ممیزی MCP و APIهای مدیریت وظایف A2A
💳 Subscription Providers -### Claude Code (Pro/Max) +کد ### Claude (Pro/Max) ```bash Dashboard → Providers → Connect Claude Code @@ -1642,7 +1642,7 @@ Models: cc/claude-haiku-4-5-20251001 ``` -**Pro Tip:** Use Opus for complex tasks, Sonnet for speed. OmniRoute tracks quota per model! +**نکته حرفه ای:** از Opus برای کارهای پیچیده و Sonnet برای سرعت استفاده کنید. OmniRoute سهمیه هر مدل را دنبال می کند! ### OpenAI Codex (Plus/Pro) @@ -1656,22 +1656,22 @@ Models: cx/gpt-5.1-codex-max ``` -#### Codex Account Limit Management (5h + Weekly) +#### Codex مدیریت محدودیت حساب (5 ساعت + هفتگی) -Each Codex account now has policy toggles in `Dashboard -> Providers`: +هر حساب Codex اکنون دارای تغییر سیاست در `Dashboard -> Providers` است: -- `5h` (ON/OFF): enforce the 5-hour window threshold policy. -- `Weekly` (ON/OFF): enforce the weekly window threshold policy. -- Threshold behavior: when an enabled window reaches >=90% usage, that account is skipped. -- Rotation behavior: OmniRoute routes to the next eligible Codex account automatically. -- Reset behavior: when the provider `resetAt` time passes, the account becomes eligible again automatically. +- `5h` (روشن/خاموش): سیاست آستانه پنجره 5 ساعته را اجرا کنید. +- `Weekly` (روشن/خاموش): سیاست آستانه پنجره هفتگی را اجرا کنید. +- رفتار آستانه: هنگامی که یک پنجره فعال به >=90 درصد استفاده می رسد، آن حساب حذف می شود. +- رفتار چرخش: OmniRoute به طور خودکار به حساب بعدی واجد شرایط Codex مسیر می‌دهد. +- رفتار بازنشانی: هنگامی که زمان ارائه دهنده `resetAt` می گذرد، حساب دوباره به طور خودکار واجد شرایط می شود. Scenarios: -- `5h ON` + `Weekly ON`: account is skipped when either window reaches threshold. -- `5h OFF` + `Weekly ON`: only weekly usage can block the account. -- `5h ON` + `Weekly OFF`: only 5-hour usage can block the account. -- `resetAt` passed: account re-enters rotation automatically (no manual re-enable). +- `5h ON` + `Weekly ON`: هنگامی که هر یک از پنجره ها به آستانه می رسد حساب حذف می شود. +- `5h OFF` + `Weekly ON`: فقط استفاده هفتگی می تواند حساب را مسدود کند. +- `5h ON` + `Weekly OFF`: فقط استفاده 5 ساعته می تواند حساب را مسدود کند. +- `resetAt` تصویب شد: حساب به طور خودکار دوباره وارد چرخش می شود (بدون فعال کردن مجدد دستی). ### GitHub Copilot @@ -1691,88 +1691,88 @@ Models:
🔑 API Key Providers -### NVIDIA NIM (FREE developer access — 70+ models) +### NVIDIA NIM (دسترسی رایگان برنامه نویس - بیش از 70 مدل) -1. Sign up: [build.nvidia.com](https://build.nvidia.com) -2. Get free API key (1000 inference credits included) -3. Dashboard → Add Provider → NVIDIA NIM: - - API Key: `nvapi-your-key` +1. ثبت نام کنید: [build.nvidia.com](https://build.nvidia.com) +2. کلید API رایگان دریافت کنید (1000 اعتبار استنتاج شامل) +3. داشبورد → افزودن ارائه دهنده → NVIDIA NIM: + - کلید API: `nvapi-your-key` -**Models:** `nvidia/llama-3.3-70b-instruct`, `nvidia/mistral-7b-instruct`, and 50+ more +**مدل ها:** `nvidia/llama-3.3-70b-instruct`، `nvidia/mistral-7b-instruct`، و بیش از 50 مدل دیگر -**Pro Tip:** OpenAI-compatible API — works seamlessly with OmniRoute's format translation! +** نکته حرفه ای: ** OpenAI سازگار با API - با ترجمه فرمت OmniRoute یکپارچه کار می کند! ### DeepSeek -1. Sign up: [platform.deepseek.com](https://platform.deepseek.com) -2. Get API key -3. Dashboard → Add Provider → DeepSeek +1. ثبت نام کنید: [platform.deepseek.com](https://platform.deepseek.com) +2. کلید API را دریافت کنید +3. داشبورد → افزودن ارائه دهنده → DeepSeek -**Models:** `deepseek/deepseek-chat`, `deepseek/deepseek-coder` +**مدل ها:** `deepseek/deepseek-chat`، `deepseek/deepseek-coder` -### Groq (Free Tier Available!) +### Groq (سطح رایگان موجود است!) -1. Sign up: [console.groq.com](https://console.groq.com) -2. Get API key (free tier included) -3. Dashboard → Add Provider → Groq +1. ثبت نام کنید: [console.groq.com](https://console.groq.com) +2. کلید API را دریافت کنید (شامل ردیف رایگان) +3. داشبورد → افزودن ارائه دهنده → Groq -**Models:** `groq/llama-3.3-70b`, `groq/mixtral-8x7b` +**مدل ها:** `groq/llama-3.3-70b`، `groq/mixtral-8x7b` -**Pro Tip:** Ultra-fast inference — best for real-time coding! +** نکته حرفه ای: ** استنتاج فوق العاده سریع - بهترین برای برنامه نویسی در زمان واقعی! -### OpenRouter (100+ Models) +### OpenRouter (100+ مدل) -1. Sign up: [openrouter.ai](https://openrouter.ai) -2. Get API key -3. Dashboard → Add Provider → OpenRouter +1. ثبت نام کنید: [openrouter.ai](https://openrouter.ai) +2. کلید API را دریافت کنید +3. داشبورد → افزودن ارائه دهنده → OpenRouter -**Models:** Access 100+ models from all major providers through a single API key. +**مدل ها:** از طریق یک کلید API به بیش از 100 مدل از همه ارائه دهندگان اصلی دسترسی پیدا کنید. -**Dashboard behavior:** OpenRouter models are managed from **Available Models**. Manual add, import, and auto-sync all update the same list. +**رفتار داشبورد:** مدل های OpenRouter از **مدل های موجود** مدیریت می شوند. افزودن دستی، وارد کردن، و همگام‌سازی خودکار همه یک لیست را به‌روزرسانی می‌کنند.
💰 Cheap Providers (Backup) -### GLM-4.7 (Daily reset, $0.6/1M) +### GLM-4.7 (بازنشانی روزانه، 0.6/1 میلیون دلار) -1. Sign up: [Zhipu AI](https://open.bigmodel.cn/) -2. Get API key from Coding Plan -3. Dashboard → Add API Key: - - Provider: `glm` - - API Key: `your-key` +1. ثبت نام: [Zhipu AI](https://open.bigmodel.cn/) +2. کلید API را از برنامه کدگذاری دریافت کنید +3. داشبورد ← افزودن کلید API: + - ارائه دهنده: `glm` + - کلید API: `your-key` -**Use:** `glm/glm-4.7` +**استفاده:** `glm/glm-4.7` -**Pro Tip:** Coding Plan offers 3× quota at 1/7 cost! Reset daily 10:00 AM. +** نکته حرفه ای: ** طرح برنامه نویسی سهمیه 3× را با هزینه 1/7 ارائه می دهد! بازنشانی روزانه 10:00 صبح. -### MiniMax M2.1 (5h reset, $0.20/1M) +### MiniMax M2.1 (5 ساعت بازنشانی، 0.20 دلار/1 میلیون دلار) -1. Sign up: [MiniMax](https://www.minimax.io/) -2. Get API key -3. Dashboard → Add API Key +1. ثبت نام کنید: [MiniMax](https://www.minimax.io/) +2. کلید API را دریافت کنید +3. داشبورد ← افزودن کلید API -**Use:** `minimax/MiniMax-M2.1` +**استفاده:** `minimax/MiniMax-M2.1` -**Pro Tip:** Cheapest option for long context (1M tokens)! +** نکته حرفه ای: ** ارزان ترین گزینه برای زمینه طولانی (1 میلیون توکن)! -### Kimi K2 ($9/month flat) +### Kimi K2 (9 دلار در ماه ثابت) -1. Subscribe: [Moonshot AI](https://platform.moonshot.ai/) -2. Get API key -3. Dashboard → Add API Key +1. مشترک شوید: [Moonshot AI](https://platform.moonshot.ai/) +2. کلید API را دریافت کنید +3. داشبورد ← افزودن کلید API -**Use:** `kimi/kimi-latest` +**استفاده:** `kimi/kimi-latest` -**Pro Tip:** Fixed $9/month for 10M tokens = $0.90/1M effective cost! +**نکته حرفه ای:** ثابت 9 دلار در ماه برای 10 میلیون توکن = 0.90 دلار / 1 میلیون هزینه موثر!
🆓 FREE Providers (Emergency Backup) -### Qoder (5 FREE models via OAuth) +### Qoder (5 مدل رایگان از طریق OAuth) ```bash Dashboard → Connect Qoder @@ -1787,7 +1787,7 @@ Models: if/deepseek-r1 ``` -### Qwen (4 FREE models via Device Code) +### Qwen (4 مدل رایگان از طریق کد دستگاه) ```bash Dashboard → Connect Qwen @@ -1799,7 +1799,7 @@ Models: qw/qwen3-coder-flash ``` -### Kiro (Claude FREE) +### Kiro (رایگان Claude) ```bash Dashboard → Connect Kiro @@ -1816,7 +1816,7 @@ Models:
🎨 Create Combos -### Example 1: Maximize Subscription → Cheap Backup +### مثال 1: حداکثر کردن اشتراک → پشتیبان گیری ارزان ``` Dashboard → Combos → Create New @@ -1830,7 +1830,7 @@ Models: Use in CLI: premium-coding ``` -### Example 2: Free-Only (Zero Cost) +### مثال 2: فقط رایگان (هزینه صفر) ``` Name: free-combo @@ -1855,9 +1855,9 @@ Settings → Models → Advanced: Model: cc/claude-opus-4-7 ``` -### Claude Code +### کد Claude -Use the **CLI Tools** page in the dashboard for one-click configuration, or edit `~/.claude/settings.json` manually. +از صفحه **CLI Tools** در داشبورد برای پیکربندی با یک کلیک استفاده کنید، یا `~/.claude/settings.json` را به صورت دستی ویرایش کنید. ### Codex CLI @@ -1870,13 +1870,13 @@ codex "your prompt" ### OpenClaw -**Option 1 — Dashboard (recommended):** +**گزینه 1 - داشبورد (توصیه می شود):** ``` Dashboard → CLI Tools → OpenClaw → Select Model → Apply ``` -**Option 2 — Manual:** Edit `~/.openclaw/openclaw.json`: +**گزینه 2 — دستی:** ویرایش `~/.openclaw/openclaw.json`: ```json { @@ -1892,7 +1892,7 @@ Dashboard → CLI Tools → OpenClaw → Select Model → Apply } ``` -> **Note:** OpenClaw only works with local OmniRoute. Use `127.0.0.1` instead of `localhost` to avoid IPv6 resolution issues. +> **توجه:** OpenClaw فقط با OmniRoute محلی کار می کند. از `127.0.0.1` به جای `localhost` برای جلوگیری از مشکلات وضوح IPv6 استفاده کنید. ### Cline / Continue / RooCode @@ -1906,7 +1906,7 @@ Settings → API Configuration: ### OpenCode -**Step 1:** Add OmniRoute as a custom provider: +**مرحله 1:** OmniRoute را به عنوان یک ارائه دهنده سفارشی اضافه کنید: ```bash opencode @@ -1914,7 +1914,7 @@ opencode # Select "Other" → Enter ID: "omniroute" → Enter your OmniRoute API key ``` -**Step 2:** Create/edit `opencode.json` in your project root: +**مرحله 2:** `opencode.json` را در ریشه پروژه خود ایجاد/ویرایش کنید: ```json { @@ -1936,123 +1936,123 @@ opencode } ``` -**Step 3:** Select the model in OpenCode: +**مرحله 3:** مدل را در OpenCode انتخاب کنید: ```bash /models # Select any OmniRoute model from the list ``` -> **Tip:** Add any model available in your OmniRoute `/v1/models` endpoint to the `models` section. Use the format `provider/model-id` from your OmniRoute dashboard. +> **نکته:** هر مدل موجود در نقطه پایانی OmniRoute `/v1/models` خود را به بخش `models` اضافه کنید. از قالب `provider/model-id` از داشبورد OmniRoute خود استفاده کنید.
--- -## Solución de Problemas +## راه حل مشکل
Click to expand troubleshooting guide -**"Language model did not provide messages"** +**"مدل زبان پیامی ارائه نکرد"** -- Provider quota exhausted → Check dashboard quota tracker -- Solution: Use combo fallback or switch to cheaper tier +- سهمیه ارائه دهنده تمام شده است → ردیاب سهمیه داشبورد را بررسی کنید +- راه حل: از ترکیبی جایگزین استفاده کنید یا به ردیف ارزان تر بروید -**Rate limiting** +**محدودیت نرخ** -- Subscription quota out → Fallback to GLM/MiniMax -- Add combo: `cc/claude-opus-4-7 → glm/glm-4.7 → if/kimi-k2-thinking` +- سهمیه اشتراک → بازگشت به GLM/MiniMax +- اضافه کردن ترکیبی: `cc/claude-opus-4-7 → glm/glm-4.7 → if/kimi-k2-thinking` -**OAuth token expired** +**توکن OAuth منقضی شده** -- Auto-refreshed by OmniRoute -- If issues persist: Dashboard → Provider → Reconnect +- به‌روزرسانی خودکار توسط OmniRoute +- در صورت وجود مشکلات: داشبورد → ارائه دهنده → اتصال مجدد -**High costs** +**هزینه های بالا** -- Check usage stats in Dashboard → Costs -- Switch primary model to GLM/MiniMax +- آمار استفاده را در داشبورد → هزینه ها بررسی کنید +- تغییر مدل اولیه به GLM/MiniMax -**Dashboard/API ports are wrong** +**پورت های داشبورد/API اشتباه هستند** -- `PORT` is the canonical base port (and API port by default) -- `API_PORT` overrides only OpenAI-compatible API listener -- `DASHBOARD_PORT` overrides only dashboard/Next.js listener -- Set `NEXT_PUBLIC_BASE_URL` to your dashboard/public URL (for OAuth callbacks) +- `PORT` پورت پایه متعارف است (و به طور پیش فرض درگاه API) +- `API_PORT` فقط شنونده OpenAI سازگار با API را لغو می کند +- `DASHBOARD_PORT` فقط شنونده داشبورد/Next.js را لغو می کند +- `NEXT_PUBLIC_BASE_URL` را روی داشبورد/URL عمومی خود تنظیم کنید (برای تماس های OAuth) -**Cloud sync errors** +**خطاهای همگام سازی ابری** -- Verify `BASE_URL` points to your running instance -- Verify `CLOUD_URL` points to your expected cloud endpoint -- Keep `NEXT_PUBLIC_*` values aligned with server-side values +- نقاط `BASE_URL` را به نمونه در حال اجرا خود تأیید کنید +- نقاط `CLOUD_URL` را به نقطه پایانی ابری مورد انتظار خود تأیید کنید +- مقادیر `NEXT_PUBLIC_*` را با مقادیر سمت سرور هماهنگ نگه دارید -**First login not working** +**لاگین اول کار نمی کند** -- Check `INITIAL_PASSWORD` in `.env` -- If unset, fallback password is `123456` +- `INITIAL_PASSWORD` را در `.env` بررسی کنید +- اگر تنظیم نشده باشد، رمز عبور بازگشتی `123456` است -**No request logs** +**بدون گزارش درخواست** -- `call_logs` in SQLite stores summary metadata for the Request Logs table and analytics views -- Detailed request/response payloads are written to `DATA_DIR/call_logs/` as one JSON artifact per request -- Enable pipeline capture from Dashboard → Logs → Request Logs if you need detailed per-stage payloads -- `Export Logs` reads the artifact files on demand, while `Export All` includes the `call_logs/` directory alongside `storage.sqlite` -- Set `APP_LOG_TO_FILE=true` if you also want application console logs in `logs/application/app.log` -- Adjust `APP_LOG_MAX_FILE_SIZE`, `APP_LOG_RETENTION_DAYS`, `APP_LOG_MAX_FILES`, and `CALL_LOG_MAX_ENTRIES` as needed +- `call_logs` در SQLite ابرداده های خلاصه را برای جدول Request Logs و نمایش های تجزیه و تحلیل ذخیره می کند +- محموله های درخواست/پاسخ تفصیلی به `DATA_DIR/call_logs/` به عنوان یک مصنوع JSON در هر درخواست نوشته می شود +- در صورت نیاز به محموله های دقیق در هر مرحله، ضبط خط لوله را از داشبورد → گزارش ها → گزارش های درخواستی فعال کنید +- `Export Logs` فایل های مصنوع را در صورت درخواست می خواند، در حالی که `Export All` شامل فهرست `call_logs/` در کنار `storage.sqlite` است +- اگر می خواهید گزارش های کنسول برنامه در `logs/application/app.log` نیز وجود داشته باشد، `APP_LOG_TO_FILE=true` را تنظیم کنید +- `APP_LOG_MAX_FILE_SIZE`، `APP_LOG_RETENTION_DAYS`، `APP_LOG_MAX_FILES`، و `CALL_LOG_MAX_ENTRIES` را در صورت نیاز تنظیم کنید -**Connection test shows "Invalid" for OpenAI-compatible providers** +**آزمایش اتصال "نامعتبر" را برای ارائه دهندگان سازگار با OpenAI نشان می دهد** -- Many providers don't expose a `/models` endpoint -- OmniRoute v1.0.6+ includes fallback validation via chat completions -- Ensure base URL includes `/v1` suffix +- بسیاری از ارائه دهندگان نقطه پایانی `/models` را افشا نمی کنند +- OmniRoute نسخه 1.0.6+ شامل اعتبار سنجی مجدد از طریق تکمیل چت است +- مطمئن شوید که پایه URL دارای پسوند `/v1` است -### 🔐 OAuth on a Remote Server +### 🔐 OAuth در یک سرور راه دور -> **⚠️ Important for users running OmniRoute on a VPS, Docker, or any remote server** +> **⚠️ برای کاربرانی که OmniRoute را روی VPS، Docker یا هر سرور راه دور اجرا می کنند مهم است** -The OAuth credentials bundled in OmniRoute are registered **for `localhost` only**. When you access OmniRoute on a remote server (e.g. `https://omniroute.myserver.com`), Google rejects the authentication with: +اعتبارنامه OAuth همراه با OmniRoute **فقط برای `localhost`** ثبت شده است. هنگامی که به OmniRoute در یک سرور راه دور (مثلاً `https://omniroute.myserver.com`) دسترسی دارید، Google احراز هویت را با: ``` Error 400: redirect_uri_mismatch ``` -#### Solution: Configure your own OAuth credentials +#### راه حل: اعتبار OAuth خود را پیکربندی کنید -You need to create an **OAuth 2.0 Client ID** in Google Cloud Console with your server's URI. +شما باید یک **OAuth 2.0 Client ID** در Google Cloud Console با URI سرور خود ایجاد کنید. -#### Step-by-step +#### گام به گام -**1. Open Google Cloud Console** +**1. باز کردن Google Cloud Console** -Go to: [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) +برو به: [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) -**2. Create a new OAuth 2.0 Client ID** +**2. یک شناسه مشتری OAuth 2.0 جدید ایجاد کنید** -- Click **"+ Create Credentials"** → **"OAuth client ID"** -- Application type: **"Web application"** -- Name: anything you like (e.g. `OmniRoute Remote`) +- روی **"+ ایجاد اعتبارنامه"** → **"OAuth شناسه مشتری"** کلیک کنید +- نوع برنامه: **"برنامه وب"** +- نام: هر چیزی که دوست دارید (به عنوان مثال `OmniRoute Remote`) -**3. Add Authorized Redirect URIs** +**3. URIهای مجاز تغییر مسیر را اضافه کنید** -In the **"Authorized redirect URIs"** field, add: +در قسمت **"URI های تغییر مسیر مجاز"**، اضافه کنید: ``` https://your-server.com/callback ``` -> Replace `your-server.com` with your server's domain or IP (include the port if needed, e.g. `http://45.33.32.156:20128/callback`). +> `your-server.com` را با دامنه یا IP سرور خود جایگزین کنید (در صورت نیاز پورت را اضافه کنید، به عنوان مثال `http://45.33.32.156:20128/callback`). -**4. Save and copy the credentials** +**4. اطلاعات کاربری را ذخیره و کپی کنید** -After creating, Google will show the **Client ID** and **Client Secret**. +پس از ایجاد، Google **شناسه مشتری** و **Client Secret** را نشان می دهد. -**5. Set environment variables** +**5. تنظیم متغیرهای محیط ** -In your `.env` (or Docker environment variables): +در `.env` (یا متغیرهای محیطی Docker): ```bash # For Antigravity: @@ -2063,7 +2063,7 @@ GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-secret ``` -**6. Restart OmniRoute** +**6. راه اندازی مجدد OmniRoute** ```bash # npm: @@ -2073,114 +2073,114 @@ npm run dev docker restart omniroute ``` -**7. Try connecting again** +**7. دوباره سعی کنید وصل شوید** -Google will now redirect correctly to `https://your-server.com/callback`. +Google اکنون به درستی به `https://your-server.com/callback` تغییر مسیر می دهد. --- -#### Temporary workaround (without custom credentials) +#### راه حل موقت (بدون اعتبارنامه های سفارشی) -If you don't want to set up your own credentials right now, you can still use the **manual URL flow**: +اگر نمی‌خواهید اعتبار خود را در حال حاضر تنظیم کنید، همچنان می‌توانید از **جریان دستی URL** استفاده کنید: -1. OmniRoute opens the Google authorization URL -2. After authorizing, Google tries to redirect to `localhost` (which fails on the remote server) -3. **Copy the full URL** from your browser's address bar (even if the page doesn't load) -4. Paste that URL into the field shown in the OmniRoute connection modal -5. Click **"Connect"** +1. OmniRoute مجوز Google را باز می کند URL +2. پس از تأیید، Google سعی می‌کند به `localhost` تغییر مسیر دهد (که در سرور راه دور با مشکل مواجه می‌شود) +3. **URL** را از نوار آدرس مرورگر خود کپی کنید (حتی اگر صفحه بارگیری نشود) +4. آن URL را در فیلد نشان داده شده در مدال اتصال OmniRoute قرار دهید +5. روی **"اتصال"** کلیک کنید -> This works because the authorization code in the URL is valid regardless of whether the redirect page loaded. +> این کار به این دلیل کار می کند که کد مجوز در URL بدون توجه به بارگیری صفحه تغییر مسیر معتبر است. --- -## 🛠️ Tech Stack +## 🛠️ پشته فناوری
Click to expand tech stack details -- **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) -- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) -- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills -- **Schemas**: Zod (MCP tool I/O validation, API contracts) -- **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) -- **Streaming**: Server-Sent Events (SSE) -- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys + MCP Scoped Authorization -- **Testing**: Node.js test runner + Vitest (900+ tests including unit, integration, E2E) -- **CI/CD**: GitHub Actions (auto npm publish + Docker Hub on release) -- **Website**: [omniroute.online](https://omniroute.online) -- **Package**: [npmjs.com/package/omniroute](https://www.npmjs.com/package/omniroute) +- **زمان اجرا **: Node.js 18–22 LTS ( +- **زبان**: TypeScript 5.9 — **100% TypeScript** در `src/` و `open-sse/` (صفر `any` در ماژول های اصلی از نسخه 2.0) +- **فریم ورک**: Next.js 16 + React 19 + Tailwind CSS 4 +- **پایگاه داده**: بهتر-sqlite3 (SQLite) + LowDB (میراث JSON) - وضعیت دامنه، لاگ های پروکسی، ممیزی MCP، تصمیمات مسیریابی، حافظه، مهارت ها +- **طرحواره**: Zod (تأیید اعتبار I/O ابزار MCP، قراردادهای API) +- **پروتکل ها**: MCP (stdio/HTTP) + A2A نسخه 0.3 (JSON-RPC 2.0 + SSE) +- **جریان**: رویدادهای ارسال شده از سرور (SSE) +- **Auth**: OAuth 2.0 (PKCE) + کلیدهای JWT + API + مجوز محدوده MCP +- **تست**: Node.js تست دونده + Vitest (900+ تست شامل واحد، ادغام، E2E) +- **CI/CD**: اقدامات GitHub (انتشار خودکار npm + مرکز Docker در زمان انتشار) +- **وب سایت**: [omniroute.online](https://omniroute.online) +- **بسته**: [npmjs.com/package/omniroute](https://www.npmjs.com/package/omniroute) - **Docker**: [hub.docker.com/r/diegosouzapw/omniroute](https://hub.docker.com/r/diegosouzapw/omniroute) -- **Resilience**: Circuit breaker, exponential backoff, anti-thundering herd, TLS spoofing, auto-combo self-healing +- **ارتعاش**: قطع کننده مدار، عقب نشینی نمایی، گله ضد رعد و برق، جعل TLS، خودترمیمی خودکار ترکیبی
--- -## Documentación +## مستندات -| Document | Description | +| سند | توضیحات | | --------------------------------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 107 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 13-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/guides/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/architecture/ARCHITECTURE.md) | System architecture and internals | -| [Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | -| [Uninstall Guide](docs/guides/UNINSTALL.md) | Clean removal for all install methods | -| [Environment Config](docs/reference/ENVIRONMENT.md) | Complete `.env` variables and references | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/reference/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/ops/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| [راهنمای کاربر](docs/guides/USER_GUIDE.md) | ارائه دهندگان، ترکیب، یکپارچه سازی CLI، استقرار | +| [مرجع API](docs/reference/API_REFERENCE.md) | تمام نقاط پایانی با مثال | +| [سرور MCP](open-sse/mcp-server/README.md) | 107 ابزار MCP، تنظیمات IDE، کلاینت های Python/TS/Go | +| [سرور A2A](src/lib/a2a/README.md) | پروتکل JSON-RPC 2.0، مهارت ها، جریان، وظیفه mgmt | +| [موتور ترکیبی خودکار](docs/auto-combo.md) | امتیاز دهی 13 عاملی، بسته های حالت، خوددرمانی | +| [رله زمینه](docs/features/context-relay.md) | استراتژی انتقال جلسه برای چرخش حساب | +| [عیب یابی](docs/guides/TROUBLESHOOTING.md) | مشکلات و راه حل های رایج | +| [معماری](docs/architecture/ARCHITECTURE.md) | معماری سیستم و قطعات داخلی | +| [اسناد پایگاه کد](docs/architecture/CODEBASE_DOCUMENTATION.md) | راهنمای مبتدی مبتدی پایه کد | +| [راهنمای حذف نصب](docs/guides/UNINSTALL.md) | حذف پاک برای همه روش های نصب | +| [پیکربندی محیط](docs/reference/ENVIRONMENT.md) | تکمیل متغیرها و مراجع `.env` | +| [مشارکت](CONTRIBUTING.md) | راه اندازی و دستورالعمل های توسعه | +| [مشخصات OpenAPI](docs/reference/openapi.yaml) | مشخصات OpenAPI 3.0 | +| [سیاست امنیتی](SECURITY.md) | گزارش آسیب پذیری و شیوه های امنیتی | +| [استقرار VM](docs/ops/VM_DEPLOYMENT_GUIDE.md) | راهنمای کامل: راه اندازی VM + nginx + Cloudflare | +| [گالری امکانات](docs/guides/FEATURES.md) | تور داشبورد بصری با اسکرین شات | +| [چک لیست انتشار](docs/ops/RELEASE_CHECKLIST.md) | مراحل اعتبار سنجی پیش از انتشار | --- -## 🗺️ Roadmap +## 🗺️ نقشه راه -OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: +OmniRoute دارای **218+ ویژگی برنامه ریزی شده** در چندین فاز توسعه است. در اینجا مناطق کلیدی وجود دارد: -| Category | Planned Features | Highlights | +| دسته بندی | ویژگی های برنامه ریزی شده | نکات برجسته | | ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, connection cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| 🧠 **مسیریابی و هوشمندی** | 25+ | مسیریابی با کمترین تأخیر، مسیریابی مبتنی بر برچسب، پیش از پرواز سهمیه، P2C آگاه از سهمیه، مسیریابی ترکیبی مبتنی بر گام | +| 🔒 **امنیت و انطباق** | 20+ | سخت شدن SSRF، پوشاندن اعتبار، نرخ محدود در نقطه پایانی، محدوده کلید مدیریت | +| 📊 **قابلیت مشاهده** | 15+ | ادغام OpenTelemetry، نظارت بر سهمیه در زمان واقعی، سلامت هدف ترکیبی، ردیابی هزینه در هر مدل | +| 🔄 **یکپارچه سازی ارائه دهنده** | 20+ | رجیستری مدل پویا، خنک کننده اتصال، چند حساب Codex، تجزیه سهمیه Copilot | +| ⚡ **عملکرد** | 15+ | لایه کش دوگانه، کش سریع، حافظه پنهان پاسخ، جریان نگهدارنده، دسته ای API | +| 🌐 **اکوسیستم** | 10+ | WebSocket API، پیکربندی بارگذاری مجدد داغ، فروشگاه پیکربندی توزیع شده، حالت تجاری | -### 🔜 Coming Soon +### 🔜 به زودی -- 🔗 **OpenCode Integration** — Native provider support for the OpenCode AI coding IDE -- 🔗 **TRAE Integration** — Full support for the TRAE AI development framework -- 📦 **Batch API** — Asynchronous batch processing for bulk requests -- 🎯 **Tag-Based Routing** — Route requests based on custom tags and metadata -- 💰 **Lowest-Cost Strategy** — Automatically select the cheapest available provider +- 🔗 **ادغام OpenCode** — پشتیبانی ارائه دهنده بومی برای کدنویسی OpenCode AI IDE +- 🔗 **ادغام TRAE** — پشتیبانی کامل از چارچوب توسعه TRAE AI +- 📦 ** دسته ای API ** - پردازش دسته ای ناهمزمان برای درخواست های انبوه +- 🎯 **مسیریابی مبتنی بر برچسب** - درخواست های مسیر بر اساس برچسب ها و ابرداده های سفارشی +- 💰 **استراتژی کم هزینه** - ارزانترین ارائه دهنده موجود را به صورت خودکار انتخاب کنید -> 📝 Full feature specifications available in [`docs/new-features/`](docs/new-features/) (217 detailed specs) +> 📝 مشخصات کامل ویژگی موجود در [`docs/new-features/`](docs/new-features/) (217 مشخصات دقیق) --- -## 👥 Contributors +## 👥 مشارکت کنندگان [![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=100&columns=20&anon=1)](https://github.com/diegosouzapw/OmniRoute/graphs/contributors) -### How to Contribute +### نحوه مشارکت -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +1. مخزن را چنگال کنید +2. شاخه ویژگی خود را ایجاد کنید (`git checkout -b feature/amazing-feature`) +3. تغییرات خود را انجام دهید (`git commit -m 'Add amazing feature'`) +4. فشار به شاخه (`git push origin feature/amazing-feature`) +5. یک Pull Request را باز کنید -See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. +برای دستورالعمل های دقیق به [CONTRIBUTING.md](CONTRIBUTING.md) مراجعه کنید. -### Releasing a New Version +### انتشار یک نسخه جدید ```bash # Create a release — npm publish happens automatically @@ -2189,7 +2189,7 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes --- -## 📊 Star History +## 📊 تاریخچه ستاره @@ -2209,15 +2209,15 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes -## 🙏 Acknowledgments +## 🙏 تشکر و قدردانی -Special thanks to **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — the original Go implementation that inspired this JavaScript port. +تشکر ویژه از **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** - پیاده سازی اصلی Go که الهام بخش این پورت جاوا اسکریپت است. --- -## Licencia +## مجوز -MIT License - see [LICENSE](LICENSE) for details. +مجوز MIT - برای جزئیات بیشتر به [LICENSE](LICENSE) مراجعه کنید. --- diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 1d98061631..c81ec3a853 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index 01fb1ec72b..974084ef14 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index b20f38d804..6db495b3d9 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index 3cabcd235f..885f78df63 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index d1dca10cc1..617ce45e8c 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index e88a70b26a..67b0cfeba3 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index fdd9526279..fa74ab5997 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index 776304488c..71572bd5a4 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/in/llm.txt b/docs/i18n/in/llm.txt index 2e0784f344..c6f22ffc46 100644 --- a/docs/i18n/in/llm.txt +++ b/docs/i18n/in/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index 160add875c..db4f6a9cfd 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index 7e3f75466f..f021f83d5a 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index 6cd010d0a4..52cf209a74 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index 12129f663d..cc3125f845 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index 8c01bf3654..650ac31922 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index 7fe980153e..bdd29b2d01 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index 32364510ee..7cb57aa53c 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index 54595155ef..73ea9e49ba 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/pl/docs/compression/COMPRESSION_ENGINES.md b/docs/i18n/pl/docs/compression/COMPRESSION_ENGINES.md index b8488d6872..ea266af05b 100644 --- a/docs/i18n/pl/docs/compression/COMPRESSION_ENGINES.md +++ b/docs/i18n/pl/docs/compression/COMPRESSION_ENGINES.md @@ -142,22 +142,22 @@ wskazuje zamiast tego lokalną kopię (instalacje offline / air-gapped). ### Opcjonalne zależności i instalacja on-demand -Przycinany stos peerów runtime LLMLingua jest **opcjonalny**. Trzy pakiety są zadeklarowane jako +Przycinany stos peerów runtime LLMLingua jest **opcjonalny**. Dwa pakiety są zadeklarowane jako `optionalDependencies` w `package.json` i utrzymywane jako **external** przez build produkcyjny (`scripts/build/prepublish.ts` ich nie bundluje): -| Package | Version (pin) | Notes | -| -------------------- | ------------- | ------------------------------------------------- | -| `@atjsh/llmlingua-2` | `2.0.3` | Pakiet wejściowy; deklaruje pozostałe jako peery | -| `@tensorflow/tfjs` | `4.22.0` | Najcięższa zależność — dominuje footprint ~800 MB | -| `js-tiktoken` | `^1.0.20` | Tokenizer | +| Package | Version (pin) | Notes | +| -------------------- | ------------- | ------------------------------------------- | +| `@atjsh/llmlingua-2` | `2.0.5` | Pakiet wejściowy; deklaruje pozostałe jako peery | +| `js-tiktoken` | `^1.0.20` | Tokenizer | -`@huggingface/transformers` jest pinowany na `3.5.2` jako **opcjonalna** zależność (współdzielona ze -ścieżką lokalnych embeddings i również śledzona do standalone bundle). Utrzymanie jej jako optional -zapobiega awariom postinstall providera CUDA `onnxruntime-node` na hostach CUDA 11, które przerywałyby -całą instalację OmniRoute; gdy opcjonalny stos jest nieobecny, LLMLingua nadal fail-openuje. Tylko trzy -powyższe pakiety to przycinane peery SLM. Standardowe `npm install` (dev) instaluje opcjonalny stos -automatycznie, o ile opcjonalne zależności nie zostaną pominięte. +`@huggingface/transformers` jest pinowany na `^4.2.0` (współdzielony ze ścieżką lokalnych embeddings +i również śledzony do standalone bundle); `@atjsh/llmlingua-2@2.0.5` peeruje na nim przez +`"^3.5.2 || ^4.0.0"`, więc obsługiwane są zarówno Transformers.js v3, jak i v4. Od 2.0.4 +`@atjsh/llmlingua-2` nie wymaga już `@tensorflow/tfjs`, co usunęło największy pojedynczy wkład +(TensorFlow.js) ze stosu SLM. Tylko dwa powyższe pakiety to przycinane peery SLM. Standardowe +`npm install` (dev) instaluje opcjonalny stos automatycznie, o ile opcjonalne zależności nie zostaną +pominięte. **Dlaczego on-demand:** pakiet publikowany w npm, standalone bundle i obraz Docker dostarczane są **bez** tych zależności, aby pozostać lekkie. Gdy ich brakuje, bramka zależności @@ -167,11 +167,12 @@ logowanego błędu). Aby aktywować go w przyciętym środowisku, zainstaluj opc ```bash # pin to the versions declared in package.json optionalDependencies -npm install @atjsh/llmlingua-2@2.0.3 @tensorflow/tfjs@4.22.0 js-tiktoken +npm install @atjsh/llmlingua-2@2.0.5 js-tiktoken ``` -Łącznie mniej więcej **~800 MB**: dominują runtime’y TensorFlow.js + transformers; model -TinyBERT dodaje ~57 MB pobierane przy pierwszym użyciu (nie przez npm). +Usunięcie `@tensorflow/tfjs` (2.0.4+) eliminuje wcześniej dominujący wkład ~800 MB — pozostały +footprint to runtime’y transformers.js + onnxruntime-node oraz model TinyBERT (~57 MB) pobierany +przy pierwszym użyciu (nie przez npm). Per środowisko: diff --git a/docs/i18n/pl/docs/ops/RELEASE_CHECKLIST.md b/docs/i18n/pl/docs/ops/RELEASE_CHECKLIST.md index 1d9cdb29f6..535dadd53c 100644 --- a/docs/i18n/pl/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/i18n/pl/docs/ops/RELEASE_CHECKLIST.md @@ -326,13 +326,11 @@ Przed wypuszczeniem dowolnego wydania v3.8.x zweryfikuj te dodatkowe pozycje: - [ ] `npm install -g omniroute@` uruchamia postinstall bez fatalnego wyjścia - [ ] Ścieżka update zachowuje optional deps: `omniroute update --apply` i auto-updater uruchamiają `npm install -g … --include=optional`, żeby `optionalDependencies` (better-sqlite3, - keytar, tls-client oraz stack SLM llmlingua: `@atjsh/llmlingua-2`, - `@huggingface/transformers@3.5.2`, `@tensorflow/tfjs`, `js-tiktoken`) przeżyły update. - `@huggingface/transformers` zostaje optional, żeby jego postinstall providera CUDA `onnxruntime-node` - nie mógł przerwać instalacji na hostach CUDA 11. Tier ultra `modelPath` SLM potrzebuje też + keytar, tls-client oraz stack SLM llmlingua: `@atjsh/llmlingua-2@2.0.5`, + `js-tiktoken`) przeżyły update. Tier ultra `modelPath` SLM potrzebuje też modelu tinybert, auto-pobieranego do `${DATA_DIR}/models/llmlingua` przy pierwszym użyciu. Postinstall (`scripts/build/colocateOptionals.mjs`) następnie ko-lokuje opcjonalne zamknięcie SLM do - `dist/node_modules`, żeby worker rozwiązywał JEDNĄ opcjonalną instancję `@huggingface/transformers` 3.5.2 + `dist/node_modules`, żeby worker rozwiązywał JEDNĄ instancję `@huggingface/transformers` ^4.2.0 — standalone trace bundluje tylko transformers, nie dynamicznie importowane optionals, więc bez tego worker załadowałby llmlingua-2 przeciw transformers z roota i tier SLM cicho fail-openowałby. diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 18c1d5aeaf..7d1d8be0f2 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index 8dc803bdbe..59be9758e4 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index 0a841f4d7f..1f4f205d44 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index 4461a57b48..485f971d1f 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index e4b51b687b..319111231a 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index 3783940896..edb3477ef2 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index 3e4eb1f5f4..48847120cb 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index ec134c100a..f90e0ec031 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index 64848422b9..8f64c7aab8 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index ebb7d28a0c..483d2f8a41 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index dc01025526..ac3a14103a 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 182af11a3b..d6e2fbd674 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index bd5997d66a..17e04f3ce1 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index 6d04724edb..9291f52642 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index ff99a1050e..0cc3e4e22e 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/zh-CN/docs/ops/RELEASE_CHECKLIST.md b/docs/i18n/zh-CN/docs/ops/RELEASE_CHECKLIST.md index d8cefd6497..afdbf398af 100644 --- a/docs/i18n/zh-CN/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/i18n/zh-CN/docs/ops/RELEASE_CHECKLIST.md @@ -275,14 +275,12 @@ npm run build:release - [ ] `npm install -g omniroute@` 运行 postinstall 无致命退出 - [ ] 更新路径保留可选依赖:`omniroute update --apply` 以及自动更新器 运行 `npm install -g … --include=optional` 以确保 `optionalDependencies`(better-sqlite3、 - keytar、tls-client 以及 llmlingua SLM 栈:`@atjsh/llmlingua-2`、 - `@huggingface/transformers@3.5.2`、`@tensorflow/tfjs`、`js-tiktoken`)在更新后仍然存在。 - `@huggingface/transformers` 保持为可选依赖,这样其 `onnxruntime-node` CUDA provider postinstall - 不会在 CUDA 11 主机上中断安装。Ultra 模式的 `modelPath` SLM 层还需要 + keytar、tls-client 以及 llmlingua SLM 栈:`@atjsh/llmlingua-2@2.0.5`、 + `js-tiktoken`)在更新后仍然存在。Ultra 模式的 `modelPath` SLM 层还需要 tinybert 模型,首次使用时自动下载到 `${DATA_DIR}/models/llmlingua`。postinstall (`scripts/build/colocateOptionals.mjs`)随后将 SLM 可选依赖闭包共置到 - `dist/node_modules`,使 Worker 解析单一的 `@huggingface/transformers` 3.5.2 - 可选实例 — standalone trace 仅打包 transformers,不包含动态导入的 + `dist/node_modules`,使 Worker 解析单一的 `@huggingface/transformers` ^4.2.0 + 实例 — standalone trace 仅打包 transformers,不包含动态导入的 可选依赖,否则 Worker 会基于根目录的 transformers 加载 llmlingua-2, SLM 层将静默失效。 - [ ] `omniroute status` 在无 `.env` 的情况下正常工作(CLI Token 路径,仅 loopback) diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index 925f9e6184..28d5e6fe23 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/zh-TW/docs/ops/RELEASE_CHECKLIST.md b/docs/i18n/zh-TW/docs/ops/RELEASE_CHECKLIST.md index bbadf03423..c604668d19 100644 --- a/docs/i18n/zh-TW/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/i18n/zh-TW/docs/ops/RELEASE_CHECKLIST.md @@ -322,14 +322,12 @@ npm run build:release - [ ] `npm install -g omniroute@<此版本>` 執行 postinstall 而不會致命退出 - [ ] 更新路徑保留選擇性依賴:`omniroute update --apply` 和自動更新器 執行 `npm install -g … --include=optional`,因此 `optionalDependencies`(better-sqlite3、 - keytar、tls-client,以及 llmlingua SLM 堆疊:`@atjsh/llmlingua-2`、 - `@huggingface/transformers@3.5.2`、`@tensorflow/tfjs`、`js-tiktoken`)在更新後仍會保留。 - `@huggingface/transformers` 維持選擇性,因此其 `onnxruntime-node` CUDA 提供者的 postinstall - 不會在 CUDA 11 主機上中斷安裝。Ultra `modelPath` SLM 層還需要 + keytar、tls-client,以及 llmlingua SLM 堆疊:`@atjsh/llmlingua-2@2.0.5`、 + `js-tiktoken`)在更新後仍會保留。Ultra `modelPath` SLM 層還需要 tinybert 模型,會在首次使用時自動下載到 `${DATA_DIR}/models/llmlingua`。Postinstall (`scripts/build/colocateOptionals.mjs`)接著將 SLM 選擇性閉包複製到 - `dist/node_modules`,使工作者解析到**單一** `@huggingface/transformers` 3.5.2 - 選擇性實例——獨立追蹤僅捆綁 transformers,而非動態匯入的 + `dist/node_modules`,使工作者解析到**單一** `@huggingface/transformers` ^4.2.0 + 實例——獨立追蹤僅捆綁 transformers,而非動態匯入的 選擇性套件,因此若無此步驟,工作者會載入 llmlingua-2 並使用根目錄的 transformers, 導致 SLM 層靜默地失敗但仍保持運作。 - [ ] `omniroute status` 在無 `.env` 的情況下正常運作(僅限 CLI 權杖路徑,迴環介面) diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index 1c95e25755..ae8fd155b5 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 79cee7893b..fb255d3543 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2069,6 +2069,39 @@ paths: description: Created combo /api/combos/{id}: + get: + tags: [Combos] + summary: Get combo by ID + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Combo details + "404": + description: Combo not found + put: + tags: [Combos] + summary: Update combo + description: >- + Partial update: the body is merged onto the stored combo, so a field left out keeps + its current value. An array that IS sent replaces the stored one outright. + parameters: + - $ref: "#/components/parameters/ResourceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Updated combo + "400": + description: Invalid body, or the resulting combo fails validation + "404": + description: Combo not found + "409": + description: Name already taken, or the combo is quota-share managed patch: tags: [Combos] summary: Update combo @@ -7329,12 +7362,18 @@ components: BearerAuth: type: http scheme: bearer - description: API key obtained from the OmniRoute dashboard + description: > + Two bearer families are accepted. Inference API keys (typically `sk-…`) + authorize `/v1/*`. Management routes also accept `oma_live_…` Access Tokens + (Settings → Access Tokens / `omniroute connect`) and API keys whose metadata + includes `manage` or `admin` scope. See docs/guides/MANAGEMENT-AUTH.md. + Bearer credentials are accepted on management routes that use this scheme; + they are not rejected solely for being Bearer. ManagementSessionAuth: type: apiKey in: cookie name: auth_token - description: Dashboard management session cookie for protected management routes + description: Dashboard management session cookie (auth_token) for protected management routes. Distinct from Bearer Access Tokens and API keys. See docs/guides/MANAGEMENT-AUTH.md. parameters: ResourceId: diff --git a/docs/ops/DATABASE_GUIDE.md b/docs/ops/DATABASE_GUIDE.md index e0a0292a05..55d3771ce6 100644 --- a/docs/ops/DATABASE_GUIDE.md +++ b/docs/ops/DATABASE_GUIDE.md @@ -453,26 +453,47 @@ Run monthly during low-traffic windows. (WAL mode reduces the need, but doesn't `src/lib/db/healthCheck.ts` provides **DB-level health diagnostics**: -````bash -GET /api/db/health +Both verbs require authentication (`401` otherwise). `GET` diagnoses only; `POST` runs the +same check with `autoRepair` enabled. -Returns: +```bash +GET /api/db/health # diagnose +POST /api/db/health # diagnose + repair +``` + +The response is the `DbHealthCheckResult` produced by `runDbHealthCheck()` +(`src/lib/db/healthCheck.ts`): ```json { - "status": "healthy", - "checks": { - "writable": { "status": "pass" }, - "integrity": { "status": "pass", "result": "ok" }, - "foreign_keys": { "status": "pass", "violations": 0 }, - "orphaned_artifacts": { "status": "warn", "count": 12 }, - "table_sizes": { - "usage_history": { "rows": 12345, "size_mb": 12.3 }, - "call_logs": { "rows": 567, "size_mb": 2.1 } + "isHealthy": false, + "issues": [ + { + "type": "broken_reference", + "table": "domain_budgets", + "description": "Domain budgets referenced API keys that no longer exist.", + "count": 2 } - } + ], + "repairedCount": 0, + "backupCreated": false, + "autoRepair": false, + "checkedAt": "2026-08-18T09:00:00.000Z", + "driver": { "name": "better-sqlite3", "degraded": false } } -```` +``` + +| Field | Meaning | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `isHealthy` | `true` when `issues` is empty. `driver` never influences it. | +| `issues[].type` | One of `integrity_check_failed`, `broken_reference`, `stale_snapshot`, `invalid_state`. | +| `repairedCount` | Rows repaired during this run; always `0` when `autoRepair` is false. | +| `backupCreated` | Whether a backup was taken before repairing. | +| `checkedAt` | ISO timestamp shared by the run and by any repair note it writes. | +| `driver.name` | SQLite driver serving the checked database. | +| `driver.degraded` | `true` when writes are not durably backed by the database file — the `sql.js` WASM fallback (whole-file persistence) or an in-memory database. | + +The same payload is returned by the `omniroute_db_health_check` MCP tool. Run `PRAGMA integrity_check` to detect corruption: diff --git a/docs/ops/MONITORING_GUIDE.md b/docs/ops/MONITORING_GUIDE.md index 82a66eeb49..6543b95f06 100644 --- a/docs/ops/MONITORING_GUIDE.md +++ b/docs/ops/MONITORING_GUIDE.md @@ -157,13 +157,13 @@ Response: ### Kubernetes probe recommendations -OmniRoute is a **single Node process** (one event loop). Stock Docker `HEALTHCHECK` targets `/api/monitoring/health` — that is **too heavy** for kubelet liveness intervals. +OmniRoute is a **single Node process** (one event loop). Stock Docker `HEALTHCHECK` targets lightweight `/healthz`. `/api/monitoring/health` is **too heavy** for kubelet liveness intervals. | Probe | Recommended target | Notes | | --- | --- | --- | | **Startup** | HTTP `GET /healthz` with a long `failureThreshold` (or large `startPeriod`) | Cold start + SQLite migration can exceed a few seconds | -| **Readiness** | HTTP `GET /healthz` | Remove endpoints while starting/stopping; still flaps if the loop is CPU-blocked | -| **Liveness** | **TCP** on the main service port (`PORT`, default `20128`), **or** HTTP `/healthz` with soft thresholds | Do **not** kill the pod on short event-loop stalls; busy ≠ dead | +| **Readiness** | HTTP `GET /healthz` | Lifecycle `ok` / `starting` / `stopping` (200 vs 503). Still flaps if the loop is CPU-blocked. A **200 in multiple seconds is not healthy** (#10303) — it means the event loop was starved before the 3-byte handler ran | +| **Liveness** | HTTP `GET /livez`, **or TCP** on the main service port (`PORT`, default `20128`) | `/livez` is process-alive only (always 200 if the handler runs). It still shares the event loop — busy ≠ dead, and it does not detect event-loop starvation (#10303) any better than TCP does. Prefer **TCP** if HTTP probes time out under catalog/compression load; do **not** kill the pod on short event-loop stalls either way | | **Deep health** | `GET /api/monitoring/health` from an external checker | Not for kubelet `livenessProbe` / tight `readinessProbe` | Example shape (adjust thresholds to your cold-start and compression load): @@ -186,17 +186,27 @@ readinessProbe: timeoutSeconds: 2 failureThreshold: 6 livenessProbe: - tcpSocket: + httpGet: + path: /livez port: http periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 6 + # Under event-loop stall HTTP /livez can still time out. TCP is the + # conservative alternative: + # tcpSocket: + # port: http ``` **Do not** point kubelet **liveness** at `/api/monitoring/health`. That path does real DB/monitoring work and will false-positive under load. Related: [#10052](https://github.com/diegosouzapw/OmniRoute/issues/10052) (probes while the event loop is busy), [#9685](https://github.com/diegosouzapw/OmniRoute/issues/9685) / [#10055](https://github.com/diegosouzapw/OmniRoute/pull/10055) (catalog pricing hog), [#10117](https://github.com/diegosouzapw/OmniRoute/issues/10117) (compression token-count hog). + +### Optional request-path work (memory, skills, token refresh) + +Memory extraction, skills injection, and OAuth token refresh share the **main Node event loop** with `/healthz`. They are dashboard-toggle features (`memoryEnabled`, `skillsEnabled`), not a worker pool. See [Environment — event-loop cost](../reference/ENVIRONMENT.md#event-loop-cost-of-memory-skills-and-token-refresh-10349). + ### Provider Health > **No REST endpoint.** Provider health data is available via the MCP tool `observability_snapshot` or the dashboard `/dashboard/providers` page. diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md index b800d6fc22..dfa96d53ee 100644 --- a/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/ops/RELEASE_CHECKLIST.md @@ -66,6 +66,15 @@ as the default reflex (minutes, reversible); `npm unpublish` only inside the 72h window and never as the first move. Docker: never rewrite a version tag — rollback is repointing `latest` to the last good digest. +**Docker Hub `latest` (required on every stable SemVer publish):** the +`docker-publish` workflow must tag **both** `X.Y.Z` and, when +`should-promote-latest.sh` agrees this is the highest stable SemVer, `:latest` +with the **same digest**. After the job: Hub `latest` digest equals the new +SemVer digest and `last_updated` moved. Do not leave `:latest` on an older +build while release notes talk about fixes that only exist on git. Compose +quickstarts use `:latest`; GitOps should keep pinning `X.Y.Z`. See +[Docker release channels](../guides/DOCKER_GUIDE.md#release-channels) and #10317. + ## Hotfix Fast-Lane (label `hotfix`) A PR labeled `hotfix` skips the heavy CI matrix (9-shard E2E, coverage ratchet, @@ -342,14 +351,12 @@ Before shipping any v3.8.x release, verify these additional items: - [ ] `npm install -g omniroute@` runs postinstall without fatal exit - [ ] Update path keeps optional deps: `omniroute update --apply` and the auto-updater run `npm install -g … --include=optional` so `optionalDependencies` (better-sqlite3, - keytar, tls-client, and the llmlingua SLM stack: `@atjsh/llmlingua-2`, - `@huggingface/transformers@3.5.2`, `@tensorflow/tfjs`, `js-tiktoken`) survive an update. - `@huggingface/transformers` stays optional so its `onnxruntime-node` CUDA provider postinstall - cannot abort installation on CUDA 11 hosts. The ultra `modelPath` SLM tier also needs the + keytar, tls-client, and the llmlingua SLM stack: `@atjsh/llmlingua-2@2.0.5`, + `js-tiktoken`) survive an update. The ultra `modelPath` SLM tier also needs the tinybert model, auto-downloaded to `${DATA_DIR}/models/llmlingua` on first use. Postinstall (`scripts/build/colocateOptionals.mjs`) then co-locates the SLM optional closure into - `dist/node_modules` so the worker resolves a SINGLE `@huggingface/transformers` 3.5.2 - optional instance — the standalone trace bundles only transformers, not the dynamically-imported + `dist/node_modules` so the worker resolves a SINGLE `@huggingface/transformers` ^4.2.0 + instance — the standalone trace bundles only transformers, not the dynamically-imported optionals, so without this the worker would load llmlingua-2 against the root's transformers and the SLM tier would silently fail-open. - [ ] `omniroute status` works with no `.env` (CLI token path, loopback only) diff --git a/docs/ops/SQLITE_RUNTIME.md b/docs/ops/SQLITE_RUNTIME.md index e2e6a412da..ab31996fb2 100644 --- a/docs/ops/SQLITE_RUNTIME.md +++ b/docs/ops/SQLITE_RUNTIME.md @@ -80,3 +80,17 @@ Implementation: - `bin/cli/runtime/index.mjs` — startup orchestrator (`warmUpRuntimes()`) - `scripts/postinstall.mjs` — npm post-install hook (non-fatal warm-up) - `src/lib/db/core.ts` — `ensureDbInitialized()` / `getDriverInfo()` exports + +## Single-writer topology (HA unsupported) + +The driver fallback chain above still runs in **one process**. Default SQLite +OmniRoute is a **single writer**: + +- Do not attach two OmniRoute replicas to the same `storage.sqlite` file. +- A container restart, Recreate deploy, OOM kill, or HEALTHCHECK restart drops + every in-flight SSE session. There is no session drain on the stock path. +- Orchestrator liveness that treats a slow `/healthz` as dead will kill the only + replica. Prefer TCP liveness + HTTP `/healthz` readiness. See + [Docker Guide — availability](../guides/DOCKER_GUIDE.md#availability-default-sqlite-is-single-replica) + and [Kubernetes probe recommendations](./MONITORING_GUIDE.md#kubernetes-probe-recommendations). + diff --git a/docs/providers/CURSOR-API-KEY-AND-CLI.md b/docs/providers/CURSOR-API-KEY-AND-CLI.md new file mode 100644 index 0000000000..53c0a85dfd --- /dev/null +++ b/docs/providers/CURSOR-API-KEY-AND-CLI.md @@ -0,0 +1,123 @@ +--- +title: "Cursor API provider and the Cursor CLI passthrough" +version: 3.8.50 +lastUpdated: 2026-08-19 +--- + +# Cursor API provider and the Cursor CLI passthrough + +Two ways to put Cursor behind OmniRoute without an IDE session: + +1. **`cursor-api` provider** (card "Cursor API", alias `cua`): an API-key + provider that holds a Cursor user API key (`crsr_…`, generated at + `https://cursor.com/dashboard/api`). Any OmniRoute client then reaches + Cursor models through `/v1/chat/completions` as `cursor-api/` or + `cua/`, with the usual quota, fallback and logging layers. The IDE + provider (`cursor`, OAuth/IDE session) is unchanged. +2. **Cursor CLI passthrough**: point the Cursor CLI (`agent`) at OmniRoute so + every RPC the CLI makes is authenticated with an OmniRoute API key, forwarded + to Cursor with a `cursor-api` connection's credential, and recorded in the + Logs page. + +## Why the key is exchanged + +`api2.cursor.sh` rejects a raw `crsr_…` key as a Bearer token (401). The Cursor +CLI first POSTs the key to `/auth/exchange_user_api_key` and receives a session +JWT that expires after one hour; the returned `refreshToken` carries the same +`exp`, so refreshing means re-exchanging the key. +`open-sse/services/cursorApiKeyAuth.ts` does that exchange, caches one session +token per key, re-exchanges five minutes before expiry and drops the cached +token when Cursor answers 401. `CursorExecutor` calls it right before opening +the upstream stream for `cursor-api` connections. + +## The `cursor-api` provider + +Registry: `open-sse/config/providers/registry/cursor/index.ts` +(`cursor_apiProvider`, `authType: "apikey"`, same `format`, `baseUrl` and +`models` as `cursor`). Catalog card: +`src/shared/constants/providers/apikey/specialty-media.ts`. Executor map: +`open-sse/executors/index.ts` (`"cursor-api"` / `cua` → +`new CursorExecutor("cursor-api")`). + +Dashboard: Providers → Cursor API → Add API key. + +REST: + +```bash +curl -sS -X POST http://localhost:20128/api/providers \ + -H "Content-Type: application/json" \ + -d '{"provider":"cursor-api","name":"cursor-api-key","apiKey":"crsr_…","priority":1}' +``` + +Then: + +```bash +curl -sS http://localhost:20128/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"cursor-api/auto","messages":[{"role":"user","content":"say PONG"}]}' +``` + +Notes: + +- Model listing for `cursor-api` comes from the static Cursor registry (the + same list the IDE provider falls back to); no `cursor-agent` install is + needed on the OmniRoute host. +- `POST /api/providers/{id}/refresh-cursor` is for the `cursor` IDE provider + only; `cursor-api` connections have no IDE session to renew. + +## Cursor CLI passthrough + +Route: `src/app/api/cursor-cli/[...path]/route.ts` → +`open-sse/handlers/cursorCliProxy.ts`. The prefix `/api/cursor-cli/` is +registered in `src/shared/constants/publicApiRoutes.ts` because the handler +enforces its own authentication: + +| Path | Auth expected from the CLI | What OmniRoute does | +| ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `POST /auth/exchange_user_api_key` | `Bearer ` | Validates the key, mints a 1h HS256 JWT (signed with `JWT_SECRET`) and returns it | +| every other path (`/aiserver.v1.*`, `/agent.v1.AgentService/RunSSE`, `/aiserver.v1.BidiService/BidiAppend`, `/v1/traces`, …) | `Bearer ` | Verifies issuer/audience/expiry, picks an active `cursor-api` connection, swaps the Authorization header for the exchanged Cursor token and streams the reply back | + +The CLI decodes `exp` from whatever token it receives, so handing it an opaque +token makes it re-exchange before almost every request; the minted JWT avoids +that. A 401 from OmniRoute makes the CLI exchange again. + +### Setup + +1. Create an OmniRoute API key (Dashboard → API keys) and a `cursor-api` + connection. +2. Tell the CLI to use HTTP/1.1 for the agent stream. In + `~/.cursor/cli-config.json`: + + ```json + { "network": { "useHttp1ForAgent": true } } + ``` + + Without this the CLI opens the agent turn over HTTP/2 to a separately + configured agent host and only the control-plane RPCs go through the + endpoint. + +3. Run the CLI against OmniRoute: + + ```bash + export CURSOR_API_ENDPOINT=http://localhost:20128/api/cursor-cli + export CURSOR_API_KEY= + agent -p --trust "Reply with exactly OK" + ``` + +Every hop lands in Logs as provider `cursor-api`, request type `cursor-cli`, +path `/api/cursor-cli/`, attributed to the OmniRoute API key and the +connection that served it. + +### Failure modes + +| Situation | Response to the CLI | +| ------------------------------------------------ | --------------------------------------------- | +| Unknown OmniRoute key and `REQUIRE_API_KEY=true` | 401 `unauthenticated` on exchange | +| `REQUIRE_API_KEY=false` | anonymous session (mirrors `/v1/*` behaviour) | +| Expired / foreign / tampered session JWT | 401, the CLI re-exchanges | +| OmniRoute API key revoked after exchange | 401 on the next RPC | +| No active `cursor-api` connection | 503 `unavailable` | +| Cursor rejects the connection's key | 401 `unauthenticated`, cached session dropped | +| Upstream unreachable | 502 `unavailable` (sanitized message) | +| `JWT_SECRET` unset | 503 on exchange | diff --git a/docs/providers/CURSOR_IMAGE.md b/docs/providers/CURSOR_IMAGE.md new file mode 100644 index 0000000000..a620c4de79 --- /dev/null +++ b/docs/providers/CURSOR_IMAGE.md @@ -0,0 +1,75 @@ +--- +title: "Cursor Image Generation" +version: 3.8.49 +lastUpdated: 2026-07-23 +--- + +# Cursor Image Generation + +OmniRoute exposes Cursor plan **image generation** on `POST /v1/images/generations` through the same provider id as chat: `cursor` (alias `cu`). + +| Field | Value | +|-------|--------| +| `IMAGE_PROVIDERS` id | `cursor` | +| Format | `cursor-agent-image` | +| Auth | Same OAuth / API-key connection as chat (`provider_connections.provider = "cursor"`) | +| Models | `cursor/auto`, `cursor/composer-2`, `cursor/composer-2.5` | + +## Why the Agent CLI + +Cursor chat in OmniRoute uses `agent.v1.AgentService/Run` (protobuf). That path **rejects** built-in client tools (shell, write, …). Image generation is a Cursor-native tool executed by the **`agent` CLI** against the seat. The image handler therefore spawns `agent` with a locked prompt and a per-request temp workspace (same shape as community seat bridges), then returns OpenAI-compatible `b64_json`. + +## Access restriction (Hard Rules #15 + #17) + +This is the only `IMAGE_PROVIDERS` format that spawns a child process (the `agent` +binary). Because `POST /v1/images/generations` is shared by ~40 other, non-spawning +image providers that remote callers legitimately use, the whole route is **not** +classified `LOCAL_ONLY` — instead `handleCursorAgentImageGeneration` enforces its own +gate using the trusted `AUTHZ_HEADER_PEER_LOCALITY` verdict the authz pipeline stamps +on every request (from the real TCP peer, never the spoofable `Host` header): only +`loopback` and `lan` callers may reach the spawn; everything else (including a leaked +API key replayed over a public tunnel) gets `403` before any credential lookup or +process spawn happens. See `src/server/authz/policies/management.ts` for the same +policy applied to the rest of the `LOCAL_ONLY` tier. + +## Concurrency gate is module-level (single-instance limitation) + +`CURSOR_IMG_MAX_CONCURRENT` is enforced by an in-memory counter/queue scoped to the +Node module instance (`open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts`). +It correctly limits concurrent `agent` spawns within one OmniRoute process, but does +**not** coordinate across multiple processes/instances sharing the same Cursor seat +(e.g. a multi-replica deployment) — each instance enforces its own independent limit. +For a single-instance deployment (the default) this is exact; horizontally scaled +deployments should keep `CURSOR_IMG_MAX_CONCURRENT` conservative per instance or route +Cursor image traffic to a single instance. + +## Requirements + +1. A connected Cursor account in the dashboard (OAuth or `crsr_…` API key). +2. The Cursor Agent binary available to the OmniRoute process: + - env `CURSOR_AGENT_BIN=/path/to/agent`, or + - `~/.local/bin/agent`, or + - `providerSpecificData.agentBin` on the Cursor connection. + +Optional tuning: + +| Env | Default | Meaning | +|-----|---------|---------| +| `CURSOR_IMG_TIMEOUT_MS` | `210000` | Per-image wall clock | +| `CURSOR_IMG_MAX_CONCURRENT` | `2` | Shared-seat concurrency gate | +| `CURSOR_IMG_MODEL` | (request model / `auto`) | Override CLI `--model` | + +## Example + +```bash +curl -sS https:///v1/images/generations \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"cursor/auto","prompt":"a lantern in fog","size":"1024x1024"}' +``` + +Generation typically takes 1–2 minutes. Prefer an internal network path; edge proxies with ~100s timeouts will fail. + +## LiteLLM + +Register an image model with `mode: image_generation`, `api_base: http://omniroute:20128/v1`, and `model: openai/cursor/auto` (or bare `cursor/auto` depending on your LiteLLM version). diff --git a/docs/providers/ZED-DOCKER.md b/docs/providers/ZED-DOCKER.md index 21b096b48c..e3519ea70c 100644 --- a/docs/providers/ZED-DOCKER.md +++ b/docs/providers/ZED-DOCKER.md @@ -103,7 +103,7 @@ The manual import endpoint can also be called directly: ``` POST /api/providers/zed/manual-import Content-Type: application/json -Authorization: Bearer +Authorization: Bearer { "provider": "openai", diff --git a/docs/providers/meta.json b/docs/providers/meta.json index 82eec7eca7..fa6485dd57 100644 --- a/docs/providers/meta.json +++ b/docs/providers/meta.json @@ -7,6 +7,7 @@ "CHATGPT_WEB", "AGENTROUTER", "ZED-DOCKER", - "CURSOR-DOCKER" + "CURSOR-DOCKER", + "CURSOR-API-KEY-AND-CLI" ] } diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index b2f5983b78..359ae4750e 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -703,6 +703,10 @@ X-OmniRoute-No-Cache: true ## Dashboard & Management +Management routes (`/api/*` except public auth/login) are **not** authorized by +ordinary inference API keys. Credential families, scopes, and curl examples: +[Management Authentication](../guides/MANAGEMENT-AUTH.md). + ### Authentication | Endpoint | Method | Description | @@ -1668,9 +1672,14 @@ See [Security > Guardrails](../security/GUARDRAILS.md) for full details. ## Authentication +See [Management Authentication](../guides/MANAGEMENT-AUTH.md) for the four +credential families (dashboard session, local CLI token, `oma_live_…` Access +Token, manage-scoped API key) and how they differ from inference keys. + - Dashboard routes (`/dashboard/*`) use `auth_token` cookie - Login uses saved password hash; fallback to `INITIAL_PASSWORD` - `requireLogin` toggleable via `/api/settings/require-login` - `/v1/*` routes optionally require Bearer API key when `REQUIRE_API_KEY=true` +- "management token" / "management-scoped API key" in this reference means one of the families in that guide — not an undefined extra secret type > **Breaking change (v3.8.0)** — `/api/v1/agents/tasks/*` and the cooldown management endpoints now require **management auth** (dashboard `auth_token` cookie or a management-scoped API key). Clients that previously called these routes unauthenticated will receive `401 Unauthorized`. See commit `588a0333` (`fix(auth): require management auth for agent and cooldown APIs`). diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ccb6a6981f..401d7f6a30 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -91,10 +91,11 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_DATA_DIR` | _(unset)_ | `open-sse/executors/promptql/threadSticky.ts` | **Fallback alias** for `DATA_DIR`, checked only when `DATA_DIR` is unset. Used to locate the PromptQL executor's on-disk thread-sticky session cache (`/promptql-thread-sessions.json`); if neither var is set, the cache stays in-memory only (not persisted across restarts). | | `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. | | `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. | -| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips the automatic database backup that runs before migrations on every startup. | +| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips automatic + pre-write SQLite file backups (startup, models.dev pricing save/clear, settings writes). Manual and pre-restore backups still run. Non-manual backups are also **throttled to at most once per 60 minutes** so hourly models.dev sync does not copy the whole DB on every pricing write. Dashboard **Settings → Storage** can disable auto-backup independently. | | `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** for `STORAGE_ENCRYPTION_KEY`. Accepted as a fallback when the primary variable is absent. | | `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** (Base64-encoded form) accepted as a fallback. Decoded automatically before use. | | `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(unset)_ | `src/lib/db/core.ts` | Override the periodic SQLite healthcheck interval (ms). When unset, defaults are derived from `NODE_ENV`. | +| `OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS` | `21600000` (6h) | `src/lib/db/core.ts` | Override the periodic `wal_checkpoint(TRUNCATE)` interval (ms). Auto-checkpoint never shrinks the WAL file itself, and a long-running server never closes its DB. `0` disables. | | `OMNIROUTE_SKIP_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts`, `src/lib/db/healthCheck.ts` | Set to `1` to skip the DB healthcheck entirely on startup. Useful for short-lived tasks and integration tests. | | `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | Set to `1` to force the DB healthcheck loop on, even when it would normally be skipped (e.g., short-lived tasks). | | `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | Set to `1` to skip the native-runtime warm-up during `npm install`. Useful in CI/headless installs where sqlite is already built. | @@ -152,6 +153,8 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | | `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows, when running into native binding / bundler-compat incompatibilities, **or on RAM-constrained machines** — Turbopack production builds on this Next.js version line (16.2.x) are known upstream to peak far higher in memory than webpack on large module graphs (Next 16.3's Turbopack memory-eviction fix is not yet stable); webpack fallback peaks much lower. See #6409. | | `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | +| `NOTIFY_SOCKET` | _(unset)_ | systemd (sd_notify protocol) | Set by systemd when the process runs under a service unit with sd_notify integration; OmniRoute reads it (see `OMNIROUTE_DISABLE_SD_NOTIFY`) to send READY/WATCHDOG notifications. Never set by the user. | +| `OMNIROUTE_DISABLE_SD_NOTIFY` | _(unset)_ | `scripts/dev/systemd-notify.mjs` | Set to `1` to disable systemd sd_notify (Type=notify / WatchdogSec=) even when running under a systemd unit. The notifier is a no-op outside systemd regardless. | | `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). | | `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. | | `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. Search providers (SEARCH_VALIDATOR_CONFIGS in `src/lib/providers/validation/searchProviders.ts`, e.g. `tavily-search`) are always excluded from the sweep — their "validation" is a real billed upstream query, so they are never health-checked on a timer (#9970). | @@ -279,6 +282,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | Reload interval (ms) for hot-reloading the payload rules file. Minimum `1000`. | | `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | Opt-in: route bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Explicit provider prefixes still win. Also configurable via a dashboard toggle on the Claude provider page. | | `COMBO_CONCURRENCY_PER_MODEL` | `3` | `open-sse/services/comboConfig.ts` | Per-model concurrency cap for round-robin combos (#9100). The round-robin combo semaphore was hard-capped at 3 concurrent requests per model with no override, serializing higher-concurrency traffic behind that cap. Validated to `>= 1`, clamped to `<= 32`. | +| `DISABLE_CONTEXT_WINDOW_CHECKS` | `false` | `open-sse/handlers/chatCore.ts` | Dangerous opt-in that skips OmniRoute's local context-window / max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits; prompt compression and the model's own output-token cap remain active. Effective precedence is Feature Flags DB override > environment variable > default; no restart is required. | --- @@ -346,6 +350,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. | | `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. | | `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | +| `OMNIROUTE_PROXY_ECHO_URL` | _(unset)_ | `src/lib/proxyEchoTarget.ts` | Pins the echo-IP target used by proxy egress probes to a single URL. Unset, the probe tries `api64.ipify.org` then `api4.ipify.org` so IPv4-only tunnels are not reported dead (#9694). | | `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. | | `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher. Long-lived SSE streams such as Codex `/v1/responses` need more than one connection when several requests share the same account-level proxy. Values above `256` are capped. | | `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 handshake (connect) timeout in ms. Raise it when a single residential gateway host is hit by high concurrency (e.g. 100 simultaneous requests) — the real handshake can exceed 10s under a saturated pool even though the proxy is reachable, which otherwise surfaces as a false `[Proxy Fast-Fail] Proxy unreachable`. Capped at `120000`. | @@ -500,8 +505,10 @@ detection above). | `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. | | `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | `false` | `open-sse/mcp-server/descriptionCompressor.ts` | Compress MCP tool descriptions before serializing the manifest. Enable values: `1`, `true`, `on`. | | `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | Compression algorithm/profile. Disable values: `0`, `false`, `off`. | +| `OMNIROUTE_MCP_FETCH_TIMEOUT_MS` | `10000` | `open-sse/mcp-server/fetchTimeout.ts` | Abort budget (ms) for MCP-server internal management reads (health, resilience, combos, quota, usage). | +| `OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS` | `60000` | `open-sse/mcp-server/fetchTimeout.ts` | Abort budget (ms) for MCP hops that wait on a provider (`route_request`, `web_search`, `web_fetch`). | | `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Model catalog sync interval in hours. | -| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Provider rate-limit and quota polling interval. | +| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/lib/usage/providerLimits.ts` | Provider rate-limit and quota polling interval. | | `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). | | `OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS` | `250` | `open-sse/services/quotaFetchThrottle.ts` | Min interval (ms) between consecutive upstream quota fetches on the per-request preflight/monitor path; spaces concurrent network calls so many accounts on one IP don't burst the upstream. Wired into the Codex (`/wham/usage`), DeepSeek, Bailian (both fetch sites), OpenCode, and Crof quota fetchers (#6009, #6911). The generic `usage.ts::getUsageForProvider` dispatch path (github/glm/minimax/nanogpt/xai/etc.) is not yet covered — tracked separately. Cache hits unaffected. `0` disables; clamped `0..5000`. | | `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | Delay (ms) before refreshing provider limits after a real usage event, giving the upstream quota API time to register consumption. | @@ -728,6 +735,7 @@ REQUEST_TIMEOUT_MS (global override) | `OMNIROUTE_AGENT_GOAL_STREAM_RECOVERY` | `true` | Enable early stream recovery automatically for detected `/goal` agent runs. Set `false`/`0`/`off` to disable the goal-specific opt-in. This can only ADD recovery on top of the operator default — it never overrides an explicit `STREAM_RECOVERY_ENABLED`/DB settings opt-out. | | `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(off)_ | Strip non-standard `codex.*` SSE events (e.g. `codex.rate_limits`) that break the OpenAI SDK's `responses.stream()` with a 502. Set `true`/`1`/`yes` to enable. | | `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. | +| `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS` | `30000` (30s) | Maximum response-start wait (ms) for each direct no-proxy attempt. A timeout retries once on a fresh socket; set `0` to disable the bound and retain the previous behavior. | | `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. | | `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. | @@ -751,6 +759,7 @@ REQUEST_TIMEOUT_MS (global override) | `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | | `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). | | `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_PPLX_SEARCH_HINT` | `0` (off) | Appends "You have built-in web search. Answer questions directly using search results." to the caller's system message (`perplexity-web/protocol.ts`). Off by default — Perplexity searches anyway, and the sentence leaks into replies as meta-commentary for coding clients. Set `1`/`true`/`yes`/`on` to restore. | | `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). | | `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | | `OMNIROUTE_NOTION_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`notionTlsClient.ts`); the `notion-web` executor raises it per-request to `180000` for long generations. | @@ -840,7 +849,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | _auto_ | Runtime V8 heap limit (MB). When unset, calibrated dynamically (~35% of system RAM, clamped to `[512, 4096]`); `512` is only the floor when total memory can't be read. Set explicitly to override. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | _auto_ | **Recommended** Docker/standalone V8 heap limit (MB). When unset, calibrated dynamically (~35% of system RAM, clamped to `[512, 4096]`); `512` is only the floor when total memory can't be read. On `run-standalone.mjs` (Docker CMD), an **explicit** value is appended as `--max-old-space-size` and **wins** over a conflicting NODE_OPTIONS heap flag (V8 last-flag). `omniroute serve` still prefers an existing NODE_OPTIONS heap (#5238). Do not set both to different numbers — the process logs a warn naming both values and the winner. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | @@ -859,6 +868,19 @@ The logging system writes to both stdout and rotated log files. All configuratio ### Memory Engine (plan 21) +### Event-loop cost of memory, skills, and token refresh (#10349) + +OmniRoute is a **single Node process**. Memory extraction/retrieval, skills injection, and provider token refresh run on that **same event loop** as `GET /healthz` and the dashboard. They are not a worker thread. + +| Work | Code | Default | Operator control | +| --- | --- | --- | --- | +| Memory extraction / retrieval | `src/lib/memory/` | Dashboard **memoryEnabled** (default on) | Turn off **Settings → Memory**. There is no separate env kill switch beyond disabling the feature in settings. | +| Skills injection | `src/lib/skills/injection.ts` | Dashboard **skillsEnabled** (default on) | Turn off **Settings → Memory/Skills** (`skillsEnabled`). Sandbox knobs below only bound execution after injection is already on. | +| Token refresh | `src/sse/services/tokenRefresh.ts` | On for connected OAuth/web providers | Disconnect the provider or let tokens stay valid; there is no `TOKEN_REFRESH=0` env today. | + +If `/healthz` is slow on a quiet box, disable memory + skills first, then check catalog/compression load (#10303, #9685). These features yield at `await` points but still compete for the one thread. + + Embedding layer, vector store and reranking knobs for the persistent memory subsystem (`src/lib/memory/`). | Variable | Default | Description | @@ -967,7 +989,7 @@ desktop install. | Variable | Default | Source File | Description | | ----------------------------------- | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MODELS_DEV_SYNC_ENABLED` | _(unset)_ | `src/lib/modelsDevSync.ts` | Hard override for models.dev pricing sync. Unset = honor Settings > AI (`modelsDevSyncEnabled`). `0`/`false`/`off`/`no` **wins over the DB** and skips both periodic sync and `getModelsDevPricing()` SQL/JSON scans (recovery when the dashboard is wedged on the same event loop). `1`/`true`/`on`/`yes` forces sync on. | +| `MODELS_DEV_SYNC_ENABLED` | _(unset)_ | `src/lib/modelsDevSync.ts` | Hard override for models.dev pricing sync. Unset = honor Settings > AI (`modelsDevSyncEnabled`). `0`/`false`/`off`/`no` **wins over the DB** and skips both periodic sync and `getModelsDevPricing()` SQL/JSON scans (recovery when the dashboard is wedged on the same event loop). `1`/`true`/`on`/`yes` forces sync on. Pricing save/clear still call `backupDbFile("pre-write")`, which is no-op under the 60-minute throttle or `DISABLE_SQLITE_AUTO_BACKUP`. | | `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. | | `CONTEXT_WINDOW_RECONCILE_INTERVAL` | `86400` (24h) | `src/lib/contextWindowResolver.ts` | Interval (seconds) for the self-correcting context-window reconciler (5004): pins provider-declared windows from `/models` discovery as `auto:discovery` overrides when they diverge from the catalog. Set to `0` to disable. Reuses already-synced data (no new fetch); never overwrites `manual` overrides. | @@ -1029,7 +1051,10 @@ Anthropic-compatible provider instead. | `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | Cache TTL for failed proxy health probes. Keep this shorter than `PROXY_HEALTH_CACHE_TTL_MS` so transient proxy timeouts under high concurrency retry quickly without disabling fast-fail for truly dead proxies. | | `PROXY_HEALTH_ENABLED` | `true` | `src/lib/proxyHealth/scheduler.ts` | Set `false` to disable the background proxy health scheduler that periodically probes registered proxies. | | `PROXY_HEALTH_INTERVAL_MS` | `600000` | `src/lib/proxyHealth/scheduler.ts` | Background health-scheduler sweep interval in ms (minimum `60000`). | -| `PROXY_HEALTH_TEST_URL` | `https://httpbin.org/ip` | `src/lib/proxyHealth/scheduler.ts` | Reachability probe target used by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Point it at an internal/self-hosted URL to avoid the public default. | +| `PROXY_HEALTH_TEST_URL` | `https://httpbin.org/ip` | `src/lib/proxyHealth/probeTarget.ts` | Reachability probe target used by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Point it at an internal/self-hosted URL to avoid the public default. | +| `PROXY_HEALTH_TEST_CONCURRENCY` | `10` | `src/lib/proxyHealth/probeTarget.ts` | Probes started at once per batch, shared by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Floored at 1 and capped at 50. | +| `PROXY_HEALTH_TEST_STAGGER_MS` | `100` | `src/lib/proxyHealth/probeTarget.ts` | Delay in ms between two probe departures inside a batch. Without it the whole batch leaves at the same moment and a shared egress IP can trip a rate-limited target. Set to `0` to disable the spacing; capped at 5000. | +| `PROXY_HEALTH_USE_PROVIDER_TARGET` | `true` | `src/lib/proxyHealth/providerProbeTarget.ts` | Set "false" to stop probing the real host of a proxy's assigned provider (`GET /models`, no API key) and always use `PROXY_HEALTH_TEST_URL` instead. | | `PROXY_HEALTH_AUTO_DEACTIVATE` | `false` | `src/lib/proxyHealth/statusPolicy.ts` | When `false` (default), automated reachability probes (the scheduler + the `/api/settings/proxies/auto-test` "Test All" button) are **read-only** and never write a proxy's status — only the operator sets active/inactive, so a flaky probe can't strand an assigned proxy (#6246). Set `true` to restore the legacy test-and-set behaviour. | | `PROXY_AUTO_REMOVE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler auto-remove proxies after repeated consecutive failures. | | `PROXY_AUTO_REMOVE_AFTER` | `3` | `src/lib/proxyHealth/scheduler.ts` | Consecutive failures before the scheduler auto-removes a proxy (when `PROXY_AUTO_REMOVE=true`). | @@ -1094,6 +1119,10 @@ changing them requires a code edit, not an env var: | `CURSOR_IMAGE_FETCH_TIMEOUT_MS` | `15000` | `open-sse/utils/cursorImages.ts` | Per-image fetch timeout (ms) for remote `image_url` vision input. | | `CURSOR_STATE_DB_PATH` | _(probed)_ | `open-sse/utils/cursorVersionDetector.ts` | Override the Cursor IDE state DB lookup used for IDE version detection. | | `CURSOR_AGENT_CLI_VERSION` | _(detect / pin)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Agent CLI build id (`YYYY.MM.DD-`) for `x-cursor-client-version: cli-…` on Agent Run. | +| `CURSOR_AGENT_BIN` | _(unset)_ | `open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts` | Path to the Cursor Agent binary used for image generation. Unset, the handler uses `providerSpecificData.agentBin` then PATH. | +| `CURSOR_IMG_TIMEOUT_MS` | `210000` | `open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts` | Per-image wall clock (ms) for Cursor Agent image jobs. | +| `CURSOR_IMG_MAX_CONCURRENT` | `2` | `open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts` | Shared-seat concurrency gate for Cursor image jobs. | +| `CURSOR_IMG_MODEL` | request / `auto` | `open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts` | Override Cursor CLI `--model` for image jobs. | | `CURSOR_DATA_DIR` | _(probed)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Override Cursor Agent CLI data dir (`…/versions/`); same var the official agent uses. | | `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. | | `OMNIROUTE_LOG_REQUEST_SHAPE` | disabled (opt-in via `"1"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads when `"1"` is set. Off by default to reduce log noise. | diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 45eededb28..8b46db649b 100644 --- a/docs/reference/FEATURE_FLAGS.md +++ b/docs/reference/FEATURE_FLAGS.md @@ -76,13 +76,14 @@ used when neither a DB override nor an environment variable is present. | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | boolean | `true` | | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). On by default (local-first); disable for strict public-only blocking. Cloud-metadata stays blocked. | | `ENABLE_CC_COMPATIBLE_PROVIDER` | boolean | `false` | ✓ | Enable Claude Code compatible provider mode. | -### Policies (3) +### Policies (4) | Key | Type | Default | Restart | Description | | ----------------------------------------- | ------- | ---------- | ------- | ---------------------------------------------------------------------- | | `TOOL_POLICY_MODE` | enum | `disabled` | | Tool-use policy enforcement mode. Values: `disabled`, `warn`, `block`. | | `RATE_LIMIT_AUTO_ENABLE` | boolean | `false` | | Automatically enable rate limiting based on usage patterns. | | `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | boolean | `false` | ✓ | Allow multiple connections per compatibility node. | +| `DISABLE_CONTEXT_WINDOW_CHECKS` | boolean | `false` | | Skip OmniRoute's local context-window / max-input-token check for direct single-model requests. Upstream limits still apply. | ### Runtime (11) diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 13869aa651..30eac06ad5 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" version: 3.8.50 -lastUpdated: 2026-08-18 +lastUpdated: 2026-08-20 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-08-18 +> **Last generated:** 2026-08-20 -Total providers: **341**. See category breakdown below. +Total providers: **346**. See category breakdown below. ## Categories @@ -62,8 +62,8 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `clinepass` | `cp` | ClinePass | OAuth | [link](https://cline.bot/cline-pass) | ClinePass is Cline's $9.99/mo subscription bundling 10 open coding models. Sign in with your Cline account (same login as the Cline CLI/IDE), or paste a direct ClinePass API key (app.cline.bot → Settings → API Keys). A ClinePass subscription unlocks the cline-pass/* models. Reuses the Cline WorkOS OAuth flow. | | `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. | | `codex` | `cx` | OpenAI Codex | OAuth | — | — | -| `cursor` | `cu` | Cursor IDE | OAuth | — | — | -| `devin-cli` | `dv` | Devin CLI | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | +| `cursor` | `cu` | Cursor IDE | OAuth, image | — | Image via Agent CLI (`CURSOR_AGENT_BIN`); same seat as chat | +| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | | `devin-desktop` | — | Devin Desktop | OAuth | [link](https://devin.ai) | Paste an existing Devin API key from an authenticated Devin session. Key export availability and steps vary by Devin version and account. | | `ghe-copilot` | `ghe-copilot` | GitHub Enterprise Copilot | OAuth | — | Enter your GHE instance URL (e.g., https://ghe.company.com) in provider settings, then authenticate via device flow. | | `github` | `gh` | GitHub Copilot | OAuth | — | — | @@ -91,7 +91,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `chatgpt-web-codex` | `cgpt-codex` | ChatGPT Web (Codex) | Web cookie | [link](https://chatgpt.com) | Paste the full ChatGPT Cookie header. OmniRoute verifies it in an isolated headless browser profile. | native | | `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | none | | `conol-web` | `cnl` | Conol (Unofficial/Experimental) | Web cookie | [link](https://conol.ai) | Use browser sign-in, or paste the full Cookie header from conol.ai. The __Secure-better-auth.session_token cookie is required. | — | -| `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. | — | +| `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. Optional: store a refresh_token in providerSpecificData.refreshToken (any Microsoft device-code/refresh flow for the substrate.office.com/sydney scopes) and OmniRoute pre-flight-refreshes the access token itself — otherwise re-capture after every ~75 min expiry. | — | | `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste the access_token from an authenticated copilot.microsoft.com request (DevTools → Network → Authorization), or export a HAR while logged in | — | | `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | emulated | | `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | — | @@ -120,7 +120,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) (228) +## API Key Providers (paid / paid-with-free-credits) (231) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -169,6 +169,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | | `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | | `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | +| `cursor-api` | `cua` | Cursor API | API key | [link](https://cursor.com/dashboard/api) | Paste a Cursor user API key (crsr_...) from cursor.com/dashboard/api. OmniRoute exchanges it for a session token on demand; no IDE or cursor-agent install is needed. Usage bills to the Cursor plan that owns the key. | | `dahl` | `dahl` | Dahl | API key | [link](https://inference.dahl.global) | Click 'Add Account' to auto-generate a token, or add a manual API key. | | `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | | `datarobot` | `datarobot` | DataRobot | API key, enterprise | [link](https://docs.datarobot.com) | Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/. | @@ -318,10 +319,12 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `sumopod` | `sumopod` | SumoPod | API key | [link](https://ai.sumopod.com) | Use your SumoPod API key (sk-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. | | `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) | | `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — | +| `tabitoken` | `tabitoken` | TabiToken | API key, aggregator | [link](https://tabitoken.com) | — | | `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com | | `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. | | `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys | | `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | — | +| `token-kiosk` | `tk` | Token Kiosk | API key | [link](https://agent-router.gaib.ai) | Use your Token Kiosk API key in Authorization: Bearer . Fully OpenAI-compatible gateway. API base URL: https://agent-router.gaib.ai/v1. | | `tokenreply` | `tokenreply` | TokenReply | API key, aggregator | [link](https://www.tokenreply.com) | Free-tagged models have model- and campaign-specific daily limits; no fixed global free quota is published. | | `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. | | `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — | @@ -353,7 +356,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zerolimitai` | `zerolimitai` | ZeroLimitAI | API key, aggregator | [link](https://www.zerolimitai.com) | Temporary free trial is advertised, but official pages conflict between 3 and 7 days; a 100-calls/day claim is not treated as permanent. | | `zylo-api` | `zylo` | Zylo API | API key, aggregator | [link](https://zyloai.net) | Basic plan: 10 RPM, 7,200 requests/day and 200,000 tokens/day; limited to Basic text models. | -## Local Providers (12) +## Local Providers (14) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -363,6 +366,8 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. | | `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). | | `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). | +| `mlx-gemma` | `mlx-gemma` | MLX Gemma 26B | Local, self-hosted | [link](https://github.com/ml-explore/mlx) | No API key required. Runs mlx-lm server locally on port 11435. Requires `uv` and `mlx-lm` installed. Model: `mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned` (~15.9GB peak memory). | +| `mlx-qwen` | `mlx-qwen` | MLX Qwen 3.8 27B | Local, self-hosted | [link](https://github.com/ml-explore/mlx) | No API key required. Runs mlx-lm server locally on port 11436. Requires `uv` and `mlx-lm` installed. Model: `maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw` (~13.1GB peak memory). | | `ollama-local` | `ollama` | Ollama | Local, self-hosted | [link](https://ollama.com) | No API key required. Ollama runs locally — configure its OpenAI-compatible base URL (default: http://localhost:11434/v1) and make sure Ollama is running before connecting. | | `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). | | `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). | diff --git a/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index 7a0b387191..bad9aa43dd 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -159,6 +159,28 @@ enumerating every existing combo that shadows a model id, so operators who hit this by accident (rather than intentionally, per #6940) have a signal. The detection helper lives in `src/lib/combos/modelNameCollision.ts`. +## Calling a Custom Combo From a Client + +Persisted combos (Settings → Combos) are only used when the client sends the combo's **exact name** in the `model` field — there is no fuzzy or partial matching of the combo name, and no `auto/` prefix involved. Resolution order (`getComboForModel()` in `src/sse/services/model.ts`): + +1. exact combo-name match (`model: "my-combo"`), +2. `combo/` prefix (`model: "combo/my-combo"`), +3. model→combo glob mappings (`/api/model-combo-mappings`). + +```bash +curl -X POST http://localhost:20128/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"my-combo","messages":[{"role":"user","content":"Hello"}]}' +``` + +Two common pitfalls: + +- **`auto` does not use your combos.** `auto`/`auto/*` builds its own zero-config candidate pool and only consults persisted combos if a combo is literally named `auto` (not recommended). To route through a combo, send its exact name — not `auto`. +- **`openrouter/auto` is a real paid OpenRouter product** ("Auto Best Available"), not an OmniRoute alias. It is the single static model entry of the OpenRouter registry (`open-sse/config/providers/registry/openrouter/index.ts`) and is billed separately. Use Settings → Routing → Hide paid models to exclude it from `auto` pools. + +See [#7992](https://github.com/diegosouzapw/OmniRoute/issues/7992) and [#7111](https://github.com/diegosouzapw/OmniRoute/issues/7111) for the original confusion this documents. + ## How It Works (Persisted Auto-Combos) The Auto-Combo Engine dynamically selects the best provider/model for each request using a **14-factor scoring function** (defined in `open-sse/services/autoCombo/scoring.ts` → `DEFAULT_WEIGHTS`). Weights form a normalized distribution (custom weights are renormalized by `normalizeScoringWeights()`). diff --git a/docs/security/BAN_DETECTION.md b/docs/security/BAN_DETECTION.md index 4f72fe7708..faf585267e 100644 --- a/docs/security/BAN_DETECTION.md +++ b/docs/security/BAN_DETECTION.md @@ -38,7 +38,7 @@ this service has been disabled in this account (Antigravity) > copy is `ACCOUNT_DEACTIVATED_SIGNALS` in `open-sse/services/accountFallback.ts`; > treat the block above as a snapshot. -Two adjacent, **separate** signal tables live in the same file and are *not* part +Two adjacent, **separate** signal tables live in the same file and are _not_ part of banned-keyword detection: - `CREDITS_EXHAUSTED_SIGNALS` — billing/quota depleted (`insufficient_quota`, @@ -70,7 +70,7 @@ upstream error response narrower **`deactivated`** label (`isActive=false` when the connection has no spare API keys) is written by the inline `chatCore.ts` path on **HTTP 401 / 403** (classified via `classifyProviderError` → `ACCOUNT_DEACTIVATED`). Note the - `markAccountUnavailable()` path writes a *different* terminal status — + `markAccountUnavailable()` path writes a _different_ terminal status — **`expired`** — for the same `ACCOUNT_DEACTIVATED` signal (via `resolveTerminalConnectionStatus`), so the same ban can surface as either `deactivated` or `expired` depending on which path handled the response. (The @@ -86,7 +86,7 @@ every failed upstream request flows through — it is **not** gated to OAuth/subscription scrapers. The resulting terminal state is per **connection**, not per provider. -That said, the built-in *strings* are oriented toward subscription/OAuth +That said, the built-in _strings_ are oriented toward subscription/OAuth providers with real ban risk (ChatGPT Web, Claude Web, Codex, Muse Spark, Antigravity). An API-key provider will only trip the detector if its error body literally contains one of the substrings. @@ -136,14 +136,62 @@ There is no separate "clear ban flag" button — recovery is re-test, re-auth, o re-enable, matching the general terminal-state rule in [RESILIENCE_GUIDE](../architecture/RESILIENCE_GUIDE.md). +## Probe isolation (model test-all) + +A **probe-origin failure** (model test-all / health-check dispatches executed +inside `runAsProbe`) never removes a connection from the pool (#9817): it is +**recorded for visibility** (`last_error`, `last_error_type`, `error_code`, +`last_error_at`) but skips **every** routing mutation — cooldowns, terminal +status (`banned` / `deactivated` / `credits_exhausted`), per-model lockouts, +the provider circuit breaker, the 5-minute quota cache, OAuth token refresh +and auto-disable. Only a real request-path failure deactivates. The recorded +error is what makes a flagged account visible in the dashboard while it stays +serving traffic. + +The single decision point is `shouldIsolateProbeFailures()` +(`src/shared/utils/probeOrigin.ts`), consulted by **every** site that could +mutate routing state from a probe-origin failure: + +- `markAccountUnavailable` (`auth.ts`) — record-only (`lastError` raw text, + `lastErrorType`, `errorCode`, `lastErrorAt`; deliberately **no** + `backoffLevel`, which would trigger the selection-time auto-decay and wipe + the record) +- `maybeAutoDisableBannedAccount` — no auto-disable +- `chatCore` — FORBIDDEN, ACCOUNT_DEACTIVATED, QUOTA_EXHAUSTED (record-only, + no terminal `credits_exhausted`), GEO_BLOCKED (no 24h exclusion), + MODEL_NOT_FOUND (no `lockModel`), the codex 429 account-rotation failover + (no `markCodexScopeRateLimited`, no persisted `rate_limited_until`, no + session-affinity clear), `persistCodexQuotaState` (no quota-state write, + no cache invalidation), `recordKeyHealthStatus` (key-health rotator + untouched) +- OAuth refresh — both the proactive refresh in the executor base + (`base.ts` `execute()`, no refresh-token rotation consumed) and the + reactive 401/403 path in `chatCore` (no `expired` deactivation) +- `chat.ts` — provider circuit breaker and the 5-minute quota cache + (`markAccountExhaustedFrom429`) never degraded + +The recorded error is what makes a flagged account visible in the dashboard +while it stays serving traffic. Note: the probe record stores the **raw** +(unsliced) error text, unlike the real path's `slice(0,100)` truncation. + +Operators who use test-all as a maintenance tool can restore the historical +behavior (probe counts as a real generation) via either: + +- the `probeCanDisable` setting (`POST /api/settings` with + `{"probeCanDisable": true}`, or a direct `key_value` DB edit), or +- feature flag **`PROBE_CAN_DISABLE=true`** (env or DB override; wins over the + setting). + +Fail-safe: if the flag or settings lookup throws, isolation stays ON. + ## Source files -| Concern | File | -| --- | --- | -| Signal tables + match | `open-sse/services/accountFallback.ts` | -| Terminalization / persistence | `src/sse/services/auth.ts` (`markAccountUnavailable`, `resolveTerminalConnectionStatus`, `clearAccountError`) | -| Auto-disable scope | `src/shared/utils/autoDisableBanned.ts`, `src/sse/services/autoDisableBannedAccount.ts` | -| Inline classification | `open-sse/handlers/chatCore.ts`, `open-sse/services/errorClassifier.ts` | -| Terminal-state recovery exclusion | `src/lib/quota/connectionRecovery.ts` | -| Custom-keyword runtime load | `src/lib/config/runtimeSettings.ts` (`setCustomBannedSignals`) | -| Settings UI | `src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx` | +| Concern | File | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Signal tables + match | `open-sse/services/accountFallback.ts` | +| Terminalization / persistence | `src/sse/services/auth.ts` (`markAccountUnavailable`, `resolveTerminalConnectionStatus`, `clearAccountError`) | +| Auto-disable scope | `src/shared/utils/autoDisableBanned.ts`, `src/sse/services/autoDisableBannedAccount.ts` | +| Inline classification | `open-sse/handlers/chatCore.ts`, `open-sse/services/errorClassifier.ts` | +| Terminal-state recovery exclusion | `src/lib/quota/connectionRecovery.ts` | +| Custom-keyword runtime load | `src/lib/config/runtimeSettings.ts` (`setCustomBannedSignals`) | +| Settings UI | `src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx` | diff --git a/docs/security/CLI_TOKEN.md b/docs/security/CLI_TOKEN.md index 1e00e22334..4d3383229d 100644 --- a/docs/security/CLI_TOKEN.md +++ b/docs/security/CLI_TOKEN.md @@ -20,21 +20,26 @@ password on every invocation. (falls back to an empty string on failure, disabling CLI auth). 2. It computes `HMAC-SHA256(machine_id, salt)` and returns the full 64-char hex digest — a deterministic, non-reversible token tied to this machine. -3. The CLI sends the token as `x-omniroute-cli-token` on every request to - `http://localhost:/api/...`. +3. The CLI sends the token as `x-omniroute-cli-token` only when the resolved + destination is an explicit loopback URL (`localhost`, `127.0.0.0/8`, or + loopback IPv6). Requests carrying the token use `redirect: error`, so a local + redirect cannot forward it to another origin. Remote contexts use scoped + access tokens instead. If derivation is unavailable, the CLI omits the header + and `omniroute doctor` reports the failure instead of treating an empty token + as valid. 4. The server (`src/server/authz/policies/management.ts`) recomputes the expected token with the same salt and compares via `timingSafeEqual` to prevent timing-based extraction. ## Security properties -| Property | Detail | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| **Loopback-only** | Accepted only when `Host` is `localhost`, `127.0.0.1`, or `::1`. | -| **Constant-time compare** | `crypto.timingSafeEqual` prevents timing attacks. | -| **Non-reversible** | HMAC output cannot recover the machine-id. | -| **No `always`-protected bypass** | `isAlwaysProtectedPath()` is evaluated before the CLI token check. `/api/shutdown` and `/api/settings/database` always require JWT. | -| **Non-exportable** | Token is never written to disk or logged. | +| Property | Detail | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Loopback-only** | Accepted only when the server's trusted peer-locality stamp (derived from the real TCP peer address) says loopback. The client-controlled `Host` header is never trusted for locality. | +| **Constant-time compare** | `crypto.timingSafeEqual` prevents timing attacks. | +| **Non-reversible** | HMAC output cannot recover the machine-id. | +| **No `always`-protected bypass** | `isAlwaysProtectedPath()` is evaluated before the CLI token check. `/api/shutdown` and `/api/settings/database` always require JWT. | +| **Non-exportable** | Token is never written to disk or logged. | ## Salt rotation diff --git a/docs/security/COMPLIANCE.md b/docs/security/COMPLIANCE.md index 738c6277ba..30ef65a15d 100644 --- a/docs/security/COMPLIANCE.md +++ b/docs/security/COMPLIANCE.md @@ -114,7 +114,7 @@ Two separate retention windows are honoured: | `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Row-cap trim for `proxy_logs` | `cleanupExpiredLogs()` runs the retention pass. It is invoked on server startup -from `src/server-init.ts` and `src/instrumentation-node.ts`. Each run logs a +from `src/instrumentation-node.ts`. Each run logs a `compliance.cleanup` audit event with the per-table delete counts. Proxy/call log trimming is batched (`BATCH_SIZE = 5000`) to avoid long write locks. diff --git a/electron/lib/remoteServerPreferences.js b/electron/lib/remoteServerPreferences.js index 21b683290f..71425e37b9 100644 --- a/electron/lib/remoteServerPreferences.js +++ b/electron/lib/remoteServerPreferences.js @@ -5,8 +5,8 @@ const path = require("path"); /** * remoteServerPreferences.js — pure read/write helpers for the small JSON - * preferences file that persists the operator-configured remote server URL - * across app restarts (see resolveRemoteServerUrl.js for how it's consumed). + * preferences file that persists desktop-shell choices needed before the + * server-owned settings database is available. * * Deliberately a plain flat JSON file rather than the app's SQLite database: * this preference must be readable before deciding whether to spawn (or even @@ -18,19 +18,20 @@ const path = require("path"); * @param {string} prefsPath - absolute path to electron-preferences.json * @param {(p: string) => boolean} [existsSync] * @param {(p: string, enc: string) => string} [readFileSync] - * @returns {{remoteServerUrl: string|null}} + * @returns {{remoteServerUrl: string|null, closeBehavior: "keep-loaded"|"unload"}} */ function readPreferences(prefsPath, existsSync = fs.existsSync, readFileSync = fs.readFileSync) { - if (!existsSync(prefsPath)) return { remoteServerUrl: null }; + if (!existsSync(prefsPath)) return { remoteServerUrl: null, closeBehavior: "keep-loaded" }; try { const parsed = JSON.parse(readFileSync(prefsPath, "utf8")); const remoteServerUrl = typeof parsed.remoteServerUrl === "string" && parsed.remoteServerUrl.trim() ? parsed.remoteServerUrl.trim() : null; - return { remoteServerUrl }; + const closeBehavior = parsed.closeBehavior === "unload" ? "unload" : "keep-loaded"; + return { remoteServerUrl, closeBehavior }; } catch { - return { remoteServerUrl: null }; + return { remoteServerUrl: null, closeBehavior: "keep-loaded" }; } } @@ -72,4 +73,35 @@ function writeRemoteServerUrl( } } -module.exports = { readPreferences, writeRemoteServerUrl }; +/** Persist whether closing the dashboard hides it or unloads its renderer. */ +function writeCloseBehavior( + prefsPath, + closeBehavior, + { + existsSync = fs.existsSync, + readFileSync = fs.readFileSync, + writeFileSync = fs.writeFileSync, + mkdirSync = fs.mkdirSync, + } = {} +) { + try { + const dir = path.dirname(prefsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const current = readPreferences(prefsPath, existsSync, readFileSync); + const next = { + ...current, + closeBehavior: closeBehavior === "unload" ? "unload" : "keep-loaded", + }; + writeFileSync(prefsPath, JSON.stringify(next, null, 2) + "\n", "utf8"); + } catch (err) { + console.error( + `[remoteServerPreferences] Failed to write preferences to ${prefsPath}:`, + err instanceof Error ? err.message : String(err) + ); + } +} + +module.exports = { readPreferences, writeRemoteServerUrl, writeCloseBehavior }; diff --git a/electron/lib/windowClosePolicy.js b/electron/lib/windowClosePolicy.js new file mode 100644 index 0000000000..989e380d41 --- /dev/null +++ b/electron/lib/windowClosePolicy.js @@ -0,0 +1,26 @@ +"use strict"; + +const CLOSE_BEHAVIOR_KEEP_LOADED = "keep-loaded"; +const CLOSE_BEHAVIOR_UNLOAD = "unload"; + +function normalizeCloseBehavior(value) { + if (value === CLOSE_BEHAVIOR_KEEP_LOADED || value === CLOSE_BEHAVIOR_UNLOAD) return value; + return null; +} + +function resolveRendererUrl(currentUrl, serverUrl) { + try { + const current = new URL(currentUrl); + const server = new URL(serverUrl); + return current.origin === server.origin ? current.href : server.href; + } catch { + return serverUrl; + } +} + +module.exports = { + CLOSE_BEHAVIOR_KEEP_LOADED, + CLOSE_BEHAVIOR_UNLOAD, + normalizeCloseBehavior, + resolveRendererUrl, +}; diff --git a/electron/lib/windowLifecycle.js b/electron/lib/windowLifecycle.js new file mode 100644 index 0000000000..8a75a4a18d --- /dev/null +++ b/electron/lib/windowLifecycle.js @@ -0,0 +1,28 @@ +/** 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 + ); +} + +function showOrCreateWindow({ appReady, getWindow, createWindow }) { + if (!appReady) return null; + + const currentWindow = getWindow(); + if (!currentWindow || currentWindow.isDestroyed()) { + return createWindow(); + } + + if (currentWindow.isMinimized()) currentWindow.restore(); + currentWindow.show(); + currentWindow.focus(); + return currentWindow; +} + +module.exports = { + shouldStartHidden, + showOrCreateWindow, +}; diff --git a/electron/main.js b/electron/main.js index f052188ff3..19f232226b 100644 --- a/electron/main.js +++ b/electron/main.js @@ -38,8 +38,19 @@ const { killProcessTree } = require("./processTree"); const { resolveServerEntry } = require("./lib/resolveServerEntry"); const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper"); const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl"); -const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences"); +const { + readPreferences, + writeRemoteServerUrl, + writeCloseBehavior, +} = require("./lib/remoteServerPreferences"); const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness"); +const { shouldStartHidden, showOrCreateWindow } = require("./lib/windowLifecycle"); +const { + CLOSE_BEHAVIOR_KEEP_LOADED, + CLOSE_BEHAVIOR_UNLOAD, + normalizeCloseBehavior, + resolveRendererUrl, +} = require("./lib/windowClosePolicy"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -49,11 +60,12 @@ if (!gotTheLock) { } app.on("second-instance", () => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); - } + const isHeadless = + process.argv.includes("--headless") || + process.argv.includes("--cli") || + process.env.OMNIROUTE_HEADLESS === "true"; + if (isHeadless) return; + showMainWindow(); }); // ── Environment Detection ────────────────────────────────── @@ -71,6 +83,8 @@ let nextServer = null; let serverPort = 20128; let isServerStopped = false; let remoteServerPromptWindow = null; +let keepAliveWithoutWindows = false; +let lastRendererUrl = null; // ── Remote Server Mode ────────────────────────────────────── // Lets the desktop shell attach to an already-running OmniRoute server (e.g. a @@ -81,6 +95,8 @@ const REMOTE_SERVER_PREFS_PATH = path.join( resolveDataDir(null, process.env), "electron-preferences.json" ); +const electronPreferences = readPreferences(REMOTE_SERVER_PREFS_PATH); +let closeBehavior = electronPreferences.closeBehavior; let remoteServerUrl = resolveRemoteServerUrl({ env: process.env, prefsPath: REMOTE_SERVER_PREFS_PATH, @@ -365,14 +381,18 @@ function setupContentSecurityPolicy() { } // ── Create Window ────────────────────────────────────────── -function createWindow() { +function createWindow({ showWhenReady = true } = {}) { + if (mainWindow && !mainWindow.isDestroyed()) return mainWindow; + + const rendererStartedAt = Date.now(); + // Platform-conditional options (#9) const platformWindowOptions = process.platform === "darwin" ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } } : { titleBarStyle: "default" }; - mainWindow = new BrowserWindow({ + const window = new BrowserWindow({ width: 1400, height: 900, minWidth: 1024, @@ -390,28 +410,28 @@ function createWindow() { backgroundColor: "#0a0a0a", ...platformWindowOptions, }); + mainWindow = window; // Load the Next.js app - mainWindow.loadURL(getServerUrl()); + window.loadURL(resolveRendererUrl(lastRendererUrl, getServerUrl())); if (isDev) { - mainWindow.webContents.openDevTools({ mode: "detach" }); + window.webContents.openDevTools({ mode: "detach" }); } - // Show window when ready (unless starting minimized/hidden in tray) - mainWindow.once("ready-to-show", () => { - const startHidden = - process.argv.includes("--hidden") || - process.argv.includes("--minimized") || - app.getLoginItemSettings().wasOpenedAsHidden; - if (!startHidden) { - mainWindow.show(); + // Hidden startup (createWindow({ showWhenReady: false })) skips the initial + // show(); the window stays created (so tray/dock interactions work) but the + // renderer only becomes visible on the next explicit showMainWindow() call. + window.once("ready-to-show", () => { + console.log(`[Electron] Renderer ready in ${Date.now() - rendererStartedAt}ms`); + if (showWhenReady) { + window.show(); } else { console.log("[Electron] Launched hidden in background tray"); } }); // Handle external links — validate URL protocol to prevent RCE - mainWindow.webContents.setWindowOpenHandler(({ url }) => { + window.webContents.setWindowOpenHandler(({ url }) => { try { const parsedUrl = new URL(url); if (["http:", "https:"].includes(parsedUrl.protocol)) { @@ -425,18 +445,44 @@ function createWindow() { return { action: "deny" }; }); - // Handle window close — minimize to tray - mainWindow.on("close", (event) => { + // Keep the server alive while either hiding the renderer for a fast reopen or + // unloading it to reclaim memory, according to the persisted tray preference. + window.on("close", (event) => { if (!app.isQuitting) { event.preventDefault(); - mainWindow.hide(); + lastRendererUrl = resolveRendererUrl(window.webContents.getURL(), getServerUrl()); + if (closeBehavior === CLOSE_BEHAVIOR_UNLOAD) { + console.log("[Electron] Dashboard renderer unloaded; server remains running"); + window.destroy(); + } else { + console.log("[Electron] Dashboard hidden; renderer kept loaded"); + window.hide(); + } } return false; }); - mainWindow.on("closed", () => { - mainWindow = null; + window.on("closed", () => { + if (mainWindow === window) mainWindow = null; }); + + return window; +} + +function showMainWindow() { + return showOrCreateWindow({ + appReady: app.isReady(), + getWindow: () => mainWindow, + createWindow, + }); +} + +function setCloseBehavior(nextBehavior) { + const normalized = normalizeCloseBehavior(nextBehavior); + if (!normalized || normalized === closeBehavior) return; + closeBehavior = normalized; + writeCloseBehavior(REMOTE_SERVER_PREFS_PATH, closeBehavior); + createTray(); } // ── System Tray ──────────────────────────────────────────── @@ -465,12 +511,7 @@ function createTray() { const contextMenu = Menu.buildFromTemplate([ { label: "Open OmniRoute", - click: () => { - if (mainWindow) { - mainWindow.show(); - mainWindow.focus(); - } - }, + click: () => showMainWindow(), }, { label: "Open Dashboard", @@ -504,6 +545,23 @@ function createTray() { }, ], }, + { + label: "When Dashboard Closes", + submenu: [ + { + label: "Keep Loaded (Faster Reopen)", + type: "radio", + checked: closeBehavior === CLOSE_BEHAVIOR_KEEP_LOADED, + click: () => setCloseBehavior(CLOSE_BEHAVIOR_KEEP_LOADED), + }, + { + label: "Unload Renderer (Lower Memory)", + type: "radio", + checked: closeBehavior === CLOSE_BEHAVIOR_UNLOAD, + click: () => setCloseBehavior(CLOSE_BEHAVIOR_UNLOAD), + }, + ], + }, { type: "separator" }, { label: "Check for Updates", @@ -522,12 +580,7 @@ function createTray() { tray.setToolTip("OmniRoute"); tray.setContextMenu(contextMenu); - tray.on("double-click", () => { - if (mainWindow) { - mainWindow.show(); - mainWindow.focus(); - } - }); + tray.on("double-click", () => showMainWindow()); } // ── Change Port (#3: now restarts server) ────────────────── @@ -549,6 +602,7 @@ async function changePort(newPort) { await waitForServer(getServerReadinessUrl()); // Reload window and update tray + lastRendererUrl = getServerUrl(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL(getServerUrl()); } @@ -613,6 +667,7 @@ async function setRemoteServerUrl(nextUrl) { remoteServerUrl = normalized; writeRemoteServerUrl(REMOTE_SERVER_PREFS_PATH, remoteServerUrl); + lastRendererUrl = getServerUrl(); startNextServer(); try { @@ -781,6 +836,14 @@ function startNextServer() { ...serverEnv, DATA_DIR: dataDir, PORT: String(serverPort), + // Pin the embedded server to loopback. Next.js standalone binds to + // `process.env.HOSTNAME || '0.0.0.0'`, and Windows always exports + // HOSTNAME as the machine name — which resolves to the LAN address, so + // the server listens only there and 127.0.0.1 stays closed. The renderer + // then fails to load `http://localhost:`, "ready-to-show" never + // fires, and the window (created with `show: false`) is never shown. + // Mirrors scripts/dev/run-next-playwright.mjs, which already pins this. + HOSTNAME: "127.0.0.1", NODE_ENV: "production", ELECTRON_RUN_AS_NODE: "1", NODE_PATH: resolveServerNodePath(serverEnv, resolvePackNodePaths(dataDir)), @@ -1086,9 +1149,20 @@ app.whenReady().then(async () => { process.argv.includes("--headless") || process.argv.includes("--cli") || process.env.OMNIROUTE_HEADLESS === "true"; + const startHidden = + !isHeadless && + shouldStartHidden({ + argv: process.argv, + loginItemSettings: app.getLoginItemSettings(), + }); + keepAliveWithoutWindows = startHidden; // Fix #1: Start server and WAIT for readiness before showing window startNextServer(); + if (!isHeadless) { + createTray(); + } + let serverReady = true; if (!isDev) { // Probe the lightweight auth-exempt endpoint instead of aggregating full monitoring state. @@ -1097,9 +1171,10 @@ app.whenReady().then(async () => { if (isHeadless) { console.log("[Electron] Headless mode active — UI window and tray icon skipped"); + } else if (startHidden) { + console.log("[Electron] Launched hidden in background tray without a renderer"); } else { - createWindow(); - createTray(); + showMainWindow(); } setupIpcHandlers(); @@ -1107,7 +1182,7 @@ app.whenReady().then(async () => { // If readiness timed out (e.g. very long first-launch migrations), don't leave the // window stuck on a hanging connection — keep polling and reload once it responds (#2460). - if (!isDev && !serverReady && !isHeadless) { + if (!isDev && !serverReady && !isHeadless && !startHidden) { void waitForServer(getServerReadinessUrl(), 300000).then((ready) => { if (ready && mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL(getServerUrl()); @@ -1125,11 +1200,7 @@ app.whenReady().then(async () => { // macOS: recreate window when dock icon clicked app.on("activate", () => { if (isHeadless) return; - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } else if (mainWindow) { - mainWindow.show(); - } + showMainWindow(); }); }); @@ -1139,7 +1210,12 @@ app.on("window-all-closed", () => { process.argv.includes("--headless") || process.argv.includes("--cli") || process.env.OMNIROUTE_HEADLESS === "true"; - if (process.platform !== "darwin" && !isHeadless) { + if ( + process.platform !== "darwin" && + !isHeadless && + !keepAliveWithoutWindows && + closeBehavior !== CLOSE_BEHAVIOR_UNLOAD + ) { app.quit(); } }); diff --git a/electron/package.json b/electron/package.json index a3793de8fa..57789a4318 100644 --- a/electron/package.json +++ b/electron/package.json @@ -64,9 +64,11 @@ "remoteServerPromptRenderer.js", "lib/resolveServerEntry.js", "lib/resolveNodeHelper.js", + "lib/windowLifecycle.js", "lib/resolveRemoteServerUrl.js", "lib/remoteServerPreferences.js", "lib/serverReadiness.js", + "lib/windowClosePolicy.js", "assets/remoteServerPrompt.html", "package.json", "node_modules/**/*" diff --git a/examples/quickstart/python_requests.py b/examples/quickstart/python_requests.py index ab838d8eb7..a27c7b38b0 100644 --- a/examples/quickstart/python_requests.py +++ b/examples/quickstart/python_requests.py @@ -26,3 +26,8 @@ data = { response = requests.post(API_URL, headers=headers, json=data) response.raise_for_status() print(response.json()["choices"][0]["message"]["content"]) + +# Fresh install, zero credentials — `auto` already works: +# curl http://localhost:20128/v1/chat/completions \ +# -H "Content-Type: application/json" \ +# -d '{"model":"auto","messages":[{"role":"user","content":"Hello!"}]}' diff --git a/llm.txt b/llm.txt index 92016efd1c..da0da5334e 100644 --- a/llm.txt +++ b/llm.txt @@ -1,6 +1,6 @@ # OmniRoute -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -14,7 +14,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 154 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -165,7 +165,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **346 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -434,7 +434,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 154 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -475,7 +475,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/next.config.mjs b/next.config.mjs index 2f22b8aebd..df3f6e32c4 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -438,6 +438,11 @@ const nextConfig = { destination: "/dashboard/omni-skills", permanent: true, }, + { + source: "/dashboard/providers/freepik", + destination: "/dashboard/providers/magnific", + permanent: true, + }, // Architecture { source: "/docs/architecture", diff --git a/open-sse/config/agyModels.ts b/open-sse/config/agyModels.ts index 412e073044..5e9f37b84e 100644 --- a/open-sse/config/agyModels.ts +++ b/open-sse/config/agyModels.ts @@ -41,6 +41,15 @@ export const AGY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, + { + id: "gemini-3.7-flash-tiered", + name: "Gemini 3.7 Flash (Tiered)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, // Gemini 3.1 Pro { id: "gemini-pro-agent", diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 5eab3f4c8e..3946b776d0 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -29,6 +29,15 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, + { + id: "gemini-3.7-flash-tiered", + name: "Gemini 3.7 Flash (Tiered)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, // Gemini 3.1 Pro budget tiers. Live streamGenerateContent validation uses // `gemini-pro-agent` for High; the separately advertised `gemini-3.1-pro-high` // discovery slot currently returns HTTP 400 and is intentionally not public. @@ -91,6 +100,13 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ ]); export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ + // Gemini 3.7 Flash tiers map to the upstream tiered endpoint model; the thinking + // budget is steered via generationConfig.thinkingConfig.thinkingBudget. + "gemini-3.7-flash": "gemini-3.7-flash-tiered", + "gemini-3.7-flash-high": "gemini-3.7-flash-tiered", + "gemini-3.7-flash-medium": "gemini-3.7-flash-tiered", + "gemini-3.7-flash-low": "gemini-3.7-flash-tiered", + "gpt-oss-120b": "gpt-oss-120b-medium", // gemini-3.1-pro-low is not aliased: the upstream accepts it verbatim. // gemini-3.1-pro-high: the discovery slot returns HTTP 400 on v1internal; // the live upstream id is gemini-pro-agent (see ANTIGRAVITY_PUBLIC_MODELS). diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts index 0cb1b05772..5c71e931a5 100644 --- a/open-sse/config/codexClient.ts +++ b/open-sse/config/codexClient.ts @@ -1,9 +1,13 @@ import { + CODEX_CLI_RS_ORIGINATOR, DEFAULT_CODEX_CLIENT_VERSION, getCodexCliRsHeaders as buildCodexCliRsHeaders, } from "@/shared/constants/codexClient"; -export { DEFAULT_CODEX_CLIENT_VERSION } from "@/shared/constants/codexClient"; +export { + DEFAULT_CODEX_CLIENT_VERSION, + CODEX_CLI_RS_ORIGINATOR, +} from "@/shared/constants/codexClient"; const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200"; const DEFAULT_CODEX_USER_AGENT_ARCH = "x64"; const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION"; @@ -51,6 +55,35 @@ export function getCodexCliRsHeaders(): Record { return buildCodexCliRsHeaders(getCodexClientVersion()); } +/** + * Identity for the credential face (auth.openai.com: token exchange / refresh). + * The real Codex client sends only `originator` + `User-Agent` on that face + * (codex-rs login/default_client.rs default_headers()); the `Version` header + * gate exists only on the chatgpt.com/backend-api inference face, so it is + * deliberately omitted here. Mirrors sub2api v0.1.178 + * ApplyCodexCanonicalAuthIdentity. + */ +export function getCodexAuthIdentityHeaders(): Record { + return { + "User-Agent": getCodexUserAgent(), + originator: CODEX_CLI_RS_ORIGINATOR, + }; +} + +/** + * Canonical Codex CLI identity for server-initiated calls against the + * chatgpt.com/backend-api face that are not tied to one end-client request + * (usage / quota / models manifest / reset-credits). Same UA/version chain as + * inference so these calls do not show up upstream as anonymous half-identities. + */ +export function getCodexBackendIdentityHeaders(): Record { + return { + "User-Agent": getCodexUserAgent(), + originator: CODEX_CLI_RS_ORIGINATOR, + Version: getCodexClientVersion(), + }; +} + export function normalizeCodexSessionId(value: unknown): string | null { if (typeof value !== "string") return null; const normalized = value.trim(); diff --git a/open-sse/config/codexIdentity.ts b/open-sse/config/codexIdentity.ts index bc45fa1cf6..a081c5402b 100644 --- a/open-sse/config/codexIdentity.ts +++ b/open-sse/config/codexIdentity.ts @@ -1,15 +1,30 @@ import { createHash, randomUUID } from "node:crypto"; import { normalizeCodexSessionId } from "./codexClient.ts"; +import { isCrossAccountCodexTurnState, readCodexTurnStateHeader } from "./codexTurnState.ts"; const CODEX_INSTALLATION_SALT = "omniroute-codex-installation"; const CODEX_SESSION_SEED_PREFIX = "omniroute:codex-session-id:v1:"; const CODEX_THREAD_SEED_PREFIX = "omniroute:codex-thread-id:v1:"; +// v2 derivations are keyed by the persisted per-connection random seed +// (codexFingerprintSeed) instead of the connection-id chain, mirroring +// sub2api v0.1.178 (#5696): deterministic derivation stays stable, but the +// seed is generated per connection so identities never collide across +// deployments and survive connection export/import. +const CODEX_INSTALLATION_SEED_PREFIX_V2 = "omniroute:codex-installation:v2:"; +const CODEX_SESSION_SEED_PREFIX_V2 = "omniroute:codex-session-id:v2:"; +const CODEX_THREAD_SEED_PREFIX_V2 = "omniroute:codex-thread-id:v2:"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export const CODEX_FINGERPRINT_MODES = ["off", "device", "session", "full"] as const; export type CodexFingerprintMode = (typeof CODEX_FINGERPRINT_MODES)[number]; export const CODEX_FINGERPRINT_MODE_KEY = "codexFingerprintMode"; +/** + * System-managed per-connection random seed used as the fingerprint + * derivation source. Never sent upstream, stripped from API responses, and + * preserved across connection updates (sub2api `codex_fingerprint_seed`). + */ +export const CODEX_FINGERPRINT_SEED_KEY = "codexFingerprintSeed"; export type CodexClientIdentity = { mode: CodexFingerprintMode; @@ -72,6 +87,61 @@ function accountSeed( ); } +/** The persisted system-managed random seed, when present and a valid UUID. */ +export function getCodexFingerprintSeed( + providerSpecificData?: Record | null +): string | null { + return normalizeUuid(providerSpecificData?.[CODEX_FINGERPRINT_SEED_KEY]); +} + +/** Modes that rewrite account-scoped identifiers and therefore need a stable seed. */ +export function codexFingerprintModeRequiresSeed(mode: CodexFingerprintMode): boolean { + return mode === "device" || mode === "session" || mode === "full"; +} + +/** + * Ensure a Codex OAuth connection carries a persisted fingerprint seed when its + * convergence mode derives account-scoped identifiers. Called at connection + * create/update time (the persistence layer owns the write); the request path + * only ever READS the seed, so an identity never rotates mid-flight. + * + * Semantics mirror sub2api v0.1.178 `prepareCodexFingerprintExtraFor{Create,Update}`: + * - the key is system-managed: any client-supplied value is stripped first; + * - an existing valid seed is ALWAYS carried forward (even when the new mode + * is `off` — it stays dormant, ready if convergence is re-enabled later); + * - otherwise a fresh seed is created only when the mode requires one + * (device/session/full; the OmniRoute default is session). + * + * Returns the (possibly new) providerSpecificData, or undefined when there is + * nothing to store. Pre-seed connections keep their legacy connection-id + * derived identity until the next save — one deliberate rotation, same as + * sub2api's migration-225 backfill. + */ +export function ensureCodexFingerprintSeed( + providerSpecificData?: Record | null, + credentials?: { accessToken?: unknown; refreshToken?: unknown } | null, + existingProviderSpecificData?: Record | null +): Record | undefined { + const psd: Record = { ...(providerSpecificData || {}) }; + // System-managed key: never trust an inbound value, regardless of auth type. + delete psd[CODEX_FINGERPRINT_SEED_KEY]; + if (!isCodexOAuthCredentials(credentials)) { + return Object.keys(psd).length > 0 ? psd : undefined; + } + + const existingSeed = getCodexFingerprintSeed(existingProviderSpecificData); + if (existingSeed) { + psd[CODEX_FINGERPRINT_SEED_KEY] = existingSeed; + return psd; + } + const mode = getCodexFingerprintMode(psd, true); + if (codexFingerprintModeRequiresSeed(mode)) { + psd[CODEX_FINGERPRINT_SEED_KEY] = randomUUID(); + return psd; + } + return Object.keys(psd).length > 0 ? psd : undefined; +} + function readNamedHeader( headers: Headers | Record | null | undefined, name: string @@ -120,6 +190,11 @@ export function getCodexInstallationId( const explicit = normalizeUuid(providerSpecificData?.codexInstallationId); if (explicit) return explicit; + const persistedSeed = getCodexFingerprintSeed(providerSpecificData); + if (persistedSeed) { + return deriveStableUUIDv4(`${CODEX_INSTALLATION_SEED_PREFIX_V2}${persistedSeed}`); + } + const legacyStableSource = nonEmptyString(providerSpecificData?.workspaceId) || nonEmptyString(providerSpecificData?.accountId) || @@ -137,6 +212,10 @@ export function getCodexConvergedSessionId( providerSpecificData?: Record | null, accountKey?: string | null ): string { + const persistedSeed = getCodexFingerprintSeed(providerSpecificData); + if (persistedSeed) { + return deriveStableUUIDv4(`${CODEX_SESSION_SEED_PREFIX_V2}${persistedSeed}`); + } return deriveStableUUIDv4( `${CODEX_SESSION_SEED_PREFIX}${accountSeed(providerSpecificData, accountKey)}` ); @@ -148,6 +227,10 @@ export function getCodexConvergedThreadId( accountKey?: string | null ): string { if (!nonEmptyString(clientSessionId)) return ""; + const persistedSeed = getCodexFingerprintSeed(providerSpecificData); + if (persistedSeed) { + return deriveStableUUIDv4(`${CODEX_THREAD_SEED_PREFIX_V2}${persistedSeed}:${clientSessionId}`); + } return deriveStableUUIDv4( `${CODEX_THREAD_SEED_PREFIX}${accountSeed(providerSpecificData, accountKey)}:${clientSessionId}` ); @@ -163,6 +246,26 @@ export function getCodexClientSessionId( ); } +/** + * Decide what to do with the client's `x-codex-turn-state` echo for the + * account about to serve this request. The blob is minted per account by the + * upstream; replaying another account's blob after failover is a proxy-only + * contradiction, so a known cross-account echo is stripped. Same-account or + * unknown provenance passes through unchanged (strip only, never inject). + * Independent of the fingerprint-convergence mode — account consistency also + * applies to explicit `off` / passthrough. + */ +export function resolveCodexTurnStateEcho( + clientHeaders?: Headers | Record | null, + accountKey?: string | null +): string | null { + const value = readCodexTurnStateHeader(clientHeaders); + if (!value) return null; + const sessionId = getCodexClientSessionId(clientHeaders); + if (sessionId && isCrossAccountCodexTurnState(sessionId, accountKey)) return null; + return value; +} + /** * One identity object for every carrier in one upstream turn. * accountKey may be the OmniRoute connection id; it is never sent upstream. @@ -284,13 +387,19 @@ export function withCodexFingerprintCredentials(); +let turnStateWrites = 0; + +function normalizeAccountKey(accountKey: unknown): string | null { + if (typeof accountKey !== "string") return null; + const trimmed = accountKey.trim(); + return trimmed || null; +} + +/** + * Read the turn-state blob from a headers bag (Headers instance or a plain + * record with arbitrary casing). Returns null when absent/blank. + */ +export function readCodexTurnStateHeader( + headers: Headers | Record | null | undefined +): string | null { + if (!headers) return null; + if (headers instanceof Headers) { + const value = headers.get(CODEX_TURN_STATE_HEADER); + return typeof value === "string" && value.trim() ? value.trim() : null; + } + if (typeof headers === "object") { + for (const [key, value] of Object.entries(headers)) { + if ( + key.toLowerCase() === CODEX_TURN_STATE_HEADER && + typeof value === "string" && + value.trim() + ) { + return value.trim(); + } + } + } + return null; +} + +function sweepExpiredTurnStateOrigins(now: number): void { + for (const [key, origin] of turnStateOrigins) { + if (origin.expiresAt <= now) { + turnStateOrigins.delete(key); + } + } +} + +/** + * Record that `accountKey` minted the turn-state blob this downstream session + * just received. Must only be called at the response commit point — when the + * header is actually written to the client. Recording earlier (e.g. for an + * attempt later discarded by failover) would poison the table and make the + * guard strip the NEXT account's legitimate echo. + */ +export function noteCodexTurnStateProvenance( + clientSessionId: string | null | undefined, + accountKey: unknown, + nowMs?: number +): void { + const sessionId = typeof clientSessionId === "string" ? clientSessionId.trim() : ""; + const account = normalizeAccountKey(accountKey); + if (!sessionId || !account) return; + + const now = typeof nowMs === "number" ? nowMs : Date.now(); + turnStateOrigins.set(sessionId, { + accountKey: account, + expiresAt: now + CODEX_TURN_STATE_TTL_MS, + }); + + turnStateWrites += 1; + if (turnStateWrites % CODEX_TURN_STATE_SWEEP_EVERY_WRITES === 0) { + sweepExpiredTurnStateOrigins(now); + } +} + +/** + * Outbound guard: true when the echoed blob is KNOWN to have been minted by a + * different account and must be stripped before going upstream. Same-account + * or unknown provenance passes through unchanged — stripping only, never + * injection (clients that cannot echo are the Claude bridge's concern, not + * this module's). + */ +export function isCrossAccountCodexTurnState( + clientSessionId: string | null | undefined, + accountKey: unknown, + nowMs?: number +): boolean { + const sessionId = typeof clientSessionId === "string" ? clientSessionId.trim() : ""; + const account = normalizeAccountKey(accountKey); + if (!sessionId || !account) return false; + + const origin = turnStateOrigins.get(sessionId); + if (!origin) return false; + const now = typeof nowMs === "number" ? nowMs : Date.now(); + if (origin.expiresAt <= now) { + turnStateOrigins.delete(sessionId); + return false; + } + return origin.accountKey !== account; +} + +/** Test hook: forget all provenance records and reset the sweep counter. */ +export function __resetCodexTurnStateOriginsForTesting(): void { + turnStateOrigins.clear(); + turnStateWrites = 0; +} diff --git a/open-sse/config/errorConfig.ts b/open-sse/config/errorConfig.ts index b326af7378..7e7355948f 100644 --- a/open-sse/config/errorConfig.ts +++ b/open-sse/config/errorConfig.ts @@ -81,6 +81,10 @@ export const COOLDOWN_MS = { // account, so re-probe only after a long window (or when the operator routes // egress through a supported-region proxy). geoBlocked: 24 * 60 * 60 * 1000, + // Antigravity BYOP (GCP_PROJECT_REQUIRED): nothing changes on the account + // until the operator enters a Project ID, so keep the connection excluded + // from selection for a long window (mirrors the geo-blocked treatment). + gcpProjectRequired: 24 * 60 * 60 * 1000, }; /** diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 3d1777ed65..fe30a37325 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -16,9 +16,11 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts"; * rewrites file timestamps on every deploy, which would report a months-old * catalog as "updated today". Bump this whenever the entries below change. */ -export const FREE_CATALOG_CURATED_AT = "2026-08-16"; +export const FREE_CATALOG_CURATED_AT = "2026-08-18"; export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ + { provider: "chatgpt-web", modelId: "gpt-5.6-luna-free", displayName: "GPT-5.6 Luna (Free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" }, + { provider: "chatgpt-web", modelId: "gpt-5.6-luna-free-thinking", displayName: "GPT-5.6 Luna (Free, Think)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" }, { provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "claude-opus-5", displayName: "Claude Opus 5", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index f8acfd4031..9c1580e7ae 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -18,6 +18,35 @@ export const GLM_ANTHROPIC_DEFAULT_BASE_URLS = Object.freeze({ }); export const GLM_SHARED_MODELS = Object.freeze([ + { + // GLM-5.3 (2026-08-14): one upstream id; effort is the reasoning_effort + // param (low|high|max, default max) — the -high/-low entries below are + // OmniRoute aliases resolved by GlmExecutor::parseGlmEffortTier. + // Default context window not yet published by Z.ai; 1M mirrored from + // GLM-5.2 (same base model). https://z.ai/blog/glm-5.3 + id: "glm-5.3", + name: "GLM 5.3", + contextLength: 1000000, + maxOutputTokens: 131072, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "glm-5.3-high", + name: "GLM 5.3 High", + contextLength: 1000000, + maxOutputTokens: 131072, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "glm-5.3-low", + name: "GLM 5.3 Low", + contextLength: 1000000, + maxOutputTokens: 131072, + toolCalling: true, + supportsReasoning: true, + }, { id: "glm-5.2", name: "GLM 5.2", diff --git a/open-sse/config/grokBuild.ts b/open-sse/config/grokBuild.ts index c6e7dca7bf..ab648afd18 100644 --- a/open-sse/config/grokBuild.ts +++ b/open-sse/config/grokBuild.ts @@ -11,6 +11,7 @@ export const GROK_BUILD_TOKEN_URL = `${GROK_BUILD_OAUTH_ISSUER}/oauth2/token`; export const GROK_BUILD_DEFAULT_CLIENT_VERSION = "0.2.106"; export const GROK_BUILD_DEFAULT_CONTEXT_WINDOW = 256_000; export const GROK_BUILD_DEFAULT_REASONING_EFFORT = "high"; +export const GROK_BUILD_SUPPORTED_REASONING_EFFORTS = Object.freeze(["low", "medium", "high"]); export const GROK_BUILD_CLIENT_IDENTIFIER = "grok-shell"; export const GROK_BUILD_TOKEN_AUTH = "xai-grok-cli"; export const GROK_BUILD_REASONING_INCLUDE = "reasoning.encrypted_content"; diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index d50865599d..02019dc4a0 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -8,7 +8,7 @@ import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts"; import { SEGMIND_IMAGE_PROVIDER } from "./providers/registry/segmind/imageModels.ts"; import { KIE_IMAGE_MODELS } from "./providers/registry/kie/imageModels.ts"; -import { FREEPIK_IMAGE_PROVIDER } from "./providers/registry/freepik/index.ts"; +import { MAGNIFIC_IMAGE_PROVIDER } from "./providers/registry/magnific/index.ts"; import { STABILITY_AI_IMAGE_MODELS } from "./providers/registry/stability-ai/imageModels.ts"; import { CHEAPERINFERENCE_IMAGE_PROVIDER } from "./providers/registry/cheaperinference/imageModels.ts"; import { @@ -210,6 +210,7 @@ export const IMAGE_PROVIDERS: Record = { authHeader: "bearer", format: "openai", // native OpenAI format models: [ + { id: "dall-e-3", name: "DALL·E 3" }, { id: "gpt-image-2", name: "GPT Image 2" }, { id: "gpt-image-1.5", name: "GPT Image 1.5" }, { id: "gpt-image-1-mini", name: "GPT Image 1 Mini" }, @@ -267,6 +268,25 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], }, + // Cursor plan image generation via the Agent CLI native `generateImage` tool. + // Reuses the same OAuth/API-key connection as chat (`provider: "cursor"`). + // Requires the `agent` binary (CURSOR_AGENT_BIN) — see cursorAgentImage handler. + cursor: { + id: "cursor", + alias: "cu", + // Sentinel: execution is local Agent CLI, not an HTTP image API. + baseUrl: "agent://cursor-agent", + authType: "oauth", + authHeader: "bearer", + format: "cursor-agent-image", + models: [ + { id: "auto", name: "Cursor Auto (Image)" }, + { id: "composer-2", name: "Composer 2 (Image)" }, + { id: "composer-2.5", name: "Composer 2.5 (Image)" }, + ], + supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "1024x1536", "1536x1024"], + }, + "microsoft-designer-web": { id: "microsoft-designer-web", alias: "msdesigner", @@ -475,7 +495,7 @@ export const IMAGE_PROVIDERS: Record = { ], supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], }, - freepik: FREEPIK_IMAGE_PROVIDER, + magnific: MAGNIFIC_IMAGE_PROVIDER, sdwebui: { id: "sdwebui", baseUrl: "http://localhost:7860/sdapi/v1/txt2img", @@ -864,7 +884,12 @@ export const IMAGE_PROVIDERS: Record = { * Get image provider config by ID */ export function getImageProvider(providerId) { - return IMAGE_PROVIDERS[providerId] || null; + if (IMAGE_PROVIDERS[providerId]) return IMAGE_PROVIDERS[providerId]; + if (!providerId) return null; + for (const config of Object.values(IMAGE_PROVIDERS)) { + if (config.alias === providerId) return config; + } + return null; } /** @@ -898,9 +923,9 @@ export function parseImageModel(modelStr) { } } - // No provider prefix — try to find the model in every provider + // No provider prefix — try to find the model in every provider, excluding cookie-auth (web) bridges for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) { - if (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr)) { + if (config.authHeader !== "cookie" && (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr))) { return { provider: providerId, model: modelStr }; } } @@ -1001,12 +1026,7 @@ export function getImageModelEntry(modelStr) { }; } -/** - * An image input is only MANDATORY for edit-only models — those whose modalities - * are `["image"]` with no `"text"`. Models listing both `["text", "image"]` accept - * an image but can also run pure text-to-image, so they must NOT be gated on an - * image input (that gate previously blocked 41 dual-modality t2i models). - */ +/** Image input is mandatory only for edit-only models (`["image"]`, no `"text"`). Dual-modality models also accept pure t2i. */ export function modalitiesRequireImageInput(inputModalities) { const list = Array.isArray(inputModalities) ? inputModalities : ["text"]; return list.includes("image") && !list.includes("text"); diff --git a/open-sse/config/musicRegistry.ts b/open-sse/config/musicRegistry.ts index 06aa8a159b..fd1f88fd88 100644 --- a/open-sse/config/musicRegistry.ts +++ b/open-sse/config/musicRegistry.ts @@ -17,6 +17,8 @@ interface MusicProvider { id: string; baseUrl: string; statusUrl?: string; + /** Regional deployment of the same contract, reachable via a base-URL override. */ + regionalBaseUrl?: string; authType: string; authHeader: string; format: string; @@ -79,14 +81,21 @@ export const MUSIC_PROVIDERS: Record = { minimax: { id: "minimax", baseUrl: "https://api.minimax.io/v1/music_generation", - statusUrl: "https://api.minimax.io/v1/query/music_generation", + // The music operation answers with the finished audio in the POST response — + // there is no task id and no query endpoint, hence no statusUrl. The regional + // deployment serves the same contract and is the only host that accepts the + // `aigc_watermark` request field. + regionalBaseUrl: "https://api.minimaxi.com/v1/music_generation", authType: "apikey", authHeader: "bearer", format: "minimax-music", models: [ + { id: "music-3.0", name: "Music 3.0" }, { id: "music-2.6", name: "Music 2.6" }, + { id: "music-3.0-free", name: "Music 3.0 Free" }, { id: "music-2.6-free", name: "Music 2.6 Free" }, { id: "music-cover", name: "Music Cover" }, + { id: "music-cover-free", name: "Music Cover Free" }, ], }, comfyui: { diff --git a/open-sse/config/providers/alternateFormats.ts b/open-sse/config/providers/alternateFormats.ts index b223a26448..8a7deb782c 100644 --- a/open-sse/config/providers/alternateFormats.ts +++ b/open-sse/config/providers/alternateFormats.ts @@ -19,6 +19,19 @@ export interface AlternateFormat { authHeader?: string; headers?: Record; urlSuffix?: string; + /** + * Monta a URL final quando o protocolo alternativo embute o modelo no path, e + * nao apenas um sufixo fixo. O caso concreto e o protocolo Gemini, cuja rota e + * `{base}/{model}:generateContent` (ou `:streamGenerateContent?alt=sse`) — algo + * que `chatPath`/`urlSuffix` nao expressam, porque ambos sao constantes. + * + * Mesma assinatura do `urlBuilder` de RegistryEntry (base ja sem "/" final, + * modelo e stream), de proposito: um gateway que fala Gemini como alternativa + * reaproveita `buildGeminiGenerateContentUrl` de shared.ts — o mesmo builder que + * o provedor Gemini nativo usa — em vez de reimplementar a rota. + * Quando ausente, a URL continua sendo `baseUrl + chatPath + urlSuffix`. + */ + urlBuilder?: (base: string, model: string, stream: boolean) => string; label: string; } diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 01395e7dd8..00861b2422 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -3,6 +3,8 @@ import { unorouterProvider } from "./registry/unorouter/index.ts"; import { aimlapiProvider } from "./registry/aimlapi/index.ts"; import { byteplusProvider } from "./registry/byteplus/index.ts"; +import { mlxGemmaProvider } from "./registry/mlx/index.ts"; +import { mlxQwenProvider } from "./registry/mlx/index.ts"; import { ollama_cloudProvider } from "./registry/ollama-cloud/index.ts"; import { syntheticProvider } from "./registry/synthetic/index.ts"; import { ideogramProvider } from "./registry/ideogram/index.ts"; @@ -16,6 +18,7 @@ import { deepaiProvider } from "./registry/deepai/index.ts"; import { upstageProvider } from "./registry/upstage/index.ts"; import { nebiusProvider } from "./registry/nebius/index.ts"; import { fireworksProvider } from "./registry/fireworks/index.ts"; +import { freebuffProvider } from "./registry/freebuff/index.ts"; import { llamagateProvider } from "./registry/llamagate/index.ts"; import { glmProvider } from "./registry/glm/index.ts"; import { glmtProvider } from "./registry/glm/t/index.ts"; @@ -65,7 +68,7 @@ import { api_airforceProvider } from "./registry/api-airforce/index.ts"; import { mistralProvider } from "./registry/mistral/index.ts"; import { togetherProvider } from "./registry/together/index.ts"; import { cohereProvider } from "./registry/cohere/index.ts"; -import { cursorProvider } from "./registry/cursor/index.ts"; +import { cursorProvider, cursor_apiProvider } from "./registry/cursor/index.ts"; import { volcengineProvider } from "./registry/volcengine/index.ts"; import { hackclubProvider } from "./registry/hackclub/index.ts"; import { freetheaiProvider } from "./registry/freetheai/index.ts"; @@ -213,6 +216,7 @@ import { kiroProvider } from "./registry/kiro/index.ts"; import { openadapterProvider } from "./registry/openadapter/index.ts"; import { ditProvider } from "./registry/dit/index.ts"; import { tokenrouterProvider } from "./registry/tokenrouter/index.ts"; +import { token_kioskProvider } from "./registry/token-kiosk/index.ts"; import { grok_cliProvider } from "./registry/grok-cli/index.ts"; import { codebuddy_cnProvider } from "./registry/codebuddy-cn/index.ts"; import { pioneerProvider } from "./registry/pioneer/index.ts"; @@ -260,9 +264,12 @@ import { freeinferenceProvider } from "./registry/freeinference/index.ts"; import { freeAiProvider } from "./registry/free-ai/index.ts"; import { voidAiProvider } from "./registry/void-ai/index.ts"; import { helixmindProvider } from "./registry/helixmind/index.ts"; +import { tabitokenProvider } from "./registry/tabitoken/index.ts"; export const REGISTRY: Record = { aimlapi: aimlapiProvider, + "mlx-gemma": mlxGemmaProvider, + "mlx-qwen": mlxQwenProvider, "ollama-cloud": ollama_cloudProvider, synthetic: syntheticProvider, ideogram: ideogramProvider, @@ -276,6 +283,7 @@ export const REGISTRY: Record = { deepai: deepaiProvider, nebius: nebiusProvider, fireworks: fireworksProvider, + freebuff: freebuffProvider, llamagate: llamagateProvider, glm: glmProvider, glmt: glmtProvider, @@ -326,6 +334,7 @@ export const REGISTRY: Record = { together: togetherProvider, cohere: cohereProvider, cursor: cursorProvider, + "cursor-api": cursor_apiProvider, volcengine: volcengineProvider, hackclub: hackclubProvider, freetheai: freetheaiProvider, @@ -475,6 +484,7 @@ export const REGISTRY: Record = { openadapter: openadapterProvider, dit: ditProvider, tokenrouter: tokenrouterProvider, + "token-kiosk": token_kioskProvider, "grok-cli": grok_cliProvider, "codebuddy-cn": codebuddy_cnProvider, pioneer: pioneerProvider, @@ -523,4 +533,5 @@ export const REGISTRY: Record = { "free-ai": freeAiProvider, "void-ai": voidAiProvider, helixmind: helixmindProvider, + tabitoken: tabitokenProvider, }; diff --git a/open-sse/config/providers/registry/chatgpt-web-codex/index.ts b/open-sse/config/providers/registry/chatgpt-web-codex/index.ts index a1ccb6b13c..1c290668f9 100644 --- a/open-sse/config/providers/registry/chatgpt-web-codex/index.ts +++ b/open-sse/config/providers/registry/chatgpt-web-codex/index.ts @@ -14,6 +14,7 @@ export const chatgpt_web_codexProvider: RegistryEntry = { format: "openai-responses", executor: "chatgpt-web-codex", baseUrl: "https://chatgpt.com", + reasoningTransport: "opaque", authType: "apikey", authHeader: "cookie", forceStream: true, diff --git a/open-sse/config/providers/registry/chatgpt-web/index.ts b/open-sse/config/providers/registry/chatgpt-web/index.ts index bb57c7f31c..16c1522e2b 100644 --- a/open-sse/config/providers/registry/chatgpt-web/index.ts +++ b/open-sse/config/providers/registry/chatgpt-web/index.ts @@ -9,12 +9,83 @@ export const chatgpt_webProvider: RegistryEntry = { authType: "apikey", authHeader: "cookie", models: [ - { id: "gpt-5.6-pro", name: "GPT-5.6 Pro", toolCalling: false }, // pro tier only, standard effort - { id: "gpt-5.6-thinking", name: "GPT-5.6 Thinking", toolCalling: false }, // plus, pro tier - { id: "gpt-5.5-pro-extended", name: "GPT-5.5 Pro Extended", toolCalling: false }, // pro tier only, extended effort - { id: "gpt-5.5-pro", name: "GPT-5.5 Pro", toolCalling: false }, // pro tier only, standard effort - { id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking", toolCalling: false }, // plus, pro tier - { id: "gpt-5.5", name: "GPT-5.5 Instant", toolCalling: false }, // free, plus, pro tier - { id: "o3", name: "o3", toolCalling: false }, // plus ~ tier + { + id: "gpt-5.6-sol-pro", + name: "GPT-5.6 Sol (Pro)", + liveCatalogIds: ["gpt-5-6-pro"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-xhigh", + name: "GPT-5.6 Sol (Xhigh)", + liveCatalogIds: ["gpt-5-6-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-high", + name: "GPT-5.6 Sol (High)", + liveCatalogIds: ["gpt-5-6-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-medium", + name: "GPT-5.6 Sol (Medium)", + liveCatalogIds: ["gpt-5-6-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-instant", + name: "GPT-5.6 Sol (Instant)", + liveCatalogIds: ["gpt-5-6"], + toolCalling: false, + }, + { + id: "gpt-5.6-luna-free-thinking", + name: "GPT-5.6 Luna (Free, Think)", + liveCatalogIds: ["gpt-5-6"], + toolCalling: false, + }, + { + id: "gpt-5.6-luna-free", + name: "GPT-5.6 Luna (Free)", + liveCatalogIds: ["gpt-5-6"], + toolCalling: false, + }, + { + id: "gpt-5.5-pro-extended", + name: "GPT-5.5 (Pro Extended)", + liveCatalogIds: ["gpt-5-5-pro"], + toolCalling: false, + }, + { + id: "gpt-5.5-pro", + name: "GPT-5.5 (Pro)", + liveCatalogIds: ["gpt-5-5-pro"], + toolCalling: false, + }, + { + id: "gpt-5.5-xhigh", + name: "GPT-5.5 (Xhigh)", + liveCatalogIds: ["gpt-5-5-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.5-high", + name: "GPT-5.5 (High)", + liveCatalogIds: ["gpt-5-5-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.5-medium", + name: "GPT-5.5 (Medium)", + liveCatalogIds: ["gpt-5-5-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.5-instant", + name: "GPT-5.5 (Instant)", + liveCatalogIds: ["gpt-5-5"], + toolCalling: false, + }, ], }; diff --git a/open-sse/config/providers/registry/codex/index.ts b/open-sse/config/providers/registry/codex/index.ts index 7d6fee4557..6b67797fa3 100644 --- a/open-sse/config/providers/registry/codex/index.ts +++ b/open-sse/config/providers/registry/codex/index.ts @@ -12,6 +12,7 @@ export const codexProvider: RegistryEntry = { format: "openai-responses", executor: "codex", baseUrl: "https://chatgpt.com/backend-api/codex/responses", + reasoningTransport: "opaque", authType: "oauth", authHeader: "bearer", defaultContextLength: 400000, diff --git a/open-sse/config/providers/registry/cursor/index.ts b/open-sse/config/providers/registry/cursor/index.ts index 0e167d39bb..1bb6d02b21 100644 --- a/open-sse/config/providers/registry/cursor/index.ts +++ b/open-sse/config/providers/registry/cursor/index.ts @@ -156,3 +156,29 @@ export const cursorProvider: RegistryEntry = { { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, ], }; + +/** + * API-key variant of the Cursor provider. + * + * Same wire protocol, executor and catalog as `cursor`, but the connection + * holds a Cursor user API key (`crsr_…`, cursor.com/dashboard/api) instead of + * an IDE/OAuth session. The executor exchanges that key for a session token + * on demand (open-sse/services/cursorApiKeyAuth.ts), so no cursor-agent or + * IDE install is needed on the OmniRoute host. Kept as a distinct backend ID + * so API-key and IDE-session connections never share renewal, quota or + * dashboard semantics. + */ +export const cursor_apiProvider: RegistryEntry = { + id: "cursor-api", + alias: "cua", + format: cursorProvider.format, + executor: "cursor-api", + baseUrl: cursorProvider.baseUrl, + chatPath: cursorProvider.chatPath, + authType: "apikey", + authHeader: "bearer", + defaultContextLength: cursorProvider.defaultContextLength, + headers: getCursorRegistryHeaders(), + clientVersion: CURSOR_REGISTRY_VERSION, + models: cursorProvider.models, +}; diff --git a/open-sse/config/providers/registry/freebuff/index.ts b/open-sse/config/providers/registry/freebuff/index.ts new file mode 100644 index 0000000000..713f469548 --- /dev/null +++ b/open-sse/config/providers/registry/freebuff/index.ts @@ -0,0 +1,70 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const freebuffProvider: RegistryEntry = { + id: "freebuff", + alias: "fb", + format: "openai", + executor: "freebuff", + baseUrl: "https://www.codebuff.com/api/v1", + authType: "apikey", + authHeader: "bearer", + models: [ + { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash", + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "openai/gpt-5.6-luna", + name: "GPT-5.6 Luna", + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "minimax/minimax-m3", + name: "MiniMax M3", + supportsVision: true, + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "mimo/mimo-v2.5", + name: "MiMo v2.5", + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "z-ai/glm-5.2", + name: "GLM 5.2", + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "crof/kimi-k3-eco", + name: "Kimi K3 Eco", + supportsVision: true, + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "anthropic/claude-fable-5", + name: "Claude Fable 5", + supportsVision: true, + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "meta/muse-spark-1.2-contributor", + name: "Meta Muse Spark 1.2 Contributor", + supportsReasoning: true, + contextLength: 131_072, + }, + ], +}; diff --git a/open-sse/config/providers/registry/freepik/index.ts b/open-sse/config/providers/registry/freepik/index.ts deleted file mode 100644 index 7b99c71168..0000000000 --- a/open-sse/config/providers/registry/freepik/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Freepik (Magnific Mystic) image provider registry entry. - * Extracted into its own module to keep open-sse/config/imageRegistry.ts - * under the file-size cap (god-file decomposition; semantic split). - */ -export const FREEPIK_IMAGE_PROVIDER = { - id: "freepik", - // Freepik rebranded its API docs to Magnific in April 2026; the Mystic - // endpoint itself still lives under api.freepik.com as of this writing - // (docs.freepik.com redirects to docs.magnific.com, but the API host - // has not moved). Re-verify against live docs if this ever 404s. - baseUrl: "https://api.freepik.com/v1/ai/mystic", - statusUrl: "https://api.freepik.com/v1/ai/mystic", - authType: "apikey", - authHeader: "x-freepik-api-key", - format: "freepik-image", // custom: async submit task_id, then poll GET /{task_id} - models: [ - { id: "realism", name: "Mystic Realism" }, - { id: "fluid", name: "Mystic Fluid (Imagen 3)" }, - { id: "zen", name: "Mystic Zen" }, - { id: "flexible", name: "Mystic Flexible" }, - { id: "super_real", name: "Mystic Super Real" }, - { id: "editorial_portraits", name: "Mystic Editorial Portraits" }, - ], - supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], -}; diff --git a/open-sse/config/providers/registry/gemini/index.ts b/open-sse/config/providers/registry/gemini/index.ts index 8119ed9c41..468fcd8889 100644 --- a/open-sse/config/providers/registry/gemini/index.ts +++ b/open-sse/config/providers/registry/gemini/index.ts @@ -1,5 +1,5 @@ import type { RegistryEntry } from "../../shared.ts"; -import { resolvePublicCred } from "../../shared.ts"; +import { buildGeminiGenerateContentUrl, resolvePublicCred } from "../../shared.ts"; export const geminiProvider: RegistryEntry = { id: "gemini", @@ -7,10 +7,7 @@ export const geminiProvider: RegistryEntry = { format: "gemini", executor: "default", baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", - urlBuilder: (base, model, stream) => { - const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; - return `${base}/${model}:${action}`; - }, + urlBuilder: buildGeminiGenerateContentUrl, authType: "apikey", authHeader: "x-goog-api-key", defaultContextLength: 1048576, diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts index f257f8d60a..e65ced0e76 100644 --- a/open-sse/config/providers/registry/grok-cli/index.ts +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -14,6 +14,7 @@ export const grok_cliProvider: RegistryEntry = { // Keep the generic translate-path contract stable. GrokCliExecutor owns the // official Grok Build upstream URL and always dispatches to /v1/responses. baseUrl: "https://cli-chat-proxy.grok.com/v1/chat/completions", + reasoningTransport: "opaque", modelsUrl: GROK_BUILD_MODELS_URL, clientVersion: getGrokBuildClientVersion(), authType: "oauth", diff --git a/open-sse/config/providers/registry/hcnsec/index.ts b/open-sse/config/providers/registry/hcnsec/index.ts index acce62c2bc..dba2b690da 100644 --- a/open-sse/config/providers/registry/hcnsec/index.ts +++ b/open-sse/config/providers/registry/hcnsec/index.ts @@ -1,11 +1,62 @@ import type { RegistryEntry } from "../../shared.ts"; -import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; +import { + buildGeminiGenerateContentUrl, + buildOpenAiCompatibleRegistryEntry, + getAnthropicCompatHeaders, +} from "../../shared.ts"; +/** + * HCNSec — NewAPI-based host (https://api.hcnsec.cn), announced by its own `/api/status` as + * 新疆幻城网安科技公益大模型安全网关. Catalogued as an API-key **regional** provider + * (`APIKEY_PROVIDERS_REGIONAL.hcnsec`); this entry only describes how to reach it. + * + * It shipped OpenAI-only. The three alternates below were added after probing the host live: + * every one of them reaches the NewAPI token layer (`{"error":{"type":"new_api_error"}}` on an + * invalid key) rather than a router 404, so each is a route this host actually serves — + * including the Gemini path in both its unary and `:streamGenerateContent?alt=sse` forms. + * The default format, base URL and auth scheme are deliberately untouched. + * + * `models: []` is unchanged and deliberate. Unlike TabiToken, this host gates every discovery + * endpoint behind auth (`/api/status` reports `pricing.requireAuth: true`; `/api/pricing`, + * `/api/models`, `/api/models/display` and `/api/user/models` all answer "Unauthorized, not + * logged in and no access token provided"). Rather than ship a guessed catalog, the model list + * is left to live discovery through `modelsUrl` with the operator's own key — the same + * arrangement `anyapi` and `helixmind` use. + */ export const hcnsecProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ id: "hcnsec", alias: "hcnsec", baseUrl: "https://api.hcnsec.cn/v1/chat/completions", modelsUrl: "https://api.hcnsec.cn/v1/models", + responsesBaseUrl: "https://api.hcnsec.cn/v1/responses", models: [], passthroughModels: true, + alternateFormats: [ + { + // `Anthropic-Version` is scoped to this alternate (deepseek's arrangement) because + // it is only meaningful on `/v1/messages`, and because `default.ts` supplies that + // default solely for `anthropic-compatible-*` provider ids — not for a gateway that + // reaches the Claude protocol through an alternate. + format: "claude", + baseUrl: "https://api.hcnsec.cn/v1/messages", + authHeader: "x-api-key", + headers: getAnthropicCompatHeaders(), + label: "Anthropic-compatible", + }, + { + format: "openai-responses", + baseUrl: "https://api.hcnsec.cn/v1/responses", + authHeader: "bearer", + label: "OpenAI Responses", + }, + { + // The Gemini protocol carries the model in the path, so this alternate needs the + // same builder the native `gemini` provider uses instead of a constant chatPath. + format: "gemini", + baseUrl: "https://api.hcnsec.cn/v1beta/models", + authHeader: "x-goog-api-key", + urlBuilder: buildGeminiGenerateContentUrl, + label: "Gemini-compatible", + }, + ], }); diff --git a/open-sse/config/providers/registry/magnific/index.ts b/open-sse/config/providers/registry/magnific/index.ts new file mode 100644 index 0000000000..63c83d5b1c --- /dev/null +++ b/open-sse/config/providers/registry/magnific/index.ts @@ -0,0 +1,26 @@ +/** + * Magnific Mystic image provider registry entry. + * Extracted into its own module to keep open-sse/config/imageRegistry.ts + * under the file-size cap (god-file decomposition; semantic split). + */ +export const MAGNIFIC_IMAGE_PROVIDER = { + id: "magnific", + // Official Magnific API (docs.magnific.com). The previous OmniRoute slug + // was `freepik` because Magnific started as Freepik's developer API; keep + // that id as a legacy alias so old URLs and `freepik/` still resolve. + alias: "freepik", + baseUrl: "https://api.magnific.com/v1/ai/mystic", + statusUrl: "https://api.magnific.com/v1/ai/mystic", + authType: "apikey", + authHeader: "x-magnific-api-key", + format: "magnific-image", // custom: async submit task_id, then poll GET /{task_id} + models: [ + { id: "realism", name: "Mystic Realism" }, + { id: "fluid", name: "Mystic Fluid (Imagen 3)" }, + { id: "zen", name: "Mystic Zen" }, + { id: "flexible", name: "Mystic Flexible" }, + { id: "super_real", name: "Mystic Super Real" }, + { id: "editorial_portraits", name: "Mystic Editorial Portraits" }, + ], + supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], +}; diff --git a/open-sse/config/providers/registry/mlx/index.ts b/open-sse/config/providers/registry/mlx/index.ts new file mode 100644 index 0000000000..24d3e8b232 --- /dev/null +++ b/open-sse/config/providers/registry/mlx/index.ts @@ -0,0 +1,66 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +// MLX ports (deterministic, documented) +const MLX_GEMMA_PORT = 11435; +const MLX_QWEN_PORT = 11436; + +// ───────────────────────────────────────────────────────────────────────────── +// Memory-aware context windows for MLX models on 24GB unified memory. +// Based on verified peak memory: Gemma 26B ~15.9GB, Qwen 27B ~13.1GB. +// KV cache estimate: 2 * 2 * layers * kv_heads * head_dim * num_ctx bytes. +// Conservative context windows to leave headroom for OS/other processes. +export const MLX_DEFAULT_CONTEXT_LIMIT = 32768; + +const CONTEXT_GEMMA_26B = 8192; // 15.9GB weights + ~3.5GB KV @ 8k = ~19.4GB (safe for 24GB) +const CONTEXT_QWEN_27B = 8192; // 13.1GB weights + ~3.5GB KV @ 8k = ~16.6GB (safe for 24GB) + +// ───────────────────────────────────────────────────────────────────────────── +// MLX Gemma 26B Provider +// Model: mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned +// Verified speed: ~38.5 tok/s, peak memory: ~15.9 GB +export const mlxGemmaProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "mlx-gemma", + alias: "mlx-gemma", + baseUrl: `http://localhost:${MLX_GEMMA_PORT}/v1`, + modelsUrl: `http://localhost:${MLX_GEMMA_PORT}/v1/models`, + passthroughModels: false, + defaultContextLength: MLX_DEFAULT_CONTEXT_LIMIT, + models: [ + { + id: "mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned", + name: "Gemma 4 26B A4B IT-QAT (MLX)", + toolCalling: true, + supportsVision: false, + supportsReasoning: false, + contextLength: CONTEXT_GEMMA_26B, + maxOutputTokens: 8192, + }, + ], + timeoutMs: 120000, // Longer timeout for model loading +}); + +// ───────────────────────────────────────────────────────────────────────────── +// MLX Qwen3.8 27B Provider +// Model: maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw +// Verified speed: ~9.1 tok/s, peak memory: ~13.1 GB +export const mlxQwenProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "mlx-qwen", + alias: "mlx-qwen", + baseUrl: `http://localhost:${MLX_QWEN_PORT}/v1`, + modelsUrl: `http://localhost:${MLX_QWEN_PORT}/v1/models`, + passthroughModels: false, + defaultContextLength: MLX_DEFAULT_CONTEXT_LIMIT, + models: [ + { + id: "maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw", + name: "Qwen 3.8 27B MLX Mixed 3.80bpw", + toolCalling: true, + supportsVision: false, + supportsReasoning: false, + contextLength: CONTEXT_QWEN_27B, + maxOutputTokens: 8192, + }, + ], + timeoutMs: 120000, // Longer timeout for model loading +}); diff --git a/open-sse/config/providers/registry/muse-code/index.ts b/open-sse/config/providers/registry/muse-code/index.ts index 66f59f9f42..37db1e988d 100644 --- a/open-sse/config/providers/registry/muse-code/index.ts +++ b/open-sse/config/providers/registry/muse-code/index.ts @@ -14,6 +14,7 @@ export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEnt id: "muse-code", alias: "mc", passthroughModels: true, + reasoningTransport: "opaque", defaultContextLength: 200000, models: [ { diff --git a/open-sse/config/providers/registry/ollama-cloud/index.ts b/open-sse/config/providers/registry/ollama-cloud/index.ts index 05b28df65f..4cf020263a 100644 --- a/open-sse/config/providers/registry/ollama-cloud/index.ts +++ b/open-sse/config/providers/registry/ollama-cloud/index.ts @@ -24,8 +24,24 @@ export const ollama_cloudProvider: RegistryEntry = { supportsReasoning: true, supportedThinkingEfforts: ["low", "medium", "high"], }, - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, + // #10788: Ollama Cloud accepts low|medium|high|max|none uniformly across + // its reasoning-capable models (see supportsMaxEffortForProvider's + // isOllamaCloud comment in open-sse/executors/base/reasoningEffort.ts) — + // declare supportedThinkingEfforts so appendSyncedEffortVariants() (which + // runs before static-model capability enrichment) can synthesize the + // catalog's selectable -low/-high/-max variant ids for these models. + { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "max"], + }, + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "max"], + }, { id: "kimi-k2.6", name: "Kimi K2.6" }, // Ollama Cloud accepts low|medium|high|max|none and rejects xhigh, so the // explicit supportsXHighEffort:false makes the sanitizer map xhigh → max. @@ -34,12 +50,14 @@ export const ollama_cloudProvider: RegistryEntry = { name: "GLM 5.1", supportsReasoning: true, supportsXHighEffort: false, + supportedThinkingEfforts: ["low", "medium", "high", "max"], }, { id: "glm-5.2", name: "GLM 5.2", supportsReasoning: true, supportsXHighEffort: false, + supportedThinkingEfforts: ["low", "medium", "high", "max"], }, // #3110: MiniMax M3 via Ollama { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true }, diff --git a/open-sse/config/providers/registry/openai/index.ts b/open-sse/config/providers/registry/openai/index.ts index 63b6a30fa3..60a276a948 100644 --- a/open-sse/config/providers/registry/openai/index.ts +++ b/open-sse/config/providers/registry/openai/index.ts @@ -7,6 +7,7 @@ export const openaiProvider: RegistryEntry = { format: "openai", executor: "default", baseUrl: "https://api.openai.com/v1/chat/completions", + reasoningTransport: "opaque", authType: "apikey", authHeader: "bearer", defaultContextLength: 128000, diff --git a/open-sse/config/providers/registry/sensenova/index.ts b/open-sse/config/providers/registry/sensenova/index.ts index 37e95acc32..2e8b5822f9 100644 --- a/open-sse/config/providers/registry/sensenova/index.ts +++ b/open-sse/config/providers/registry/sensenova/index.ts @@ -27,6 +27,8 @@ export const sensenovaProvider: RegistryEntry = { contextLength: 1048576, maxOutputTokens: 65536, supportsReasoning: true, + supportedThinkingEfforts: ["none", "low", "medium", "high", "xhigh"], + supportsXHighEffort: true, interleavedField: "reasoning_content", }, { diff --git a/open-sse/config/providers/registry/tabitoken/index.ts b/open-sse/config/providers/registry/tabitoken/index.ts new file mode 100644 index 0000000000..f95d188c20 --- /dev/null +++ b/open-sse/config/providers/registry/tabitoken/index.ts @@ -0,0 +1,59 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { getAnthropicCompatHeaders } from "../../shared.ts"; + +/** + * TabiToken — NewAPI-based Claude gateway (https://tabitoken.com). + * + * The catalog below is not hand-written: TabiToken leaves the NewAPI pricing endpoint + * public (`/api/status` reports `pricing.requireAuth: false`), so `GET /api/pricing` + * lists every model together with the protocols it accepts. All four entries report + * `supported_endpoint_types: ["anthropic","openai"]`, which is why only those two + * protocols are declared here — the host also routes `/v1/responses` and the Gemini + * `/v1beta` path, but no model on this gateway is reachable through them. + * + * Claude-first (`/v1/messages` + `x-api-key`) because the whole catalog is Claude and + * that avoids a translation hop for Claude-native clients; `passthroughModels` keeps + * models added upstream usable before this list catches up. + * + * No static fingerprint headers. TabiToken fronts Cloudflare, and the only User-Agent + * it rejects is the literal `curl/*` default — a browser UA is answered with + * "Access denied: abusive or non-compliant use is prohibited", while sending no UA + * (the fetch default) reaches the token layer normally. So, unlike agentrouter, this + * entry needs neither a static nor a dynamic wire image. + * + * `headers` carries only `Anthropic-Version`, and it has to live on the entry rather + * than come from the executor: `default.ts` defaults that header solely for provider + * ids prefixed `anthropic-compatible-` (buildHeaders, the `startsWith` branch), so a + * plain `format: "claude"` entry would POST `/v1/messages` without it. Six sibling + * third-party Claude entries (wafer, zai, xiaomi-mimo, xiaomi-mimo-token-plan, + * bailian-coding-plan, deepseek) set it for exactly this reason. Entry-level headers + * are merged for every format (base.ts::buildHeadersPreamble), so the OpenAI alternate + * below also sends it — a documented no-op on `/chat/completions` (see the same note in + * executors/github.ts). + */ +export const tabitokenProvider: RegistryEntry = { + id: "tabitoken", + alias: "tabitoken", + format: "claude", + executor: "default", + baseUrl: "https://tabitoken.com/v1/messages", + modelsUrl: "https://tabitoken.com/v1/models", + authType: "apikey", + authHeader: "x-api-key", + headers: getAnthropicCompatHeaders(), + alternateFormats: [ + { + format: "openai", + baseUrl: "https://tabitoken.com/v1/chat/completions", + authHeader: "bearer", + label: "OpenAI-compatible", + }, + ], + models: [ + { id: "claude-opus-5", name: "Claude Opus 5" }, + { id: "claude-opus-5-thinking", name: "Claude Opus 5 (Thinking)" }, + { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { id: "claude-opus-4-8-thinking", name: "Claude Opus 4.8 (Thinking)" }, + ], + passthroughModels: true, +}; diff --git a/open-sse/config/providers/registry/token-kiosk/index.ts b/open-sse/config/providers/registry/token-kiosk/index.ts new file mode 100644 index 0000000000..319747828c --- /dev/null +++ b/open-sse/config/providers/registry/token-kiosk/index.ts @@ -0,0 +1,20 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const token_kioskProvider: RegistryEntry = { + id: "token-kiosk", + alias: "tk", + format: "openai", + executor: "default", + baseUrl: "https://agent-router.gaib.ai/v1/chat/completions", + modelsUrl: "https://agent-router.gaib.ai/v1/models", + authType: "apikey", + authHeader: "bearer", + defaultContextLength: 128000, + models: [ + { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet (Token Kiosk)", contextLength: 200000, toolCalling: true, supportsVision: true }, + { id: "deepseek-v3", name: "DeepSeek V3 (Token Kiosk)", contextLength: 64000, toolCalling: true }, + { id: "deepseek-r1", name: "DeepSeek R1 (Token Kiosk)", contextLength: 64000, toolCalling: true, supportsReasoning: true }, + { id: "kimi-k1.5", name: "Kimi K1.5 (Token Kiosk)", contextLength: 128000, toolCalling: true }, + { id: "minimax-m6", name: "MiniMax M6 (Token Kiosk)", contextLength: 128000, toolCalling: true }, + ], +}; diff --git a/open-sse/config/providers/registry/xai/index.ts b/open-sse/config/providers/registry/xai/index.ts index efd0a72e3a..dcf9124441 100644 --- a/open-sse/config/providers/registry/xai/index.ts +++ b/open-sse/config/providers/registry/xai/index.ts @@ -12,6 +12,7 @@ export const xaiProvider: RegistryEntry = { // XaiExecutor.buildUrl (open-sse/executors/xai.ts) for models tagged // targetFormat: "openai-responses" below. responsesBaseUrl: "https://api.x.ai/v1/responses", + reasoningTransport: "opaque", authType: "apikey", authHeader: "bearer", models: [ @@ -54,6 +55,7 @@ export const xai_oauthProvider: RegistryEntry = { executor: "xai-oauth", baseUrl: xaiProvider.baseUrl, responsesBaseUrl: xaiProvider.responsesBaseUrl, + reasoningTransport: "opaque", authType: "oauth", authHeader: xaiProvider.authHeader, passthroughModels: true, diff --git a/open-sse/config/providers/registry/zai/index.ts b/open-sse/config/providers/registry/zai/index.ts index e126aee21f..1141ea8dc3 100644 --- a/open-sse/config/providers/registry/zai/index.ts +++ b/open-sse/config/providers/registry/zai/index.ts @@ -11,13 +11,15 @@ export const zaiProvider: RegistryEntry = { authType: "apikey", authHeader: "x-api-key", headers: getAnthropicCompatHeaders(), - // Real upstream model IDs only. The effort tiers (glm-5.2-high / glm-5.2-max) - // are intentionally NOT listed here: they are OmniRoute aliases resolved by the - // GlmExecutor (parseGlm52Effort → base "glm-5.2" + effort field). This provider - // uses the DefaultExecutor, which sends the model ID verbatim, so the aliases - // would reach z.ai's Anthropic endpoint as unknown IDs. Use the `glm` provider - // for effort tiers. Vision models are likewise omitted (handled elsewhere). + // Real upstream model IDs only. The effort tiers (glm-5.2-high/-max, + // glm-5.3-high/-low) are intentionally NOT listed here: they are OmniRoute + // aliases resolved by the GlmExecutor (parseGlmEffortTier → base model + + // effort selector). This provider uses the DefaultExecutor, which sends the + // model ID verbatim, so the aliases would reach z.ai's Anthropic endpoint as + // unknown IDs. Use the `glm` provider for effort tiers. Vision models are + // likewise omitted (handled elsewhere). models: [ + { id: "glm-5.3", name: "GLM 5.3" }, { id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5", name: "GLM 5" }, diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 88fff0fef7..d87250644d 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -46,6 +46,12 @@ export interface RegistryModel { id: string; name: string; aliases?: readonly string[]; + /** + * Upstream model IDs that prove this static model is live when the provider + * has an authoritative synchronized catalog. Needed for curated IDs whose + * public name differs from the ID sent to the upstream service. + */ + liveCatalogIds?: readonly string[]; toolCalling?: boolean; supportsReasoning?: boolean; supportedThinkingEfforts?: readonly string[]; @@ -102,6 +108,8 @@ export interface RegistryOAuth { pollUrlBase?: string; } +export type ReasoningTransport = "plaintext" | "opaque" | "none"; + export interface RegistryEntry { id: string; alias?: string; @@ -114,6 +122,8 @@ export interface RegistryEntry { /** Override models URL used only for API key validation, not catalog discovery. */ testKeyModelsUrl?: string; responsesBaseUrl?: string; + /** Provider-bound replay format; omitted providers accept portable plaintext reasoning. */ + reasoningTransport?: ReasoningTransport; /** Anthropic-native /v1/messages endpoint (e.g. GitHub Copilot's shim) used * for models tagged `targetFormat: "claude"` on an otherwise openai-format * provider — see registry/github/index.ts. */ @@ -752,3 +762,20 @@ export function buildAntigravityUrl(base: string, model: string, stream: boolean const path = stream ? "/v1internal:streamGenerateContent?alt=sse" : "/v1internal:generateContent"; return `${base}${path}`; } + +/** + * Gemini protocol `generateContent` route: the model goes in the path, not the body. + * + * Shared because the format has two consumers: the native `gemini` provider + * (RegistryEntry.urlBuilder) and gateways that expose Gemini as an alternate + * protocol (AlternateFormat.urlBuilder, see alternateFormats.ts). One copy per + * consumer would leave the streaming `?alt=sse` suffix free to diverge. + */ +export function buildGeminiGenerateContentUrl( + base: string, + model: string, + stream: boolean +): string { + const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; + return `${base}/${model}:${action}`; +} diff --git a/open-sse/config/searchRegistry.ts b/open-sse/config/searchRegistry.ts index ce20777cb0..9230fa1b0e 100644 --- a/open-sse/config/searchRegistry.ts +++ b/open-sse/config/searchRegistry.ts @@ -303,6 +303,19 @@ export const SEARCH_CREDENTIAL_FALLBACKS: Record = { export const SEARCH_PROVIDER_ALIASES: Record = { "jina-ai": "jina-search", jina: "jina-search", + brave: "brave-search", + serper: "serper-search", + perplexity: "perplexity-search", + exa: "exa-search", + tavily: "tavily-search", + "google-pse": "google-pse-search", + linkup: "linkup-search", + ollama: "ollama-search", + searchapi: "searchapi-search", + youcom: "youcom-search", + searxng: "searxng-search", + zai: "zai-search", + duckduckgo: "duckduckgo-free", }; export function resolveSearchProviderId(providerId: string): string { diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index c91cb5cbb7..877431b68e 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -103,6 +103,7 @@ import { } from "./base/headers.ts"; import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting"; import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth"; +import { isProbeContext } from "@/shared/utils/probeOrigin"; // Header helpers extracted to a pure leaf; re-exported for external importers // (executors + tests) that import them from "./base.ts". export { @@ -689,7 +690,10 @@ export class BaseExecutor { // Track per-URL intra-retry attempts to avoid infinite loops const retryAttemptsByUrl: Record = {}; - if (this.needsRefresh(credentials)) { + // Probe-origin dispatches must not consume a refresh-token rotation — + // routing state untouched; the reactive 401/403 path is probe-guarded + // in chatCore (#9817). + if (!isProbeContext() && this.needsRefresh(credentials)) { try { // Fix A: wire onCredentialsRefreshed through runWithOnPersist so it runs // INSIDE the per-connection mutex inside getAccessToken. Not every @@ -800,7 +804,14 @@ export class BaseExecutor { activeCredentials ); const url = this.buildUrl(model, stream, urlIndex, requestCredentials); - const headers = this.buildHeaders(requestCredentials, stream, clientHeaders, model, undefined, body); + const headers = this.buildHeaders( + requestCredentials, + stream, + clientHeaders, + model, + undefined, + body + ); applyConfiguredUserAgent(headers, requestCredentials?.providerSpecificData); // Strip OpenAI SDK (X-Stainless-*) metadata + normalize SDK-derived User-Agent diff --git a/open-sse/executors/base/reasoningEffort.ts b/open-sse/executors/base/reasoningEffort.ts index f3d138bb79..9b8ffb1da0 100644 --- a/open-sse/executors/base/reasoningEffort.ts +++ b/open-sse/executors/base/reasoningEffort.ts @@ -6,6 +6,7 @@ import { supportsClaudeMaxEffort, supportsXHighEffort, getProviderModel, + getProviderModels, } from "../../config/providerModels.ts"; /** @@ -351,6 +352,31 @@ export function sanitizeReasoningEffortForProvider( // new models from being unusable for weeks until they're whitelisted (#8057). if (effortStr === "max") { if (supportsMax) return body; // explicitly known to accept max + + // A model that explicitly advertises its accepted tiers is safe to normalize. + // Keep the default pass-through for absent metadata: an unlisted model might + // support literal `max`, and #8057 deliberately avoids blocking such models. + const providerModelId = modelStr.startsWith(`${provider}/`) + ? modelStr.slice(provider.length + 1) + : modelStr; + // Do not fall back to a globally registered model here. Identical ids can + // have different upstream contracts across providers (for example, OpenCode + // and SenseNova both expose deepseek-v4-flash with different max support). + const explicitEfforts = getProviderModels(provider).find( + (entry) => entry.id === providerModelId || entry.aliases?.includes(providerModelId) + )?.supportedThinkingEfforts; + const maxFallback = + Array.isArray(explicitEfforts) && !explicitEfforts.includes("max") + ? ["xhigh", "high", "medium", "low"].find((tier) => explicitEfforts.includes(tier)) + : undefined; + if (maxFallback) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: downgraded reasoning_effort max → ${maxFallback} (explicit model capability)` + ); + return writeEffortValue(b, maxFallback, c); + } + if (!supportsXHigh) { // Model is explicitly flagged as rejecting xhigh (and not in supportsMax) — // it likely only accepts standard tiers. Degrade to its highest: high. @@ -360,7 +386,6 @@ export function sanitizeReasoningEffortForProvider( ); return writeEffortValue(b, "high", c); } - // Default: pass max through unchanged — trust the upstream return body; } diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 0168d07de2..438565b45c 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -32,7 +32,11 @@ import { __resetChatGptImageCacheForTesting, type ChatGptImageConversationContext, } from "../services/chatgptImageCache.ts"; -import { isThinkingCapableModel, resolveChatGptModel } from "./chatgpt-web/models.ts"; +import { + resolveChatGptModel, + resolveChatGptSystemHints, + type ChatGptThinkingEffort, +} from "./chatgpt-web/models.ts"; import { cleanChatGptText } from "./chatgpt-web/citations.ts"; import { resumeChatGptHandoff, type FinalAssistantAnswer } from "./chatgpt-web/handoff.ts"; @@ -43,7 +47,6 @@ const SESSION_URL = `${CHATGPT_BASE}/api/auth/session`; const SENTINEL_PREPARE_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements/prepare`; const SENTINEL_CR_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements`; const CONV_URL = `${CHATGPT_BASE}/backend-api/f/conversation`; -const USER_LAST_USED_MODEL_CONFIG_URL = `${CHATGPT_BASE}/backend-api/settings/user_last_used_model_config`; const DEFAULT_PRO_POLL_TIMEOUT_MS = 20 * 60_000; const DEFAULT_PRO_POLL_INTERVAL_MS = 4_000; @@ -81,10 +84,8 @@ function deviceIdFor(cookie: string): string { return id; } -// OmniRoute model ID → ChatGPT internal slug. The public ChatGPT Web catalog -// keeps OmniRoute's historical dot-form IDs (e.g. "gpt-5.5-pro"), while -// ChatGPT's backend routes use dash-form slugs (e.g. "gpt-5-5-pro"). The slug -// catalog comes from /backend-api/models on a logged-in account. +// OmniRoute model IDs select a GPT-5.6 Sol performance lane. Captured browser +// requests use one of `gpt-5-6`, `gpt-5-6-thinking`, or `gpt-5-6-pro`. // ─── Browser-like default headers ────────────────────────────────────────── @@ -408,25 +409,6 @@ async function runSessionWarmup( } } -// ─── Thinking-effort preference (PATCH user_last_used_model_config) ──────── -// chatgpt.com has two thinking levels for its dedicated thinking-models: -// • standard — default, faster -// • extended — longer reasoning budget -// The browser sets the level by PATCHing `/backend-api/settings/user_last_used_model_config` -// once, then issues the conversation request — the conversation endpoint itself -// has no `thinking_effort` field; the server reads the user's stored preference -// at routing time. We mirror that handshake when an OpenAI-style request -// includes `reasoning_effort` (or a direct `providerSpecificData.thinkingEffort` -// override). -// -// Cached per (cookie, slug, effort): the preference persists server-side, so -// re-PATCHing the same combination is wasted bytes. Refreshed on TTL expiry or -// whenever the caller switches efforts. - -const thinkingEffortCache = new Map(); -const THINKING_EFFORT_TTL_MS = 5 * 60 * 1000; -const THINKING_EFFORT_CACHE_MAX = 400; - function configuredProPollTimeoutMs(): number { const raw = Number(process.env.OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS); if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_PRO_POLL_TIMEOUT_MS; @@ -439,73 +421,6 @@ function configuredProPollIntervalMs(): number { return Math.floor(raw); } -async function setUserThinkingEffort( - modelSlug: string, - effort: "standard" | "extended" | "max", - accessToken: string, - accountId: string | null, - sessionId: string, - deviceId: string, - cookie: string, - signal: AbortSignal | null | undefined, - log: - | { - debug?: (tag: string, msg: string) => void; - warn?: (tag: string, msg: string) => void; - } - | null - | undefined -): Promise { - const cacheKey = `${cookieKey(cookie)}:${modelSlug}:${effort}`; - const now = Date.now(); - const last = thinkingEffortCache.get(cacheKey); - if (last && now - last < THINKING_EFFORT_TTL_MS) { - log?.debug?.("CGPT-WEB", `thinking_effort cached (${modelSlug}=${effort}) — skip PATCH`); - return; - } - if (thinkingEffortCache.size >= THINKING_EFFORT_CACHE_MAX && !thinkingEffortCache.has(cacheKey)) { - const first = thinkingEffortCache.keys().next().value; - if (first) thinkingEffortCache.delete(first); - } - - const url = - `${USER_LAST_USED_MODEL_CONFIG_URL}` + - `?model_slug=${encodeURIComponent(modelSlug)}` + - `&thinking_effort=${encodeURIComponent(effort)}`; - const headers: Record = { - ...browserHeaders(), - ...oaiHeaders(sessionId, deviceId), - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - Cookie: buildSessionCookieHeader(cookie), - Priority: "u=4", - }; - if (accountId) headers["chatgpt-account-id"] = accountId; - - try { - const r = await tlsFetchChatGpt(url, { - method: "PATCH", - headers, - timeoutMs: 15_000, - signal, - }); - if (r.status >= 400) { - log?.warn?.( - "CGPT-WEB", - `thinking_effort PATCH ${r.status} for ${modelSlug}=${effort} (continuing)` - ); - return; - } - thinkingEffortCache.set(cacheKey, now); - log?.debug?.("CGPT-WEB", `thinking_effort PATCH OK (${modelSlug}=${effort})`); - } catch (err) { - log?.warn?.( - "CGPT-WEB", - `thinking_effort PATCH failed: ${err instanceof Error ? err.message : String(err)}` - ); - } -} - async function prepareChatRequirements( accessToken: string, accountId: string | null, @@ -889,6 +804,7 @@ interface ChatGptMessage { id: string; author: { role: string }; content: { content_type: "text"; parts: string[] }; + metadata?: Record; } /** @@ -984,7 +900,8 @@ function buildConversationBody( // chatgpt.com history. Disable Temporary Chat only when ChatGPT needs a // durable image conversation (image generation/editing). persistConversation: boolean; - thinkingEffort: "standard" | "extended" | "max" | null; + thinkingEffort: ChatGptThinkingEffort | null; + systemHints: readonly string[]; continuation?: ChatGptImageConversationContext | null; } ): Record { @@ -1021,6 +938,8 @@ function buildConversationBody( }); } + const systemHints = options.systemHints; + const currentUserContent = hasOpenWebUIImageContext(parsed) ? "Briefly acknowledge the image result described in the system context. Do not generate, edit, or request another image." : parsed.currentMsg || ""; @@ -1029,6 +948,7 @@ function buildConversationBody( id: randomUUID(), author: { role: "user" }, content: { content_type: "text", parts: [currentUserContent] }, + ...(systemHints.length > 0 ? { metadata: { system_hints: [...systemHints] } } : {}), }); return { @@ -1051,6 +971,7 @@ function buildConversationBody( supports_buffering: true, force_parallel_switch: "auto", paragen_cot_summary_display_override: "allow", + ...(systemHints.length > 0 ? { system_hints: [...systemHints] } : {}), ...(options.thinkingEffort ? { thinking_effort: options.thinkingEffort } : {}), }; } @@ -2920,24 +2841,6 @@ export class ChatGptWebExecutor extends BaseExecutor { log ); - // 2a''. Apply thinking-effort preference for thinking models. - // Dedicated thinking models mirror the browser's user-config PATCH; - // GPT-5.5 Pro effort is sent with the conversation body. - const requestedEffort = resolvedModel.effort; - if (requestedEffort && isThinkingCapableModel(model, modelSlug)) { - await setUserThinkingEffort( - modelSlug, - requestedEffort, - tokenEntry.accessToken, - tokenEntry.accountId, - sessionId, - deviceId, - cookie, - signal, - log - ); - } - // 2b. Sentinel chat-requirements let reqs: ChatRequirements; try { @@ -3019,7 +2922,7 @@ export class ChatGptWebExecutor extends BaseExecutor { } // Toggle Temporary Chat off only when ChatGPT needs a durable image - // conversation. Text requests, including GPT-5.5 Pro, stay temporary so + // conversation. Text requests, including GPT-5.6 Sol Pro, stay temporary so // they do not show up in the user's chatgpt.com sidebar/history. const imageEdit = looksLikeImageEditRequest(parsed); const continuation = imageEdit ? parsed.latestImageContext : null; @@ -3033,13 +2936,14 @@ export class ChatGptWebExecutor extends BaseExecutor { : "Image-gen intent detected — disabling Temporary Chat for this turn" ); } else if (resolvedModel.isPro) { - log?.debug?.("CGPT-WEB", "GPT-5.5 Pro text request — keeping Temporary Chat enabled"); + log?.debug?.("CGPT-WEB", "GPT-5.6 Sol Pro text request — keeping Temporary Chat enabled"); } const parentMessageId = continuation?.parentMessageId ?? randomUUID(); const cgptBody = buildConversationBody(parsed, modelSlug, parentMessageId, { persistConversation, - thinkingEffort: requestedEffort, + thinkingEffort: resolvedModel.effort, + systemHints: resolveChatGptSystemHints(model), continuation, }); @@ -3230,7 +3134,6 @@ function stringToStream(text: string): ReadableStream { export function __resetChatGptWebCachesForTesting(): void { tokenCache.clear(); warmupCache.clear(); - thinkingEffortCache.clear(); deviceIdCache.clear(); __resetChatGptImageCacheForTesting(); dplCache = null; diff --git a/open-sse/executors/chatgpt-web/models.ts b/open-sse/executors/chatgpt-web/models.ts index 87abd04880..1917437baa 100644 --- a/open-sse/executors/chatgpt-web/models.ts +++ b/open-sse/executors/chatgpt-web/models.ts @@ -3,106 +3,60 @@ export const MODEL_MAP: Record = { // ChatGPT backend slugs are also accepted directly for power users / tests. - "gpt-5-6-pro": "gpt-5-6-pro", + "gpt-5-6": "gpt-5-6", "gpt-5-6-thinking": "gpt-5-6-thinking", - "gpt-5-5-pro": "gpt-5-5-pro", - "gpt-5-5-pro-extended": "gpt-5-5-pro", - "gpt-5-5-thinking": "gpt-5-5-thinking", + "gpt-5-6-pro": "gpt-5-6-pro", "gpt-5-5": "gpt-5-5", - "gpt-5-3": "gpt-5-3", - "gpt-5-3-mini": "gpt-5-3-mini", + "gpt-5-5-thinking": "gpt-5-5-thinking", + "gpt-5-5-pro": "gpt-5-5-pro", - // Public OmniRoute dot-form ids exposed by the provider catalog. - "gpt-5.6-pro": "gpt-5-6-pro", - "gpt-5.6-thinking": "gpt-5-6-thinking", + // Free accounts leave Luna selection to ChatGPT's server-side auto router. + "gpt-5.6-luna-free": "auto", + "gpt-5.6-luna-free-thinking": "auto", + + // Captured from a real ChatGPT v2 picker conversation. The visible + // performance levels select distinct backend model/effort pairs. + "gpt-5.6-sol-instant": "gpt-5-6", + "gpt-5.6-sol-medium": "gpt-5-6-thinking", + "gpt-5.6-sol-high": "gpt-5-6-thinking", + "gpt-5.6-sol-xhigh": "gpt-5-6-thinking", + "gpt-5.6-sol-pro": "gpt-5-6-pro", + + "gpt-5.5-instant": "gpt-5-5", + "gpt-5.5-medium": "gpt-5-5-thinking", + "gpt-5.5-high": "gpt-5-5-thinking", + "gpt-5.5-xhigh": "gpt-5-5-thinking", "gpt-5.5-pro": "gpt-5-5-pro", "gpt-5.5-pro-extended": "gpt-5-5-pro", - "gpt-5.5-thinking": "gpt-5-5-thinking", + // Compatibility alias for existing chatgpt-web image integrations. It is + // intentionally absent from the provider's visible curated model list. "gpt-5.5": "gpt-5-5", - "gpt-5.3-instant": "gpt-5-3-instant", - "gpt-5.3": "gpt-5-3", - "gpt-5.3-mini": "gpt-5-3-mini", - o3: "o3", }; export type ChatGptThinkingEffort = "standard" | "extended" | "max"; -export const MODEL_FORCED_EFFORT: Record = { - "gpt-5-6-pro": "standard", - "gpt-5.6-pro": "standard", - "gpt-5-5-pro": "standard", - "gpt-5-5-pro-extended": "extended", +export const MODEL_FORCED_EFFORT: Record = { + "gpt-5.6-sol-instant": null, + "gpt-5.6-sol-medium": "standard", + "gpt-5.6-sol-high": "extended", + "gpt-5.6-sol-xhigh": "max", + "gpt-5.6-sol-pro": "standard", + "gpt-5.5-instant": null, + "gpt-5.5-medium": "standard", + "gpt-5.5-high": "extended", + "gpt-5.5-xhigh": "max", "gpt-5.5-pro": "standard", "gpt-5.5-pro-extended": "extended", }; -/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint - * accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a - * new thinking entry there automatically extends this set. - * - * Derived from MODEL_MAP keys (always dot-form) that contain "thinking" or - * are the `o3` reasoning model; the values are the chatgpt.com-side slugs. */ -export const THINKING_CAPABLE_SLUGS: ReadonlySet = new Set( - Object.entries(MODEL_MAP) - .filter(([k]) => k.includes("thinking") || k === "o3") - .map(([, v]) => v) -); +const MODEL_SYSTEM_HINTS: Record = { + // Captured from the Free-account Think toggle. ChatGPT sends this both at + // the request root and on the user message metadata. + "gpt-5.6-luna-free-thinking": ["reason"], +}; -/** chatgpt.com only exposes the thinking-effort toggle on dedicated thinking - * models and the o-series. PATCHing for a non-thinking surface is a no-op - * (the server accepts it but the routing-time read picks the wrong knob). - * - * The lookup also catches callers that pass a chatgpt.com slug directly as - * the `model` field without MODEL_MAP translation. */ -export function isThinkingCapableModel(modelId: string, slug: string): boolean { - return ( - modelId.includes("thinking") || - modelId === "o3" || - slug.includes("thinking") || - THINKING_CAPABLE_SLUGS.has(slug) || - THINKING_CAPABLE_SLUGS.has(modelId) - ); -} - -/** Map either a chatgpt.com-native value (`standard`/`extended`/`max`) or the - * OpenAI Chat Completions `reasoning_effort` field to the value the - * `user_last_used_model_config` endpoint expects. - * - * minimal | low | medium | standard → standard - * high | extended → extended - * xhigh | max → max - * - * `xhigh` remains a compatibility alias for the highest ChatGPT Web tier. - * Returns null for absent/unknown inputs. */ -export function normalizeThinkingEffort(input: unknown): ChatGptThinkingEffort | null { - if (typeof input !== "string") return null; - const v = input.trim().toLowerCase(); - if (v === "max" || v === "xhigh") return "max"; - if (v === "extended" || v === "high") return "extended"; - if (v === "standard" || v === "low" || v === "medium" || v === "minimal") { - return "standard"; - } - return null; -} - -/** Resolve the requested effort for this turn. - * Order: `providerSpecificData.thinkingEffort` (raw override, takes native - * `standard`/`extended`/`max` values) > `body.reasoning_effort` (top-level - * OpenAI Chat Completions field) > `body.reasoning.effort` (Responses-API - * nesting). Returns null when the caller did not request one. */ -export function resolveThinkingEffort( - body: unknown, - providerSpecificData: Record | undefined -): ChatGptThinkingEffort | null { - if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) { - return normalizeThinkingEffort(providerSpecificData.thinkingEffort); - } - const b = (body as Record | null) ?? null; - if (!b) return null; - const top = normalizeThinkingEffort(b.reasoning_effort); - if (top) return top; - const nested = (b.reasoning as Record | undefined)?.effort; - return normalizeThinkingEffort(nested); +export function resolveChatGptSystemHints(model: string): string[] { + return [...(MODEL_SYSTEM_HINTS[model] ?? [])]; } export interface ResolvedChatGptModel { @@ -113,12 +67,16 @@ export interface ResolvedChatGptModel { export function resolveChatGptModel( model: string, - body: unknown, - providerSpecificData: Record | undefined + _body?: unknown, + _providerSpecificData?: Record ): ResolvedChatGptModel { const slug = MODEL_MAP[model] ?? model; - const forcedEffort = MODEL_FORCED_EFFORT[model] ?? null; - const effort = forcedEffort ?? resolveThinkingEffort(body, providerSpecificData); - const isPro = slug === "gpt-5-6-pro" || slug === "gpt-5-5-pro"; + const effort = MODEL_FORCED_EFFORT[model] ?? null; + const isPro = + model === "gpt-5.6-sol-pro" || + model === "gpt-5.5-pro" || + model === "gpt-5.5-pro-extended" || + slug === "gpt-5-6-pro" || + slug === "gpt-5-5-pro"; return { slug, effort, isPro }; } diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 93e5a2fe16..ab595c3cbb 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -20,6 +20,7 @@ import { import { FETCH_BODY_TIMEOUT_MS, HTTP_STATUS, PROVIDERS } from "../config/constants.ts"; import { readCodexPeekChunk, buildCodexTimeoutSafePassthroughBody } from "./codex/bodyTimeout.ts"; import { + CODEX_CLI_RS_ORIGINATOR, getCodexClientVersion, getCodexUserAgent, normalizeCodexSessionId, @@ -33,7 +34,7 @@ import { } from "../config/codexIdentity.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; -import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts"; +import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts"; import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; @@ -41,8 +42,8 @@ import { errorResponse } from "../utils/error.ts"; import { normalizeCodexResponsesInput } from "../utils/responsesInputNormalization.ts"; import * as prl from "../utils/providerRequestLogging.ts"; import { createRequire } from "module"; -// Quota parsing/scheduling extracted to a pure leaf; re-exported for external -// importers (handlers/chatCore/codexQuota.ts + tests). +// Quota parsing/scheduling extracted to a pure leaf; re-exported for the +// Codex account module and tests. export { type CodexQuotaSnapshot, parseCodexQuotaHeaders, @@ -225,7 +226,6 @@ function convertSystemToDeveloperRole(body: Record): void { } } - function stripOrphanedCodexFunctionCallOutputs(body: Record): void { if (!Array.isArray(body.input)) return; const input = body.input; @@ -1045,10 +1045,11 @@ export class CodexExecutor extends BaseExecutor { CodexClientIdentity | null | undefined; const originalIdentityHeaders = credentials?.providerSpecificData ?.codexOriginalIdentityHeaders as Record | null | undefined; + const turnStateEcho = credentials?.providerSpecificData?.codexTurnStateEcho; // Originator header — identifies the client type to the Codex backend. // Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs" - headers["originator"] = "codex_cli_rs"; + headers["originator"] = CODEX_CLI_RS_ORIGINATOR; // session_id header — enables prompt cache affinity on the Codex backend. // The official Codex client sets this to conversation_id (a stable UUID per session). @@ -1060,6 +1061,13 @@ export class CodexExecutor extends BaseExecutor { applyCodexOriginalIdentityHeaders(headers, originalIdentityHeaders); applyCodexClientIdentityHeaders(headers, clientIdentity); + // x-codex-turn-state: forward the client's echo when the provenance guard + // (in withCodexFingerprintCredentials) cleared it as same-account. The + // blob is account-bound; a stripped (absent) value must stay absent. + if (typeof turnStateEcho === "string" && turnStateEcho) { + headers["x-codex-turn-state"] = turnStateEcho; + } + return headers; } @@ -1381,10 +1389,12 @@ export class CodexExecutor extends BaseExecutor { delete body.session_id; delete body.conversation_id; - applyResponsesInputPolicy( - body, - credentials?.providerSpecificData?.preserveEncryptedReasoning === true - ); + applyReasoningInputPolicy(body, "responses", { + provider: "codex", + preserveEncryptedReasoning: + credentials?.providerSpecificData?.preserveEncryptedReasoning === true, + onIncompatibleReasoning: "drop", + }); if (nativeCodexPassthrough) { return body; diff --git a/open-sse/executors/copilot-m365-connection.ts b/open-sse/executors/copilot-m365-connection.ts index 0c5303c250..c8251af5f4 100644 --- a/open-sse/executors/copilot-m365-connection.ts +++ b/open-sse/executors/copilot-m365-connection.ts @@ -8,6 +8,7 @@ * the URL MUST go through redactWsUrl(). */ +import { resolvePublicCred } from "../utils/publicCreds.ts"; import { randomUUID, randomBytes } from "node:crypto"; import type { ProviderCredentials } from "./base.ts"; @@ -160,7 +161,16 @@ export function resolveConnectionParams( const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; const parsedApiKey = typeof credentials?.apiKey === "string" ? parsePastedCredential(credentials.apiKey) : {}; + // A JWT in credentials.accessToken (3 dot-separated parts — the individual-tier + // token is an opaque JWE with 5) is the freshest copy: the executor refreshes it + // in place before resolving params, and the framework mutates it after a refresh. + const credentialsJwt = + typeof credentials?.accessToken === "string" && + credentials.accessToken.split(".").length === 3 + ? credentials.accessToken + : ""; const accessToken = + credentialsJwt || parsedApiKey.accessToken || (typeof credentials?.apiKey === "string" && credentials.apiKey && @@ -254,6 +264,135 @@ export function redactWsUrl(wsUrl: string): string { return wsUrl.replace(/access_token=[^&]*/i, "access_token=REDACTED"); } +// ── OAuth refresh support (#10718 — client ids observed in the browser token +// and M365-Copilot2API) ──────────────────────────────────────────────────── +// +// The browser-issued access_token lives ~75 minutes. These helpers redeem a +// stored refresh_token at the Microsoft identity platform (same public client +// the m365.cloud.microsoft web app uses) so the connection self-heals instead +// of requiring a fresh DevTools capture after every expiry. + +/** Public client id observed in both the browser token and M365-Copilot2API. */ +export const M365_OAUTH_CLIENT_ID = resolvePublicCred("m365_oauth_client_id"); + +export const M365_OAUTH_SCOPE = + "openid profile offline_access https://substrate.office.com/sydney/M365Chat.Read " + + "https://substrate.office.com/sydney/sydney.readwrite"; + +/** Refresh lead time — refresh when the current token has less than this left. */ +export const M365_REFRESH_LEAD_MS = 5 * 60 * 1000; + +type MinimalLog = { + info?: (tag: string, message: string) => void; + warn?: (tag: string, message: string) => void; +}; + +/** Decode a JWT payload WITHOUT verification — exp/tid are routing hints, never authz. */ +export function decodeJwtClaims( + token: string +): { exp?: number; tid?: string; oid?: string } | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); + return payload && typeof payload === "object" ? payload : null; + } catch { + return null; + } +} + +/** True when the token is unreadable, already expired, or inside the refresh lead window. */ +export function tokenNeedsRefresh(token: string, leadMs = M365_REFRESH_LEAD_MS): boolean { + const claims = decodeJwtClaims(token); + if (!claims?.exp) return true; + return claims.exp * 1000 <= Date.now() + leadMs; +} + +/** The freshest readable access token for a connection (JWT column → apiKey → psd). */ +export function currentM365AccessToken( + credentials: ProviderCredentials | undefined +): string { + if ( + typeof credentials?.accessToken === "string" && + credentials.accessToken.split(".").length === 3 + ) { + return credentials.accessToken; + } + if (typeof credentials?.apiKey === "string") { + const parsed = parsePastedCredential(credentials.apiKey); + if (parsed.accessToken && parsed.accessToken.split(".").length === 3) return parsed.accessToken; + // Opaque (JWE) individual-tier token — still a usable credential, just not refreshable. + return parsed.accessToken || ""; + } + const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; + if (typeof psd.accessToken === "string") return psd.accessToken; + if (typeof psd.access_token === "string") return psd.access_token; + return ""; +} + +/** The chathub path (`@`) from wherever it is stored. */ +export function currentM365ChathubPath(credentials: ProviderCredentials | undefined): string { + const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; + return ( + (typeof credentials?.apiKey === "string" + ? parsePastedCredential(credentials.apiKey).chathubPath + : "") || + (typeof psd.chathubPath === "string" && psd.chathubPath) || + (typeof psd.userTenant === "string" && psd.userTenant) || + "" + ); +} + +export interface M365RefreshResult { + accessToken: string; + refreshToken?: string; + expiresIn?: number; +} + +/** + * Redeem the refresh_token (public client — no secret). MS may rotate the + * refresh_token; callers MUST persist the returned one when present or the + * token family dies after the first refresh. + */ +export async function refreshM365AccessToken( + refreshToken: string, + tid: string, + log?: MinimalLog +): Promise { + const endpoint = `https://login.microsoftonline.com/${tid || "common"}/oauth2/v2.0/token`; + try { + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: M365_OAUTH_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + scope: M365_OAUTH_SCOPE, + }), + }); + const data = (await res.json().catch(() => ({}))) as Record; + if (!res.ok || typeof data.access_token !== "string") { + const error = typeof data.error === "string" ? data.error : `HTTP ${res.status}`; + log?.warn?.("M365_TOKEN", `refresh_token grant failed: ${error}`); + return { error }; + } + log?.info?.("M365_TOKEN", "access token refreshed via refresh_token grant"); + return { + accessToken: data.access_token, + refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : undefined, + expiresIn: typeof data.expires_in === "number" ? data.expires_in : undefined, + }; + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + log?.warn?.("M365_TOKEN", `refresh request failed: ${error}`); + return { error }; + } +} + /** Flatten OpenAI messages into a single prompt (system instructions prepended). */ export function buildPrompt(body: JsonRecord | undefined): string { const messages = (body?.messages as Array) || []; diff --git a/open-sse/executors/copilot-m365-frames.ts b/open-sse/executors/copilot-m365-frames.ts index add2716ee0..c8c6dee7be 100644 --- a/open-sse/executors/copilot-m365-frames.ts +++ b/open-sse/executors/copilot-m365-frames.ts @@ -11,7 +11,9 @@ * Protocol (from @skyzea1's #4042 capture): * - JSON messages terminated with the SignalR record separator `\x1e`. * - Handshake: → {"protocol":"json","version":1} ← {} → {"type":6} - * - Send: type:4 invocation to target "chat" with arguments[0] = { message, ... } + * - Send: type:4 invocation to target "chat" with arguments[0] = { message, ... }, + * immediately followed by a type:1 target:"Metrics" frame in the SAME socket + * write (#10718 — an invocation without its Metrics pair is silently dropped). * - Stream: type:1 target:"update" deltas (bot text at arguments[0].messages[].text, * accumulated — NOT incremental) → isLastUpdate:true → type:2 final → type:3 completion. */ @@ -25,19 +27,18 @@ export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const; /** SignalR keepalive ping frame. */ export const KEEPALIVE_PING = { type: 6 } as const; -/** Allowed message types observed in the individual M365 send frame. */ +/** + * Allowed message types observed in the 2026-08 recapture of the working + * `m365.cloud.microsoft/chat` client (#10718). The old 11-entry list is no longer + * seen on the wire — the stale shape gets closed immediately after the type:4. + */ export const ALLOWED_MESSAGE_TYPES = [ "Chat", "Suggestion", - "InternalSearchQuery", "Disengaged", - "InternalLoaderMessage", "Progress", - "GeneratedCode", - "RenderCardRequest", - "AdsQuery", - "SemanticSerp", - "GenerateContentQuery", + "EndOfRequest", + "InternalLoaderMessage", ] as const; /** @@ -74,22 +75,20 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [ "SwitchRespondingEndpoint", ] as const; +/** + * Individual / EDU option sets from the 2026-08 recapture (#10718) — 14 entries. + * The previous 25-entry consumer/MSA set (enable_msa_user, pdnascan, cwc_code_*, + * …) is no longer observed on the wire and belongs to the shape the substrate + * now drops silently. + */ export const M365_DEFAULT_OPTION_SETS = [ "search_result_progress_messages_with_search_queries", "update_textdoc_response_after_streaming", "deepleo_networking_timeout_10minutes_canmore", "cwc_flux_image", - "cwc_code_interpreter", - "cwc_code_interpreter_amsfix", - "enable_msa_user", - "cwcgptv", + "cwcfluxgptv", "flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch", "gptvnorm2048", - "pdnascan", - "cwc_code_interpreter_citation_fix", - "code_interpreter_interactive_charts", - "cwc_code_interpreter_interactive_charts_inline_image", - "code_interpreter_matplotlib_patching", "cwc_fileupload_odb", "update_memory_plugin", "add_custom_instructions", @@ -97,9 +96,6 @@ export const M365_DEFAULT_OPTION_SETS = [ "flux_v3_progress_messages", "enable_batch_token_processing", "enable_gg_gpt", - "flux_v3_image_gen_enable_non_watermarked_storage", - "flux_v3_image_gen_enable_story", - "rich_responses", ] as const; /** Append the record separator to a JSON-serializable frame. */ @@ -117,6 +113,32 @@ export function keepaliveFrame(): string { return encodeFrame(KEEPALIVE_PING); } +/** + * #10718 — the browser follows the type:4 chat invocation with this type:1 + * target:"Metrics" frame in the SAME socket write. Sending the invocation alone + * gets it silently ignored (no update frames at all), so the executor must + * concatenate `metricsFrame()` onto the invocation payload. + */ +export const CHAT_METRICS_FRAME = { + arguments: [ + { + Timestamps: { + ConnectionEstablished: "", + ConnectionStart: "", + UserInputStart: "", + UserInputSubmit: "", + }, + }, + ], + target: "Metrics", + type: 1, +} as const; + +/** Serialized Metrics follow-up frame (see {@link CHAT_METRICS_FRAME}). */ +export function metricsFrame(): string { + return encodeFrame(CHAT_METRICS_FRAME); +} + /** * Split a raw socket buffer into complete `\x1e`-terminated frames, returning any * trailing partial frame as `rest` so it can be prepended to the next chunk. @@ -155,22 +177,37 @@ export function handshakeError(frame: Record | null): string | export interface ChatInvocationOptions { text: string; - /** Per-connection trace id (hex), reused as clientCorrelationId/traceId. */ + /** Per-invocation trace id (GUID). */ traceId: string; - /** Per-session id (GUID). */ + /** Client correlation id; defaults to {@link ChatInvocationOptions.traceId}. */ + clientCorrelationId?: string; + /** Per-session id (GUID, == the WS URL X-SessionId query). */ sessionId: string; + /** Per-request id (== the WS URL chatsessionid/clientrequestid query). */ + requestId: string; + /** + * Conversation id — MUST match the ConversationId query of the WS URL the + * invocation rides on (#10718: the server cross-checks the two). + */ + conversationId: string; + /** BCP-47 locale echoed in message.locale; defaults to "en-us". */ + locale?: string; + /** IANA time zone for message.locationInfo; defaults to "UTC". */ + timeZone?: string; + /** Hour offset for message.locationInfo; defaults to 0. */ + timeZoneOffset?: number; /** Whether this is the first turn of the conversation. */ isStartOfSession?: boolean; - /** Tier-specific option flags; left empty by default (tuned during live validation). */ + /** Tier-specific option flags; defaults to {@link M365_DEFAULT_OPTION_SETS}. */ optionsSets?: string[]; tone?: string; /** Tier-specific allowed message types; defaults to {@link ALLOWED_MESSAGE_TYPES}. */ allowedMessageTypes?: readonly string[]; /** - * Tier-specific disconnect behavior sent in every type:4 chat invocation. The work - * Surface rejects any value other than exactly "continue" (#8971). Defaults to "" - * for individual/consumer/EDU tiers; {@link resolveChatInvocationOverrides} returns - * "continue" for the enterprise tier. + * Tier-specific disconnect behavior sent in the type:4 chat invocation. The work + * surface rejects any value other than exactly "continue" (#8971), so the + * enterprise tier sends it; the 2026-08 recapture shows the individual/EDU + * surface omits the key entirely, so it is left out unless set (#10718). */ disconnectBehavior?: string; } @@ -185,7 +222,7 @@ export function resolveChatInvocationOverrides(tier: string | undefined): { optionsSets: string[]; tone: string; allowedMessageTypes: readonly string[]; - disconnectBehavior: string; + disconnectBehavior: string | undefined; } { if (tier === "enterprise") { return { @@ -197,9 +234,12 @@ export function resolveChatInvocationOverrides(tier: string | undefined): { } return { optionsSets: [...M365_DEFAULT_OPTION_SETS], - tone: "", + // #10718 — the 2026-08 recapture sends tone:"magic" (lowercase) on the + // individual/EDU surface; the old "" default is part of the dropped shape. + tone: "magic", allowedMessageTypes: ALLOWED_MESSAGE_TYPES, - disconnectBehavior: "", + // Omitted entirely on the individual/EDU wire (see ChatInvocationOptions). + disconnectBehavior: undefined, }; } @@ -207,7 +247,7 @@ export function resolveChatInvocationOverrides(tier: string | undefined): { * BizChat exposes several models selected by the `tone` field of the `type:4` chat * invocation (#7872, values confirmed against a real enterprise tenant in #7850). Each * tone-selected variant is registered as its own model id; the bare `copilot-m365` id is - * intentionally absent here so it keeps the tier default tone (`Magic` on enterprise, `""` + * intentionally absent here so it keeps the tier default tone (`Magic` on enterprise, `magic` * otherwise) resolved by {@link resolveChatInvocationOverrides}. */ export const M365_MODEL_TONE_MAP: Readonly> = { @@ -228,7 +268,14 @@ export function resolveToneForModel(model: string | undefined): string | undefin /** * Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated). - * Mirrors the argument shape captured on the individual M365 path in #4042. + * Mirrors the argument shape recaptured from a working `m365.cloud.microsoft/chat` + * client in 2026-08 (#10718). Notable differences from the pre-#10718 shape: a + * populated `clientInfo` + `productThreadType:"Office"`, a `conversationId` + * matching the WS URL query, a rich `message` object, and no + * `spokenTextMode` / `extraExtensionParameters` / `isSbsSupported` / + * `renderReferencesBehindEOS` / `disconnectBehavior` — none of those are still + * observed on the wire, and the stale shape gets closed immediately after the + * invocation. */ export function buildChatInvocation(opts: ChatInvocationOptions): Record { return { @@ -237,33 +284,48 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record { - ws?.send(keepaliveFrame()); const overrides = resolveChatInvocationOverrides(input.tier); // Model-driven tone (#7872) wins over the tier default; a bare/unknown id // keeps the tier tone resolved above. const tone = resolveToneForModel(input.model) ?? overrides.tone; - ws?.send( - encodeFrame( - buildChatInvocation({ - text: input.prompt, - traceId, - sessionId, - isStartOfSession: true, - ...overrides, - tone, - }) - ) + const invocationFrame = encodeFrame( + buildChatInvocation({ + text: input.prompt, + traceId, + sessionId, + requestId, + conversationId, + isStartOfSession: true, + ...overrides, + tone, + }) ); + // #10718 — the invocation and its type:1 Metrics follow-up must land + // in ONE socket write, exactly as the browser sends them; a bare + // invocation (or one preceded by a type:6 ping) is silently dropped. + ws?.send(invocationFrame + metricsFrame()); }; ws.on("open", () => { @@ -273,6 +290,62 @@ export class CopilotM365WebExecutor extends BaseExecutor { ); } + /** + * #10718 — proactively refresh the M365 access token before opening the WS. + * A WS-handshake 401 surfaces as an error event INSIDE the SSE stream (the HTTP + * response is already 200 by then), so chatCore's generic 401→refresh→retry + * orchestration never triggers — the refresh has to happen here, pre-flight. + * No-ops for legacy connections without a stored refresh_token. + */ + private async ensureFreshCredentials( + credentials: ExecuteInput["credentials"], + onCredentialsRefreshed: ExecuteInput["onCredentialsRefreshed"], + log: ExecutorLog | null + ): Promise { + const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; + const refreshToken = + credentials.refreshToken || (typeof psd.refreshToken === "string" ? psd.refreshToken : ""); + if (!refreshToken) return; + + const current = currentM365AccessToken(credentials); + if (current && !tokenNeedsRefresh(current)) return; + + const tid = + decodeJwtClaims(current)?.tid || (typeof psd.tid === "string" ? psd.tid : "") || ""; + const result = await refreshM365AccessToken(refreshToken, tid, log ?? undefined); + if ("error" in result) { + // Fall through with the existing token — the WS layer will surface the failure. + return; + } + + const rotated = result.refreshToken || refreshToken; + const chathubPath = currentM365ChathubPath(credentials); + const assembledApiKey = chathubPath + ? ["access_token=", result.accessToken, "; chathubPath=", chathubPath].join("") + : ""; + const next = { + ...credentials, + accessToken: result.accessToken, + refreshToken: rotated, + // Keep the pasted-format apiKey self-consistent so every resolution path + // (fresh column, stale column, dashboard re-read) sees the same token. + ...(assembledApiKey ? { apiKey: assembledApiKey } : {}), + ...(result.expiresIn + ? { expiresAt: new Date(Date.now() + result.expiresIn * 1000).toISOString() } + : {}), + }; + Object.assign(credentials, next); + try { + await onCredentialsRefreshed?.(next); + } catch (err) { + // #7676 pattern: a persistence failure must never fail the user-facing response. + log?.warn?.( + "M365_TOKEN", + `persisting refreshed token failed (${err instanceof Error ? err.message : String(err)}) — will re-refresh next request` + ); + } + } + async execute(input: ExecuteInput): Promise<{ response: Response; url: string; @@ -293,6 +366,12 @@ export class CopilotM365WebExecutor extends BaseExecutor { }; } + await this.ensureFreshCredentials( + input.credentials, + input.onCredentialsRefreshed, + input.log ?? null + ); + const connectionParams = resolveConnectionParams(input.credentials); if ("error" in connectionParams) { return { diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 89f7799b03..bc9ade9d27 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -57,6 +57,13 @@ import { type StreamingState as ComposerStreamingState, } from "../utils/composerToolCalls.ts"; import { cursorSessionManager, type CursorSession } from "../services/cursorSessionManager.ts"; +import { + CursorApiKeyExchangeError, + invalidateCursorSessionToken, + isCursorApiKey, + resolveCursorBearerToken, + stripCursorOAuthTokenPrefix, +} from "../services/cursorApiKeyAuth.ts"; import crypto from "crypto"; import * as fs from "node:fs"; import * as zlib from "node:zlib"; @@ -706,18 +713,44 @@ export function processFrame( } export class CursorExecutor extends BaseExecutor { - constructor() { - super("cursor", PROVIDERS.cursor); + constructor(provider: "cursor" | "cursor-api" = "cursor") { + super(provider, PROVIDERS[provider]); } buildUrl() { return CURSOR_AGENT_URL; } + /** + * API-key connections carry a `crsr_…` key that api2.cursor.sh does not + * accept as a Bearer; swap it for the exchanged session token before the + * h2 stream is opened. OAuth/IDE-session connections pass through untouched. + */ + async resolveExecutionCredentials(credentials) { + if (!isCursorApiKey(credentials?.apiKey)) return credentials; + try { + const accessToken = await resolveCursorBearerToken(credentials); + return { ...credentials, accessToken }; + } catch (err) { + const status = + err instanceof CursorApiKeyExchangeError ? err.status : HTTP_STATUS.SERVER_ERROR; + const message = err instanceof Error ? err.message : String(err); + return new Response( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(message), + type: status === HTTP_STATUS.UNAUTHORIZED ? "authentication_error" : "connection_error", + code: "", + }, + }), + { status, headers: { "Content-Type": "application/json" } } + ); + } + } + buildHeaders(credentials) { - const accessToken = credentials.accessToken; const ghostMode = credentials.providerSpecificData?.ghostMode !== false; - const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken; + const cleanToken = stripCursorOAuthTokenPrefix(credentials.accessToken ?? ""); const requestId = crypto.randomUUID(); const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`; @@ -825,7 +858,7 @@ export class CursorExecutor extends BaseExecutor { */ private async loadLiveCatalogIds(): Promise | undefined> { try { - const catalog = await getActiveSyncedCatalog("cursor"); + const catalog = await getActiveSyncedCatalog(this.provider); if (!catalog.models.length) return undefined; return new Set(catalog.models.map((model) => model.id)); } catch { @@ -1179,7 +1212,11 @@ export class CursorExecutor extends BaseExecutor { async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) { const url = this.buildUrl(); - const headers = this.buildHeaders(credentials); + const executionCredentials = await this.resolveExecutionCredentials(credentials); + if (executionCredentials instanceof Response) { + return { response: executionCredentials, url, headers: {}, transformedBody: body }; + } + const headers = this.buildHeaders(executionCredentials); mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); const messages: ChatMessage[] = body.messages || []; @@ -1252,8 +1289,10 @@ export class CursorExecutor extends BaseExecutor { if (isToolFollowUp) { session = cursorSessionManager.acquire(conversationId); // #9029: content-based session match when client lacks conversation_id. - if (!session && !body.conversation_id) session = cursorSessionManager.findByToolCallIds( - messages.filter(m => m.role === "tool" && m.tool_call_id).map(m => m.tool_call_id!)); + if (!session && !body.conversation_id) + session = cursorSessionManager.findByToolCallIds( + messages.filter((m) => m.role === "tool" && m.tool_call_id).map((m) => m.tool_call_id!) + ); } if (session) { @@ -1334,6 +1373,9 @@ export class CursorExecutor extends BaseExecutor { if (opened.status !== 200) { const errBuf = await opened.consumeError(); const errText = errBuf.toString("utf8") || "Unknown error"; + if (opened.status === HTTP_STATUS.UNAUTHORIZED && isCursorApiKey(credentials.apiKey)) { + invalidateCursorSessionToken(credentials.apiKey); + } return { response: buildErrorResponse(opened.status, `[${opened.status}]: ${errText}`), url, diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 056772040a..0d2c9182bd 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -191,6 +191,9 @@ export class DefaultExecutor extends BaseExecutor { // Operator's manual override (#6147) keeps its own semantics and falls // through to the provider-specific handling below. const normalized = alternate.baseUrl.replace(/\/$/, ""); + // A model-scoped alternate (the Gemini protocol: `{base}/{model}:generateContent`) + // builds its own URL — chatPath/urlSuffix are constants and cannot carry the model. + if (alternate.urlBuilder) return alternate.urlBuilder(normalized, model, stream); return `${normalized}${alternate.chatPath || ""}${alternate.urlSuffix || ""}`; } } diff --git a/open-sse/executors/freebuff.ts b/open-sse/executors/freebuff.ts new file mode 100644 index 0000000000..bc2de30f63 --- /dev/null +++ b/open-sse/executors/freebuff.ts @@ -0,0 +1,172 @@ +import { + BaseExecutor, + type ExecuteInput, +} from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; + +const MODEL_TO_AGENT: Record = { + "deepseek/deepseek-v4-flash": "base2-free-deepseek-flash", + "deepseek/deepseek-v4-pro": "base2-free-deepseek", + "openai/gpt-5.6-luna": "base2-free-luna", + "minimax/minimax-m3": "base2-free-minimax-m3", + "mimo/mimo-v2.5": "base2-free-mimo", + "z-ai/glm-5.2": "base2-free-glm", + "crof/kimi-k3-eco": "base2-free-kimi-k3-eco", + "anthropic/claude-fable-5": "base2-free-fable", + "meta/muse-spark-1.2-contributor": "base2-free-muse-spark", +}; + +function generateClientSessionId(): string { + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; + let out = ""; + for (let i = 0; i < 13; i++) { + out += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + return out; +} + +export class FreebuffExecutor extends BaseExecutor { + constructor() { + super("freebuff", (PROVIDERS as Record).freebuff as string || "freebuff"); + } + + override async execute(input: ExecuteInput) { + const { model, body, stream, credentials, signal } = input; + const token = credentials?.apiKey || credentials?.accessToken || ""; + + if (!token) { + return { + response: new Response( + JSON.stringify({ error: { message: "Freebuff Auth Token required", type: "authentication_error" } }), + { status: 401, headers: { "Content-Type": "application/json" } } + ), + }; + } + + const requestedModel = typeof model === "string" ? model.replace(/^freebuff\//, "") : (model || "deepseek/deepseek-v4-flash"); + const agentId = MODEL_TO_AGENT[requestedModel] || "base2-free"; + + const authHeaders = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "codebuff/0.1.0 (darwin-arm64)", + }; + + let instanceId = ""; + let runId = ""; + + // 1. Session acquisition + try { + const sessionRes = await fetch("https://www.codebuff.com/api/v1/freebuff/session", { + method: "POST", + headers: { + ...authHeaders, + "x-freebuff-model": requestedModel, + }, + body: JSON.stringify({}), + signal, + }); + if (sessionRes.ok) { + const data = (await sessionRes.json()) as { instanceId?: string }; + instanceId = data.instanceId || ""; + } else { + const errText = await sessionRes.text(); + return { + response: new Response( + JSON.stringify({ error: { message: `Freebuff session failed (${sessionRes.status}): ${errText}`, type: "upstream_error" } }), + { status: sessionRes.status, headers: { "Content-Type": "application/json" } } + ), + }; + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return { + response: new Response( + JSON.stringify({ error: { message: `Freebuff session network error: ${msg}`, type: "upstream_error" } }), + { status: 502, headers: { "Content-Type": "application/json" } } + ), + }; + } + + // 2. Start agent run + try { + const runRes = await fetch("https://www.codebuff.com/api/v1/agent-runs", { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ action: "START", agentId }), + signal, + }); + if (runRes.ok) { + const runData = (await runRes.json()) as { runId?: string }; + runId = runData.runId || ""; + } + } catch {} + + // 3. Prepare Chat Payload & Buffy System Prompt + const incomingMessages = Array.isArray(body?.messages) ? [...body.messages] : []; + const hasBuffyPrompt = + incomingMessages.length > 0 && + incomingMessages[0].role === "system" && + typeof incomingMessages[0].content === "string" && + incomingMessages[0].content.trim().startsWith("You are Buffy"); + + if (!hasBuffyPrompt) { + incomingMessages.unshift({ + role: "system", + content: "You are Buffy, the strategic coding assistant.", + }); + } + + const clientSessionId = generateClientSessionId(); + const upstreamBody = { + ...(body || {}), + model: requestedModel, + messages: incomingMessages, + stream: stream !== false, + codebuff_metadata: { + run_id: runId, + cost_mode: "free", + client_id: clientSessionId, + freebuff_instance_id: instanceId, + ...((body as Record)?.codebuff_metadata as Record || {}), + }, + }; + + const completionHeaders = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "ai-sdk/openai-compatible/1.0.25/codebuff", + Accept: "application/json, text/event-stream", + "x-freebuff-instance-id": instanceId, + ...(runId ? { "x-codebuff-run-id": runId } : {}), + "x-codebuff-agent-id": agentId, + }; + + // 4. Chat Completion + const completionUrl = "https://www.codebuff.com/api/v1/chat/completions"; + const response = await fetch(completionUrl, { + method: "POST", + headers: completionHeaders, + body: JSON.stringify(upstreamBody), + signal, + }); + + // 5. Finish agent run (background) + if (runId) { + void fetch("https://www.codebuff.com/api/v1/agent-runs", { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ + action: "FINISH", + runId, + status: "completed", + totalSteps: 1, + directCredits: 0, + totalCredits: 0, + }), + }).catch(() => {}); + } + + return { response }; + } +} diff --git a/open-sse/executors/gitlab.ts b/open-sse/executors/gitlab.ts index fa0b22c5c1..b82d5c47aa 100644 --- a/open-sse/executors/gitlab.ts +++ b/open-sse/executors/gitlab.ts @@ -10,6 +10,7 @@ import { } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; +import { isProbeContext } from "@/shared/utils/probeOrigin"; import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; import { buildStreamingResponse, @@ -208,9 +209,7 @@ function buildToolExchangePrompt(messages: OpenAIMessage[]): string { const line = renderConversationTurn(message, role, text); if (line) convo.push(line); } - const header = systemParts.length - ? `System instructions:\n${systemParts.join("\n\n")}\n\n` - : ""; + const header = systemParts.length ? `System instructions:\n${systemParts.join("\n\n")}\n\n` : ""; const body = `${header}${convo.join( "\n\n" )}\n\nContinue the response using the tool result above; do not repeat the tool call.`.trim(); @@ -672,7 +671,9 @@ export class GitlabExecutor extends BaseExecutor { } let activeCredentials = input.credentials; - if (this.needsRefresh(activeCredentials)) { + // Probe-origin dispatches must not consume a refresh-token rotation — + // routing state untouched; mirrors the base.ts guard (#9817). + if (!isProbeContext() && this.needsRefresh(activeCredentials)) { const refreshed = await this.refreshCredentials(activeCredentials, input.log || null); if (refreshed) { activeCredentials = mergeCredentials(activeCredentials, refreshed); diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index 945dbe82cb..98e2112c70 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -52,17 +52,41 @@ function getEffectiveKey(credentials: ProviderCredentials): string { return credentials.apiKey || credentials.accessToken || ""; } +export type GlmEffortLevel = "low" | "high" | "max"; + +type GlmEffortTier = { + baseModel: string; + effort: GlmEffortLevel; + /** Transport where the upstream honors the effort selector for this family. */ + transport: GlmTransport; +}; + /** - * GLM-5.2 effort tiers route exclusively through the Anthropic transport, - * where Zhipu maps Claude Code effort selectors (high/max) to reasoning - * intensity. The base model ID sent upstream is always "glm-5.2". + * GLM-5.2 effort tiers (glm-5.2-high/-max) route exclusively through the + * Anthropic transport, where Zhipu maps Claude Code effort selectors (high/max) + * to reasoning intensity. The base model ID sent upstream is always "glm-5.2". + * + * GLM-5.3 replaced tier endpoints with a documented `reasoning_effort` request + * parameter (low|high|max, default max) on the coding chat/completions endpoint, + * so its tiers stay on the OpenAI transport and inject `reasoning_effort` + + * `thinking.type=enabled` (5.3 no longer accepts thinking disabled). * * https://docs.z.ai/devpack/latest-model + * https://z.ai/blog/glm-5.3 */ -function parseGlm52Effort(model: string): { baseModel: string; effort: "high" | "max" } | null { - if (model === "glm-5.2-high") return { baseModel: "glm-5.2", effort: "high" }; - if (model === "glm-5.2-max") return { baseModel: "glm-5.2", effort: "max" }; - return null; +function parseGlmEffortTier(model: string): GlmEffortTier | null { + switch (model) { + case "glm-5.2-high": + return { baseModel: "glm-5.2", effort: "high", transport: "anthropic" }; + case "glm-5.2-max": + return { baseModel: "glm-5.2", effort: "max", transport: "anthropic" }; + case "glm-5.3-high": + return { baseModel: "glm-5.3", effort: "high", transport: "openai" }; + case "glm-5.3-low": + return { baseModel: "glm-5.3", effort: "low", transport: "openai" }; + default: + return null; + } } /** @@ -278,7 +302,7 @@ export class GlmExecutor extends DefaultExecutor { credentials: ProviderCredentials, transport: GlmTransport ) { - const effortTier = parseGlm52Effort(model); + const effortTier = parseGlmEffortTier(model); const effectiveModel = effortTier ? effortTier.baseModel : model; const transformed = this.transformRequest(effectiveModel, body, stream, credentials); @@ -313,6 +337,14 @@ export class GlmExecutor extends DefaultExecutor { } if (transport === "openai") { + // GLM-5.3 effort tiers: inject the documented `reasoning_effort` param and + // force thinking on — 5.3 rejects thinking.type "disabled", and an effort + // tier without thinking would silently drop the selector upstream. + if (record && effortTier && effortTier.transport === "openai") { + const existingThinking = asRecord(record.thinking); + record.thinking = { ...existingThinking, type: "enabled" }; + record.reasoning_effort = effortTier.effort; + } if (record && stream && hasTools(record) && record.tool_stream === undefined) { return { ...record, tool_stream: true }; } @@ -446,7 +478,12 @@ export class GlmExecutor extends DefaultExecutor { */ private async finalizeAnthropicTransportResult( input: ExecuteInput, - result: { response: Response; url: string; headers: Record; transformedBody: unknown } + result: { + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + } ): Promise { const { response: rawResponse, url, headers, transformedBody } = result; const clientHeaders = input.clientHeaders ?? {}; @@ -475,13 +512,14 @@ export class GlmExecutor extends DefaultExecutor { } async execute(input: ExecuteInput): Promise { - const effortTier = parseGlm52Effort(input.model); + const effortTier = parseGlmEffortTier(input.model); - // GLM-5.2 effort tiers route directly through Anthropic transport (no fallback). - // Zhipu only graduates effort on the Anthropic endpoint via the - // effort-2025-11-24 beta header included in GLM_ANTHROPIC_BETA. + // Effort tiers route directly through their family's transport (no fallback): + // GLM-5.2 → Anthropic (Zhipu only graduates effort there, via the + // effort-2025-11-24 beta header in GLM_ANTHROPIC_BETA); GLM-5.3 → OpenAI + // coding endpoint (`reasoning_effort` param). See parseGlmEffortTier. if (effortTier) { - return this.executeTransport(input, "anthropic"); + return this.executeTransport(input, effortTier.transport); } const primaryTransport = getGlmTransport( diff --git a/open-sse/executors/grok-cli.ts b/open-sse/executors/grok-cli.ts index 275e8ebfdd..fc37b23ce2 100644 --- a/open-sse/executors/grok-cli.ts +++ b/open-sse/executors/grok-cli.ts @@ -12,13 +12,14 @@ import { GROK_BUILD_DEFAULT_REASONING_EFFORT, GROK_BUILD_REASONING_INCLUDE, GROK_BUILD_RESPONSES_URL, + GROK_BUILD_SUPPORTED_REASONING_EFFORTS, GROK_BUILD_TOKEN_URL, } from "../config/grokBuild.ts"; import { resolvePublicCred } from "../utils/publicCreds.ts"; import { BaseExecutor, type ExecutorLog, type ProviderCredentials } from "./base.ts"; const GROK_BUILD_MAX_TOOLS = 200; -const GROK_BUILD_SUPPORTED_REASONING_EFFORTS = new Set(["low", "medium", "high"]); +const GROK_BUILD_REASONING_EFFORT_SET = new Set(GROK_BUILD_SUPPORTED_REASONING_EFFORTS); const GROK_BUILD_REFRESH_MAX_ATTEMPTS = 3; const GROK_BUILD_REFRESH_MIN_DELAY_MS = 200; const GROK_BUILD_TERMINAL_REFRESH_ERRORS = new Set(["invalid_grant", "invalid_client"]); @@ -33,7 +34,6 @@ const GROK_BUILD_UNSUPPORTED_PARAMS = [ "reasoning_effort", ]; - /** * Grok Build's cli-chat-proxy is stricter about Responses `function_call_output.output` * than OpenAI's Responses API. Agent tool results can contain truncated / incomplete @@ -128,7 +128,7 @@ function normalizeGrokBuildReasoning( ): Record | null { const reasoning = asRequestRecord(value); const hasExplicitEffort = Object.prototype.hasOwnProperty.call(reasoning, "effort"); - if (!GROK_BUILD_SUPPORTED_REASONING_EFFORTS.has(String(reasoning.effort))) { + if (!GROK_BUILD_REASONING_EFFORT_SET.has(String(reasoning.effort))) { delete reasoning.effort; } if (model === "grok-composer-2.5-fast") { diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 3fc2bdf9b3..34f3bafe73 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -1,9 +1,6 @@ import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts"; -import { - registerExecutor, - getRegisteredExecutor, - hasRegisteredExecutor, -} from "./registry.ts"; +import { registerExecutor, getRegisteredExecutor, hasRegisteredExecutor } from "./registry.ts"; +import type { BaseExecutor } from "./base.ts"; import { AntigravityExecutor } from "./antigravity.ts"; import { GithubExecutor } from "./github.ts"; import { GheCopilotExecutor } from "./ghe-copilot.ts"; @@ -17,6 +14,7 @@ import { BedrockExecutor } from "./bedrock.ts"; import { GlmExecutor } from "./glm.ts"; import { PollinationsExecutor } from "./pollinations.ts"; import { CloudflareAIExecutor } from "./cloudflare-ai.ts"; +import { FreebuffExecutor } from "./freebuff.ts"; import { OpencodeExecutor } from "./opencode.ts"; import { VertexExecutor } from "./vertex.ts"; import { CliproxyapiExecutor } from "./cliproxyapi.ts"; @@ -107,6 +105,8 @@ const executors = { "glm-cn": new GlmExecutor("glm-cn"), glmt: new GlmExecutor("glmt"), cu: new CursorExecutor(), // Alias for cursor + "cursor-api": new CursorExecutor("cursor-api"), + cua: new CursorExecutor("cursor-api"), "azure-openai": new AzureOpenAIExecutor(), "azure-ai": new AzureAiExecutor(), "command-code": new CommandCodeExecutor(), @@ -118,6 +118,8 @@ const executors = { pol: new PollinationsExecutor(), // Alias "cloudflare-ai": new CloudflareAIExecutor(), cf: new CloudflareAIExecutor(), // Alias + freebuff: new FreebuffExecutor(), + fb: new FreebuffExecutor(), // Alias "opencode-zen": new OpencodeExecutor("opencode-zen"), "opencode-go": new OpencodeExecutor("opencode-go"), opencode: new OpencodeExecutor("opencode-zen"), // Alias for opencode-zen @@ -235,7 +237,7 @@ const executors = { // Bootstrap: register every built-in in the ExecutorRegistry. registerExecutor // throws on duplicates, so an alias collision fails at module load, exactly as // loudly as a duplicate object key would have failed at lint time. -for (const [alias, executor] of Object.entries(executors)) { +for (const [alias, executor] of Object.entries(executors) as [string, BaseExecutor][]) { registerExecutor(alias, executor); } diff --git a/open-sse/executors/perplexity-web/protocol.ts b/open-sse/executors/perplexity-web/protocol.ts index f98680c4ff..12e98ccdc4 100644 --- a/open-sse/executors/perplexity-web/protocol.ts +++ b/open-sse/executors/perplexity-web/protocol.ts @@ -370,15 +370,29 @@ export function buildPplxRequestBody( }; } +const SEARCH_HINT = "You have built-in web search. Answer questions directly using search results."; + +/** + * Whether to append {@link SEARCH_HINT} to the caller's system message. + * + * It used to be unconditional. Perplexity's answer engine is search-first anyway, and + * for coding clients the sentence leaks into replies as meta-commentary ("I need to + * search before responding per my instructions"), so it is now opt-in via + * `OMNIROUTE_PPLX_SEARCH_HINT`. Read per call rather than at module load so the flag + * can be flipped without restarting the server (and so tests can toggle it). + */ +function searchHintEnabled(): boolean { + return /^(1|true|yes|on)$/i.test(process.env.OMNIROUTE_PPLX_SEARCH_HINT ?? ""); +} + export function buildQuery(parsed: ParsedMessages, followUpUuid: string | null): string { if (followUpUuid) return parsed.currentMsg; const obj: Record = {}; if (parsed.systemMsg.trim()) { - obj.instructions = [ - parsed.systemMsg.trim(), - "You have built-in web search. Answer questions directly using search results.", - ]; + obj.instructions = searchHintEnabled() + ? [parsed.systemMsg.trim(), SEARCH_HINT] + : [parsed.systemMsg.trim()]; } if (parsed.history.length > 0) { obj.history = parsed.history; diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 32ef003110..9dc499a1e4 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -229,6 +229,16 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) { return audioStreamResponse(res); } +/** + * Voice-note clients send response_format=ogg. OpenAI TTS documents opus, not ogg. + * OmniRoute already returns Ogg/Opus bytes for opus — alias ogg → opus (#10587). + */ +export function normalizeSpeechResponseFormat(fmt) { + if (typeof fmt !== "string" || !fmt) return "mp3"; + const lower = fmt.toLowerCase(); + return lower === "ogg" ? "opus" : lower; +} + /** * Handle Soniox TTS (OpenAI speech shape → Soniox /tts, returns raw audio bytes) */ @@ -963,7 +973,7 @@ export async function handleAudioSpeech({ model: modelId, input: body.input, voice: body.voice || "alloy", - response_format: body.response_format || "mp3", + response_format: normalizeSpeechResponseFormat(body.response_format), speed: body.speed || 1.0, }), }); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d54689472c..beb842bf2d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -33,7 +33,39 @@ import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHe import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts"; import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; -import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts"; +import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts"; +import { + createRoutingEvent, + emitRoutingEvent, + outcomeFromStatus, +} from "../services/routing/index.ts"; + +/** + * Best-effort finish_reason extraction from a (possibly translated) response + * body for routing-event telemetry. Returns null when the shape is unknown. + */ +function routingFinishReason(body: unknown): string | null { + if (!body || typeof body !== "object") return null; + const record = body as Record; + const choices = record.choices; + if (Array.isArray(choices)) { + const first = choices[0]; + if (first && typeof first === "object") { + const fr = (first as Record).finish_reason; + if (typeof fr === "string") return fr; + } + } + const output = record.output; + if (Array.isArray(output)) { + for (const item of output) { + if (item && typeof item === "object") { + const fr = (item as Record).finish_reason; + if (typeof fr === "string") return fr; + } + } + } + return null; +} import { getHeaderValueCaseInsensitive, isNoMemoryRequested, @@ -41,7 +73,11 @@ import { isStripReasoningRequested, } from "./chatCore/headers.ts"; import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts"; -import { isCodexOriginatedHeaders } from "../config/codexIdentity.ts"; +import { getCodexClientSessionId, isCodexOriginatedHeaders } from "../config/codexIdentity.ts"; +import { + noteCodexTurnStateProvenance, + readCodexTurnStateHeader, +} from "../config/codexTurnState.ts"; import { trackDevice, extractIpFromHeaders } from "../services/deviceTracker.ts"; import { getCombosCached } from "./chatCore/comboContextCache.ts"; export { clearCombosCache, clearUpstreamProxyConfigCache } from "./chatCore/comboContextCache.ts"; @@ -166,7 +202,10 @@ import { deriveRequestCapabilityRequirements, buildCapabilityMismatchMessage, } from "@/shared/constants/capabilities/capabilityFilter.ts"; -import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts"; +import { + areContextWindowChecksDisabled, + isFeatureFlagEnabled, +} from "@/shared/utils/featureFlags.ts"; import { resolveNoAuthEchoModel } from "./chatCore/noAuthEchoModel.ts"; import { REASONING_BUFFER_MIN_TRIGGER, @@ -330,7 +369,7 @@ import { import { cacheReasoningFromAssistantMessage } from "../services/reasoningCache.ts"; import { sanitizeOpenAITool } from "../services/toolSchemaSanitizer.ts"; import { isCompactResponsesEndpoint } from "../executors/codex.ts"; -import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts"; +import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts"; import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts"; @@ -393,8 +432,10 @@ import { } from "../utils/aiSdkCompat.ts"; import { generateRequestId } from "@/shared/utils/requestId"; import { isLocalStreamLifecycleError } from "@/shared/utils/circuitBreaker"; +import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin"; import { extractFacts } from "@/lib/memory/extraction"; import { handleToolCallExecution } from "@/lib/skills/interception"; +import { MEMORY_BUILTIN_TOOL_NAMES } from "@/lib/skills/memoryBuiltins"; import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; import { @@ -470,6 +511,7 @@ export async function handleChatCore({ conversationId = null, modelPinned = false, skipResourcePressureGuard = false, + reasoningTransportFallback = "skip", managedLease = null, }) { let { provider, model, extendedContext } = modelInfo; @@ -652,40 +694,6 @@ export async function handleChatCore({ creds: Record | null | undefined, transport?: string ): void => recordKeyHealthStatusFor(status, creds, log, transport); - const persistCodexQuotaState = async (headers: Record | null, status = 0) => { - const currentConnectionId = getCurrentConnectionId(); - if (provider !== "codex" || !currentConnectionId || !headers) return; - try { - const existingProviderData = - credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" - ? (credentials.providerSpecificData as Record) - : {}; - // Pure payload build extracted to chatCore/codexQuota.ts (#3501). Returns null when the - // response carries no quota headers (nothing to persist). - const built = buildCodexQuotaPersistence({ - headers, - existingProviderData, - modelForScope: model || requestedModel || "", - status, - }); - if (!built) return; - if (built.exhaustionLog) { - log?.debug?.("CODEX", built.exhaustionLog); - } - // Invalidate the preflight cache for this connection so the next - // isModelAvailable check fetches fresh quota data. - if (status === 429) { - invalidateCodexQuotaCache(currentConnectionId); - } - await updateProviderConnection(currentConnectionId, { - providerSpecificData: built.nextProviderData, - }); - credentials.providerSpecificData = built.nextProviderData; - } catch (err) { - const errMessage = err instanceof Error ? err.message : String(err); - log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`); - } - }; // ── Phase 9.2: Idempotency check ── // Resolve the idempotency key once here and reuse it at the Phase 9.2 save site below, // rather than re-deriving it. (#3821-review LEDGER-6) @@ -722,12 +730,13 @@ export async function handleChatCore({ copilotCompatibleReasoning, clientResponseFormat, } = resolveChatCoreRequestFormat({ clientRawRequest, body, provider, userAgent }); - const nativeOpenAICompatibleResponsesPassthrough = shouldUseNativeOpenAICompatibleResponsesPassthrough({ - provider, - sourceFormat, - endpointPath, - providerSpecificData: credentials?.providerSpecificData, - }); + const nativeOpenAICompatibleResponsesPassthrough = + shouldUseNativeOpenAICompatibleResponsesPassthrough({ + provider, + sourceFormat, + endpointPath, + providerSpecificData: credentials?.providerSpecificData, + }); const responsesInputItems = Array.isArray(body?.input) ? body.input : []; const customToolNames = collectCustomToolNamesForSourceFormat( sourceFormat, @@ -1185,11 +1194,30 @@ export async function handleChatCore({ return cacheHit; } - if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") { - applyResponsesInputPolicy( + const reasoningInputFormat = + sourceFormat === FORMATS.OPENAI_RESPONSES + ? "responses" + : sourceFormat === FORMATS.OPENAI + ? "chat" + : null; + if (reasoningInputFormat && body && typeof body === "object") { + const policy = applyReasoningInputPolicy( body as Record, - credentials?.providerSpecificData?.preserveEncryptedReasoning === true + reasoningInputFormat, + { + provider, + preserveEncryptedReasoning: + credentials?.providerSpecificData?.preserveEncryptedReasoning === true, + onIncompatibleReasoning: reasoningTransportFallback === "drop" ? "drop" : "reject", + } ); + if (policy.incompatibleReasoning) { + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult( + HTTP_STATUS.BAD_REQUEST, + "Reasoning continuation is not compatible with the selected target" + ); + } } body = sanitizeChatRequestBody(body, sourceFormat, targetFormat); @@ -2036,13 +2064,18 @@ export async function handleChatCore({ const modelOutputCap = toPositiveInteger( getExplicitModelOutputCap({ provider, model: effectiveModel }) ); + const contextWindowChecksDisabled = areContextWindowChecksDisabled(); const outputBudget = enforceOutputTokenBudget( body as Record, finalEstimatedInputTokens, - finalContextLimit, + contextWindowChecksDisabled ? Number.MAX_SAFE_INTEGER : finalContextLimit, targetFormat === FORMATS.CLAUDE && sourceFormat !== FORMATS.CLAUDE ? DEFAULT_MAX_TOKENS : 0, modelOutputCap, - toPositiveInteger(resolveInputTokenCapForGate({ provider, model: effectiveModel }, { isCombo })) + contextWindowChecksDisabled + ? null + : toPositiveInteger( + resolveInputTokenCapForGate({ provider, model: effectiveModel }, { isCombo }) + ) ); if (outputBudget.ok === false) { const exceededInputCap = outputBudget.maxInputTokens !== undefined; @@ -2968,7 +3001,18 @@ export async function handleChatCore({ ? (extractSessionAffinityKey(body, clientRawRequest?.headers) ?? null) : null; - while (attempts < maxAttempts) { + // ── Antigravity BYOP 422 account-rotation state ───────────────────── + // A GCP_PROJECT_REQUIRED 422 is account-specific (that Google + // account lacks a GCP Project ID). Rotate to a sibling antigravity + // account instead of surfacing the error, so multi-account setups + // keep working without user action. Tracked separately from + // maxAttempts so non-BYOP antigravity failures never get a second + // shot (no double upstream calls). + const antigravityByopExcludedIds: string[] = []; + let antigravityByopRotationPending = false; + + while (attempts < maxAttempts || antigravityByopRotationPending) { + antigravityByopRotationPending = false; // consumed per iteration trace("pre_executor", { attempt: attempts }); updatePendingScope(pendingScope, { stage: "sending_to_provider", @@ -3051,6 +3095,33 @@ export async function handleChatCore({ const res = normalizeExecutorResult(rawExecutorResult); trace("post_executor", { status: res?.response?.status }); + if ( + provider === "codex" && + attemptConnectionId && + !(await shouldIsolateProbeFailures()) + ) { + try { + const persistedQuota = await persistCodexChildQuotaResponse({ + connectionId: String(attemptConnectionId), + model: modelToCall || model || requestedModel || "", + headers: normalizeHeaders(res.response.headers), + status: res.response.status, + }); + if (persistedQuota) { + execCreds.providerSpecificData = persistedQuota.providerSpecificData; + if (persistedQuota.exhaustionLog) { + log?.debug?.("CODEX", persistedQuota.exhaustionLog); + } + } + if (res.response.status === 429) { + invalidateCodexQuotaCache(String(attemptConnectionId)); + } + } catch (err) { + const errMessage = err instanceof Error ? err.message : String(err); + log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`); + } + } + // Track Gemini RPM + RPD request counts for 429 classification if (provider === "gemini") { incrementRequestCount(modelToCall); @@ -3060,7 +3131,11 @@ export async function handleChatCore({ stage: "provider_response_started", }); - if (res.response.status === 401 && executionConnectionId) { + if ( + res.response.status === 401 && + executionConnectionId && + !(await shouldIsolateProbeFailures()) + ) { recordKeyHealthStatus(401, execCreds); } @@ -3090,7 +3165,10 @@ export async function handleChatCore({ !managedLease && comboStrategy !== "context-relay" && res.response.status === 429 && - attempts < maxAttempts - 1 + attempts < maxAttempts - 1 && + // Probe-origin (test-all) 429 must not rotate accounts or persist + // cooldowns — routing state untouched (#9817). + !(await shouldIsolateProbeFailures()) ) { const failedConnectionId = executionConnectionId || credentials?.connectionId || connectionId; @@ -3105,29 +3183,15 @@ export async function handleChatCore({ `429 on connection ${String(failedConnectionId).slice(0, 8)} (attempt ${attempts + 1}/${maxAttempts}), rotating account` ); - // Mark only the current Codex model scope as rate-limited. + // Mark only the current Codex model scope as rate-limited. A connection-wide + // cooldown here would let a Spark limit suppress independent Sol/Terra traffic. if (failedConnectionId) { await markCodexScopeRateLimited({ failedConnectionId: String(failedConnectionId), model: modelToCall || model || requestedModel || null, rateLimitedUntil: new Date(Date.now() + (retryAfterMs || 60_000)).toISOString(), - credentials, + credentials: execCreds || credentials, }); - // Fix B: also persist the cooldown to - // `provider_connections.rate_limited_until`. Without this, - // the Codex 429 cascade survives the current request (via - // `markCodexScopeRateLimited`'s in-memory Map) but is lost - // on process restart — the same exhausted Codex key is - // re-picked on the very next request. Mirrors - // `open-sse/executors/antigravity.ts:343`. - // Best-effort: never crash the chat path on DB write failure. - try { - const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); - const untilMs = Date.now() + (retryAfterMs || 60_000); - setConnectionRateLimitUntil(String(failedConnectionId), untilMs); - } catch { - // ignore — best effort - } if (!codexExcludedIds.includes(String(failedConnectionId))) { codexExcludedIds.push(String(failedConnectionId)); } @@ -3195,6 +3259,55 @@ export async function handleChatCore({ continue; } + // ── Antigravity BYOP 422 account rotation ─────────────────────── + // GCP_PROJECT_REQUIRED (422, code gcp_project_required) means + // THIS Google account must Bring Its Own GCP Project. Mark the + // connection excluded (rateLimitedUntil, best-effort) and rotate + // to a sibling antigravity account so the request succeeds + // without user action. When no sibling exists (or all are BYOP), + // fall through: the error-state block excludes the connection + // and the actionable 422 is surfaced. + if (provider === "antigravity" && res.response.status === 422) { + const byopBody = await res.response + .clone() + .text() + .catch(() => ""); + if (byopBody.includes("gcp_project_required")) { + const byopFailedId = + executionConnectionId || credentials?.connectionId || connectionId; + if (byopFailedId) { + if (!antigravityByopExcludedIds.includes(String(byopFailedId))) { + antigravityByopExcludedIds.push(String(byopFailedId)); + } + try { + const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); + setConnectionRateLimitUntil( + String(byopFailedId), + Date.now() + COOLDOWN_MS.gcpProjectRequired + ); + } catch { + // best-effort — never break the rotation path + } + } + const byopNextCreds = await getProviderCredentials( + "antigravity", + null, + null, + modelToCall || model || requestedModel || null, + { excludeConnectionIds: [...antigravityByopExcludedIds] } + ).catch(() => null); + if (byopNextCreds && !byopNextCreds.allRateLimited) { + log?.warn?.( + "ANTIGRAVITY_BYOP_ROTATION", + `BYOP 422 on connection ${String(byopFailedId).slice(0, 8)} → rotating to ${String(byopNextCreds.connectionId).slice(0, 8)}` + ); + Object.assign(credentials, byopNextCreds); + antigravityByopRotationPending = true; + continue; + } + } + } + // For streaming: release the semaphore when the client drains or cancels the stream. if (stream) { const originalBody = res.response.body; @@ -3387,6 +3500,15 @@ export async function handleChatCore({ const responseHeaders = new Headers(headersObj); stripStaleForwardingHeaders(responseHeaders); stripNextMiddlewareControlHeaders(responseHeaders); + // The upstream headers (turn-state included) are about to be committed + // to the client — record which connection minted the blob so a later + // cross-account echo can be stripped (Codex failover guard). + if (provider === "codex" && readCodexTurnStateHeader(responseHeaders)) { + noteCodexTurnStateProvenance( + getCodexClientSessionId(clientRawRequest?.headers), + rawResult._executionCredentials?.connectionId ?? credentials?.connectionId + ); + } const contentType = (responseHeaders.get("content-type") || "").toLowerCase(); const payload = await readNonStreamingResponseBody( rawResult.response, @@ -3700,10 +3822,15 @@ export async function handleChatCore({ } // Handle 401/403 - try token refresh using executor + // T-PROBE: probe-origin failures never attempt the refresh — a probe must + // not consume a rotating refresh token nor persist an "expired" + // deactivation on refresh failure (#9817). The 401/403 then flows into + // the normal providerFailure classification (record-only in probe mode). if ( (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN) && - !hadStreamOptions // Skip refresh if failure may be from stream_options removal, not auth + !hadStreamOptions && // Skip refresh if failure may be from stream_options removal, not auth + !(await shouldIsolateProbeFailures()) ) { // Fix A: wrap refreshCredentials in runWithOnPersist so the persist callback // executes INSIDE the per-connection mutex held by getAccessToken. This makes @@ -3855,8 +3982,6 @@ export async function handleChatCore({ } } - await persistCodexQuotaState(normalizeHeaders(providerResponse.headers), providerResponse.status); - // Check provider response - return error info for fallback handling providerFailure: if (!providerResponse.ok) { trackPendingRequest(model, provider, connectionId, false); @@ -3985,17 +4110,34 @@ export async function handleChatCore({ if (errorConnectionId && errorType) { try { if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) { - await updateProviderConnection(errorConnectionId, { - isActive: false, - testStatus: "banned", - lastErrorType: errorType, - lastError: message, - errorCode: statusCode, - }); - console.warn( - `[provider] Node ${errorConnectionId} banned (${statusCode}) — disabling permanently` - ); + // T-PROBE: a probe-origin failure (model test-all) must never + // remove the connection from the pool — record but stay active. + if (await shouldIsolateProbeFailures()) { + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + lastErrorAt: new Date().toISOString(), + }); + console.warn( + `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active` + ); + } else { + await updateProviderConnection(errorConnectionId, { + isActive: false, + testStatus: "banned", + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + }); + console.warn( + `[provider] Node ${errorConnectionId} banned (${statusCode}) — disabling permanently` + ); + } } else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) { + // T-PROBE: probe-origin failures (test-all) never deactivate — + // record but stay active; Plan A (extra keys) stays first so the + // real path keeps its existing priority (#9817). // Plan A: if connection has extra API keys, don't disable — only the failing key is affected. // Single-key connections still get disabled as before. if ( @@ -4013,6 +4155,16 @@ export async function handleChatCore({ console.warn( `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — has extra keys, keeping connection active` ); + } else if (await shouldIsolateProbeFailures()) { + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + lastErrorAt: new Date().toISOString(), + }); + console.warn( + `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active` + ); } else { await updateProviderConnection(errorConnectionId, { isActive: false, @@ -4026,73 +4178,90 @@ export async function handleChatCore({ ); } } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) { - // Kimi's 403 says "billing cycle" for both an exhausted subscription and a - // temporary request window. Read its official usage endpoint before making - // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit - // window must recover automatically at the reported reset time. - let kimiRateLimitResetAt: string | null = null; - if (provider === "kimi-coding") { - try { - const { fetchAndPersistProviderLimits } = await import("@/lib/usage/providerLimits"); - const { usage } = await fetchAndPersistProviderLimits(errorConnectionId, "manual"); - kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage); - } catch { - // Preserve the existing quota handling when Kimi's usage endpoint is unavailable. - } - } - - // Providers with per-model quotas — lock the model only, not the connection - const quotaCooldownMs = kimiRateLimitResetAt - ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0) - : retryAfterMs || COOLDOWN_MS.rateLimit; - const accountSemaphoreKey = resolveAccountSemaphoreKey({ - provider, - model: currentModel, - connectionId: errorConnectionId, - credentials, - }); - if (accountSemaphoreKey) { - markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs); - } - if (kimiRateLimitResetAt) { + // T-PROBE: probe-origin failures never write quota state — + // `testStatus: "credits_exhausted"` is terminal and removes the + // connection from the pool; semaphore locks and per-model quota + // lockouts are routing mutations too. Record only (#9817). + if (await shouldIsolateProbeFailures()) { await updateProviderConnection(errorConnectionId, { - testStatus: "unavailable", - rateLimitedUntil: kimiRateLimitResetAt, - backoffLevel: 0, - lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED, - lastError: message, - errorCode: statusCode, - }); - console.warn( - `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}` - ); - } else if (isModelScope() && errorConnectionId) { - const lockFn = provider === "antigravity" ? lockExactModel : lockModel; - lockFn(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs); - console.warn( - `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` - ); - } else if ( - lockModelIfPerModelQuota( - provider, - errorConnectionId, - model, - "quota_exhausted", - quotaCooldownMs - ) - ) { - const quotaScope = getQuotaScopeLabelForProvider(provider, model); - console.warn( - `[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)` - ); - } else { - await updateProviderConnection(errorConnectionId, { - testStatus: "credits_exhausted", lastErrorType: errorType, lastError: message, errorCode: statusCode, + lastErrorAt: new Date().toISOString(), }); - console.warn(`[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`); + console.warn( + `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active` + ); + } else { + // Kimi's 403 says "billing cycle" for both an exhausted subscription and a + // temporary request window. Read its official usage endpoint before making + // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit + // window must recover automatically at the reported reset time. + let kimiRateLimitResetAt: string | null = null; + if (provider === "kimi-coding") { + try { + const { fetchAndPersistProviderLimits } = + await import("@/lib/usage/providerLimits"); + const { usage } = await fetchAndPersistProviderLimits(errorConnectionId, "manual"); + kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage); + } catch { + // Preserve the existing quota handling when Kimi's usage endpoint is unavailable. + } + } + + // Providers with per-model quotas — lock the model only, not the connection + const quotaCooldownMs = kimiRateLimitResetAt + ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0) + : retryAfterMs || COOLDOWN_MS.rateLimit; + const accountSemaphoreKey = resolveAccountSemaphoreKey({ + provider, + model: currentModel, + connectionId: errorConnectionId, + credentials, + }); + if (accountSemaphoreKey) { + markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs); + } + if (kimiRateLimitResetAt) { + await updateProviderConnection(errorConnectionId, { + testStatus: "unavailable", + rateLimitedUntil: kimiRateLimitResetAt, + backoffLevel: 0, + lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED, + lastError: message, + errorCode: statusCode, + }); + console.warn( + `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}` + ); + } else if (isModelScope() && errorConnectionId) { + const lockFn = provider === "antigravity" ? lockExactModel : lockModel; + lockFn(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs); + console.warn( + `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` + ); + } else if ( + lockModelIfPerModelQuota( + provider, + errorConnectionId, + model, + "quota_exhausted", + quotaCooldownMs + ) + ) { + const quotaScope = getQuotaScopeLabelForProvider(provider, model); + console.warn( + `[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)` + ); + } else { + await updateProviderConnection(errorConnectionId, { + testStatus: "credits_exhausted", + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + }); + console.warn(`[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`); + } } } else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) { // Normal 401 (token/session auth issue): keep account active for refresh/re-auth. @@ -4134,31 +4303,60 @@ export async function handleChatCore({ lastError: message, errorCode: statusCode, }); - try { - const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); - setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs); - } catch { - // DB write failure must never break the fallback loop + // T-PROBE: the 24h exclusion is a routing mutation — a probe must + // not push a connection into a day-long cooldown (#9817). + if (!(await shouldIsolateProbeFailures())) { + try { + const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); + setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs); + } catch { + // DB write failure must never break the fallback loop + } } console.warn( `[provider] Node ${errorConnectionId} geo-blocked (${statusCode}) — excluded for ${Math.ceil(geoCooldownMs / 1000)}s, trying other accounts` ); + } else if (errorType === PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED) { + // Antigravity BYOP: the account must Bring Its Own GCP Project. + // Account-specific and fixable by entering a Project ID — never a + // model lockout, never a ban. Exclude the connection for the + // cooldown window so selection prefers sibling accounts; the 422 + // body carries the actionable message when no sibling is available. + const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 24 * 60 * 60 * 1000; + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + }); + try { + const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); + setConnectionRateLimitUntil(errorConnectionId, Date.now() + byopCooldownMs); + } catch { + // best-effort — never break the error path + } + console.warn( + `[provider] Node ${errorConnectionId} GCP project required (${statusCode}) — excluded for ${Math.ceil(byopCooldownMs / 1000)}s, routing to other accounts (enter a Project ID to restore)` + ); } else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) { // 404 — model/endpoint does not exist upstream. Lock the model so the // retry/backoff loop stops hammering the dead endpoint (which would // otherwise degenerate into a 429 rate-limit storm). Connection stays // active since only the specific model is unavailable. (#6827) const notFoundCooldownMs = COOLDOWN_MS.notFound; - lockModel( - provider, - errorConnectionId, - currentModel, - "model_not_found", - notFoundCooldownMs - ); - console.warn( - `[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)` - ); + // T-PROBE: the model lockout is a routing mutation — a probe must + // not lock a model for the cooldown window (#9817). + if (!(await shouldIsolateProbeFailures())) { + lockModel( + provider, + errorConnectionId, + currentModel, + "model_not_found", + notFoundCooldownMs + ); + console.warn( + `[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)` + ); + } } } catch { // Best-effort state update; request flow should continue with fallback handling. @@ -4783,9 +4981,11 @@ export async function handleChatCore({ const customSkillExecutionEnabled = Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true; - const builtinToolNames = [webSearchFallbackPlan.toolName, webFetchFallbackPlan.toolName].filter( - (name): name is string => Boolean(name) - ); + const builtinToolNames = [ + webSearchFallbackPlan.toolName, + webFetchFallbackPlan.toolName, + ...(memoryOwnerId && memorySettings?.enabled ? MEMORY_BUILTIN_TOOL_NAMES : []), + ].filter((name): name is string => Boolean(name)); if (customSkillExecutionEnabled || builtinToolNames.length > 0) { const skillSessionId = pipelineSessionId; @@ -4917,6 +5117,27 @@ export async function handleChatCore({ }); persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "malformed_translated_response"); trackPendingRequest(model, provider, pendingConnId, false); + // Routing event (feedback foundation) — record the malformed outcome so + // the quality tracker de-prioritizes this model over time. + void emitRoutingEvent( + createRoutingEvent({ + requestId: traceId || pendingRequestId || "unknown", + provider: provider || "unknown", + model: model || "unknown", + strategy: isCombo ? (comboStrategy ?? "combo") : "direct", + latencyMs: Date.now() - startTime, + ttftMs: null, + inputTokens: null, + outputTokens: null, + cost: null, + retries: 0, + fallbackUsed: false, // combo-level fallback tracked by decisionTrace + outcome: "malformed", + status: HTTP_STATUS.BAD_GATEWAY, + finishReason: routingFinishReason(translatedResponse), + connectionId: credentials?.connectionId ?? null, + }) + ); return createErrorResult( HTTP_STATUS.BAD_GATEWAY, malformedMessage, @@ -5017,6 +5238,43 @@ export async function handleChatCore({ response: { status: 200, data: translatedResponse }, }); + // Routing event (feedback foundation) — fire-and-forget, cheap. + void emitRoutingEvent( + createRoutingEvent({ + requestId: traceId || pendingRequestId || "unknown", + provider: provider || "unknown", + model: model || "unknown", + strategy: isCombo ? (comboStrategy ?? "combo") : "direct", + latencyMs: Date.now() - startTime, + ttftMs: null, + inputTokens: + usage && typeof usage === "object" + ? (() => { + const promptTokens = (usage as Record).prompt_tokens; + return typeof promptTokens === "number" && Number.isFinite(promptTokens) + ? promptTokens + : null; + })() + : null, + outputTokens: + usage && typeof usage === "object" + ? (() => { + const completionTokens = (usage as Record).completion_tokens; + return typeof completionTokens === "number" && Number.isFinite(completionTokens) + ? completionTokens + : null; + })() + : null, + cost: Number.isFinite(estimatedCost) ? estimatedCost : null, + retries: 0, + fallbackUsed: false, // combo-level fallback tracked by decisionTrace + outcome: "success", + status: 200, + finishReason: routingFinishReason(translatedResponse), + connectionId: credentials?.connectionId ?? null, + }) + ); + return { success: true, response: buildNonStreamingJsonResponse(translatedResponse, responseHeaders), @@ -5106,6 +5364,17 @@ export async function handleChatCore({ comboStrategy, }); + // The streaming headers (turn-state included, when present) are committed to + // the client from here on — record which connection minted the blob so a + // later cross-account echo can be stripped (Codex failover guard). The + // in-place failover update means `credentials` is the winning account. + if (provider === "codex" && readCodexTurnStateHeader(providerResponse.headers)) { + noteCodexTurnStateProvenance( + getCodexClientSessionId(clientRawRequest?.headers), + credentials?.connectionId + ); + } + // Create transform stream with logger for streaming response let transformStream; const responseToolNameMap = mergeResponseToolNameMap( @@ -5126,6 +5395,8 @@ export async function handleChatCore({ error: streamError, errorCode: streamErrorCode, ttft, + itlMs: streamItlMs, + interrupted: streamInterrupted, }) => { const normalizedStreamStatus = streamStatus || 200; if (streamCompletionRecorded) return; @@ -5229,6 +5500,53 @@ export async function handleChatCore({ endpoint: endpointPath, }); + // Routing event (feedback foundation) — fire-and-forget, cheap, never blocks + // the stream. Feeds the quality tracker + optional OTel exporter. + void emitRoutingEvent( + createRoutingEvent({ + requestId: traceId || pendingRequestId || "unknown", + provider: provider || "unknown", + model: model || "unknown", + strategy: isCombo ? (comboStrategy ?? "combo") : "direct", + latencyMs: Date.now() - startTime, + ttftMs: typeof ttft === "number" && Number.isFinite(ttft) && ttft >= 0 ? ttft : null, + itlMs: + typeof streamItlMs === "number" && Number.isFinite(streamItlMs) && streamItlMs >= 0 + ? streamItlMs + : null, + inputTokens: + streamUsage && typeof streamUsage === "object" + ? (() => { + const promptTokens = (streamUsage as Record).prompt_tokens; + return typeof promptTokens === "number" && Number.isFinite(promptTokens) + ? promptTokens + : null; + })() + : null, + outputTokens: + streamUsage && typeof streamUsage === "object" + ? (() => { + const completionTokens = (streamUsage as Record).completion_tokens; + return typeof completionTokens === "number" && Number.isFinite(completionTokens) + ? completionTokens + : null; + })() + : null, + cost: null, + retries: 0, + fallbackUsed: false, // combo-level fallback tracked by decisionTrace + outcome: + normalizedStreamStatus === 200 + ? "success" + : streamErrorCode === "stream_interrupted" || streamErrorCode === "aborted" + ? "stream_interrupted" + : outcomeFromStatus(normalizedStreamStatus), + status: normalizedStreamStatus, + finishReason: routingFinishReason(streamResponseBody), + connectionId: streamConnectionId ?? credentials?.connectionId ?? null, + }) + ); + persistAttemptLogs({ status: normalizedStreamStatus, error: streamError || undefined, diff --git a/open-sse/handlers/chatCore/codexFailover.ts b/open-sse/handlers/chatCore/codexFailover.ts index f552af7375..d48cb7d4bc 100644 --- a/open-sse/handlers/chatCore/codexFailover.ts +++ b/open-sse/handlers/chatCore/codexFailover.ts @@ -1,42 +1,29 @@ -import { getCodexModelScope } from "../../config/codexQuotaScopes.ts"; -import { updateProviderConnection } from "@/lib/db/providers"; -import { getCachedProviderConnectionById } from "@/lib/localDb"; +import { persistCodexChildCooldown } from "../../services/codexAccount/index.ts"; type CodexFailoverCredentials = { connectionId?: string | null; providerSpecificData?: unknown; }; -function asProviderData(value: unknown): Record { - return value && typeof value === "object" ? (value as Record) : {}; -} - export async function markCodexScopeRateLimited(params: { failedConnectionId: string; model: string | null; rateLimitedUntil: string; credentials?: CodexFailoverCredentials | null; }): Promise { - const connection = await getCachedProviderConnectionById(params.failedConnectionId).catch(() => null); - const existingProviderData = connection - ? asProviderData(connection.providerSpecificData) - : asProviderData(params.credentials?.providerSpecificData); - const existingScopeMap = asProviderData(existingProviderData.codexScopeRateLimitedUntil); - const nextProviderData = { - ...existingProviderData, - codexScopeRateLimitedUntil: { - ...existingScopeMap, - [getCodexModelScope(params.model || "")]: params.rateLimitedUntil, - }, - }; + const persisted = params.model + ? await persistCodexChildCooldown({ + connectionId: params.failedConnectionId, + model: params.model, + rateLimitedUntil: params.rateLimitedUntil, + }).catch(() => null) + : null; - updateProviderConnection(params.failedConnectionId, { - ...(connection ? { providerSpecificData: nextProviderData } : {}), - lastError: "429 rate limited — codex account rotation", - errorCode: 429, - }).catch(() => {}); - - if (params.credentials && String(params.credentials.connectionId) === params.failedConnectionId) { - params.credentials.providerSpecificData = nextProviderData; + if ( + persisted && + params.credentials && + String(params.credentials.connectionId) === params.failedConnectionId + ) { + params.credentials.providerSpecificData = persisted.providerSpecificData; } } diff --git a/open-sse/handlers/chatCore/codexQuota.ts b/open-sse/handlers/chatCore/codexQuota.ts deleted file mode 100644 index 7bc6eac851..0000000000 --- a/open-sse/handlers/chatCore/codexQuota.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * chatCore Codex quota-persistence builder (Quality Gate v2 / Fase 9 — chatCore god-file - * decomposition, #3501). - * - * Pure core of handleChatCore's persistCodexQuotaState: turns the upstream Codex quota response - * headers into the next `providerSpecificData` payload (the codexQuotaState snapshot, plus — on a - * 429 whose dual-window usage is past the exhaustion threshold — the per-scope cooldown timestamp, - * the exhausted window, and the debug-log message). The handler keeps the impure parts byte- - * identically: the DB write (updateProviderConnection), the preflight-cache invalidation on every - * 429, the credentials mutation, and emitting the returned log line. - */ - -import { - parseCodexQuotaHeaders, - getCodexModelScope, - getCodexDualWindowCooldownMs, -} from "../../executors/codex.ts"; - -export type CodexQuotaPersistence = { - /** The merged providerSpecificData to persist (existing data + codexQuotaState [+ 429 cooldown]). */ - nextProviderData: Record; - /** The CODEX debug-log message to emit when a 429 exhausted a window, else null. */ - exhaustionLog: string | null; -}; - -/** - * Build the providerSpecificData update for a Codex quota response. Returns null when the response - * carries no quota headers (nothing to persist). Pure: a function of the headers, the existing - * provider data, the model used for scope resolution, and the upstream status. - */ -export function buildCodexQuotaPersistence(opts: { - headers: Record; - existingProviderData: Record; - modelForScope: string; - status: number; -}): CodexQuotaPersistence | null { - const { headers, existingProviderData, modelForScope, status } = opts; - - const quota = parseCodexQuotaHeaders(headers); - if (!quota) return null; - - const scope = getCodexModelScope(modelForScope); - const quotaState = { - usage5h: quota.usage5h, - limit5h: quota.limit5h, - resetAt5h: quota.resetAt5h, - usage7d: quota.usage7d, - limit7d: quota.limit7d, - resetAt7d: quota.resetAt7d, - scope, - updatedAt: new Date().toISOString(), - }; - - const nextProviderData: Record = { - ...existingProviderData, - codexQuotaState: quotaState, - }; - - let exhaustionLog: string | null = null; - - // T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking. - // Use dual-window cooldown to distinguish short-term and weekly Codex exhaustion. - if (status === 429) { - const { cooldownMs, window: exhaustedWindow } = getCodexDualWindowCooldownMs(quota); - if (cooldownMs > 0) { - const scopeUntil = new Date(Date.now() + cooldownMs).toISOString(); - const scopeMapRaw = - existingProviderData && - typeof existingProviderData === "object" && - existingProviderData.codexScopeRateLimitedUntil && - typeof existingProviderData.codexScopeRateLimitedUntil === "object" - ? existingProviderData.codexScopeRateLimitedUntil - : {}; - - nextProviderData.codexScopeRateLimitedUntil = { - ...(scopeMapRaw as Record), - [scope]: scopeUntil, - }; - nextProviderData.codexExhaustedWindow = exhaustedWindow; - exhaustionLog = `Quota exhaustion on ${exhaustedWindow} window, cooldown until ${scopeUntil}`; - } - } - - return { nextProviderData, exhaustionLog }; -} diff --git a/open-sse/handlers/chatCore/memorySkillsInjection.ts b/open-sse/handlers/chatCore/memorySkillsInjection.ts index 2a43c17cbf..14fbdc9575 100644 --- a/open-sse/handlers/chatCore/memorySkillsInjection.ts +++ b/open-sse/handlers/chatCore/memorySkillsInjection.ts @@ -2,6 +2,7 @@ import { retrieveMemories } from "@/lib/memory/retrieval"; import { getMemorySettings, DEFAULT_MEMORY_SETTINGS, toMemoryRetrievalConfig } from "@/lib/memory/settings"; import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection"; import { injectSkills } from "@/lib/skills/injection"; +import { buildMemoryToolsForProvider } from "@/lib/skills/memoryBuiltins"; import { skillRegistry } from "@/lib/skills/registry"; import { FORMATS } from "../../translator/formats.ts"; import { detectCachingContext } from "../../services/compression/cachingAware.ts"; @@ -138,6 +139,43 @@ export async function injectMemoryAndSkills({ } } + if (memoryOwnerId && memorySettings?.enabled && body.stream !== true) { + // Server-side builtin memory tools (memory_save/update/search/delete) are + // executed by the gateway's tool-call interception, which runs only on the + // non-stream path. Stream clients (opencode etc.) execute tools client-side, + // so for them these tools would be announced but never executed; they should + // use the MCP memory tools (omniroute_memory_*) instead. + const existingTools = Array.isArray(body.tools) ? body.tools : []; + const existingToolNames = new Set( + existingTools.flatMap((tool) => { + const record = tool as Record | null; + if (!record || typeof record !== "object") return []; + const fn = record.function as Record | undefined; + if (typeof fn?.name === "string") return [fn.name]; + if (typeof record.name === "string") return [record.name]; + return []; + }) + ); + const memoryTools = buildMemoryToolsForProvider( + getSkillsProviderForFormat(sourceFormat) + ).filter((tool) => { + const record = tool as Record; + const name = + (record.function as Record | undefined)?.name ?? record.name; + return typeof name === "string" && !existingToolNames.has(name); + }); + if (memoryTools.length > 0) { + body = { + ...body, + tools: [...existingTools, ...memoryTools], + }; + log?.debug?.( + "MEMORY", + `Injected ${memoryTools.length} memory tool(s) for key=${memoryOwnerId}` + ); + } + } + if (memoryOwnerId && memorySettings?.skillsEnabled) { // Ensure the registry cache is warm before listing: on a cold/fresh // process skills that exist only in the DB would be missed (false diff --git a/open-sse/handlers/chatCore/responseHeaders.ts b/open-sse/handlers/chatCore/responseHeaders.ts index 59c45ba829..8206304544 100644 --- a/open-sse/handlers/chatCore/responseHeaders.ts +++ b/open-sse/handlers/chatCore/responseHeaders.ts @@ -28,11 +28,18 @@ const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([ "x-amz-security-token", "x-auth-token", "x-accel-buffering", - // 314-byte Codex session blob. It is not a client rate-limit signal and - // alone ate ~40% of the old 768-byte budget, evicting x-codex-*-used-percent. - "x-codex-turn-state", ]); +/** + * `x-codex-turn-state` is forwarded verbatim and EXEMPT from the forwarding + * budget. The real Codex client captures this ~314-byte blob from /responses + * (and echoes it back within the same turn), so dropping it breaks the + * protocol chain — but naively counting it against the budget used to evict + * the x-codex-*-used-percent quota headers (the reason it was denylisted + * under #10315-era budgeting). Carving it out keeps both. + */ +const CODEX_TURN_STATE_RESPONSE_HEADER = "x-codex-turn-state"; + const DEFAULT_FORWARDED_HEADER_BUDGET_BYTES = 768; /** @@ -206,7 +213,9 @@ export function buildStreamingResponseHeaders( STREAMING_RESPONSE_HEADER_DENYLIST.has(normalized) || connectionScopedHeaders.has(normalized) || isNextMiddlewareControlHeader(normalized) || - isOmniRouteInternalHeader(normalized) + isOmniRouteInternalHeader(normalized) || + // Forwarded separately below, outside the byte budget. + normalized === CODEX_TURN_STATE_RESPONSE_HEADER ) { return; } @@ -269,6 +278,10 @@ export function buildStreamingResponseHeaders( "X-Accel-Buffering": "no", [OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS", }; + const codexTurnState = providerHeaders.get(CODEX_TURN_STATE_RESPONSE_HEADER)?.trim(); + if (codexTurnState) { + responseHeaders[CODEX_TURN_STATE_RESPONSE_HEADER] = codexTurnState; + } attachOmniRouteMetaHeaders(responseHeaders, meta); return responseHeaders; } diff --git a/open-sse/handlers/cursorCliProxy.ts b/open-sse/handlers/cursorCliProxy.ts new file mode 100644 index 0000000000..dbf742f8f6 --- /dev/null +++ b/open-sse/handlers/cursorCliProxy.ts @@ -0,0 +1,524 @@ +/** + * Cursor CLI passthrough. + * + * cursor-agent honours `--endpoint` / CURSOR_API_ENDPOINT and, with + * `network.useHttp1ForAgent: true`, talks to that endpoint exclusively over + * HTTP/1.1: unary Connect-RPC POSTs (`/aiserver.v1.*`, `/agent.v1.*`, + * `/aiserver.v1.BidiService/BidiAppend`), the agent turn as + * `/agent.v1.AgentService/RunSSE` (text/event-stream), OTLP traces on + * `/v1/traces`, and the API-key bootstrap `POST /auth/exchange_user_api_key`. + * + * Pointing the CLI at OmniRoute therefore only needs a thin forwarder: + * 1. `/auth/exchange_user_api_key` authenticates the CLI with an OmniRoute + * API key and hands back an OmniRoute-minted session JWT. The CLI reads + * `exp` from whatever JWT it receives and re-exchanges when the token is + * opaque or expired, so the minted token must be a real JWT with `exp`. + * 2. Every other path verifies that JWT, resolves an active `cursor-api` + * connection (the crsr_ key is exchanged for a session token), swaps the + * Authorization header and streams the upstream reply back unchanged. + * Each hop is recorded in call_logs. + */ + +import { SignJWT, jwtVerify, type JWTPayload } from "jose"; +import { z } from "zod"; +import { getApiKeyById, getApiKeyMetadata, validateApiKey } from "@/lib/db/apiKeys"; +import { getProviderConnections } from "@/lib/db/providers"; +import { saveCallLog } from "@/lib/usage/callLogs"; +import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; +import { HTTP_STATUS } from "../config/constants.ts"; +import { + CURSOR_API_BASE_URL, + CURSOR_API_KEY_EXCHANGE_PATH, + CursorApiKeyExchangeError, + invalidateCursorSessionToken, + isCursorApiKey, + resolveCursorBearerToken, +} from "../services/cursorApiKeyAuth.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +export const CURSOR_CLI_PROXY_PREFIX = "/api/cursor-cli"; +export const CURSOR_CLI_SESSION_ISSUER = "omniroute"; +export const CURSOR_CLI_SESSION_AUDIENCE = "cursor-cli"; +export const CURSOR_CLI_SESSION_TTL_SECONDS = 60 * 60; +export const CURSOR_CLI_REQUEST_TYPE = "cursor-cli"; +const ANONYMOUS_SUBJECT = "anonymous"; +const PROVIDER_ID = "cursor-api"; + +const REQUEST_HEADER_DENYLIST = new Set([ + "authorization", + "host", + "connection", + "content-length", + "accept-encoding", + "keep-alive", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", + "cookie", +]); + +const RESPONSE_HEADER_DENYLIST = new Set([ + "connection", + "content-encoding", + "content-length", + "keep-alive", + "transfer-encoding", + "set-cookie", +]); + +const exchangeBodySchema = z.object({}).passthrough(); + +const sessionClaimsSchema = z.object({ + sub: z.string().min(1), + iss: z.literal(CURSOR_CLI_SESSION_ISSUER), + aud: z.union([ + z.literal(CURSOR_CLI_SESSION_AUDIENCE), + z.array(z.string()).refine((list) => list.includes(CURSOR_CLI_SESSION_AUDIENCE)), + ]), + exp: z.number(), + name: z.string().nullable().optional(), +}); + +export type CursorCliPrincipal = { + apiKeyId: string | null; + apiKeyName: string | null; +}; + +export type CursorCliConnectionLike = { + id?: unknown; + apiKey?: unknown; + accessToken?: unknown; + priority?: unknown; + rateLimitedUntil?: unknown; +}; + +export type CursorCliProxyDeps = { + fetchImpl: typeof fetch; + now: () => number; + getSecret: () => string | undefined; + validateApiKey: (key: string) => Promise; + getApiKeyMetadata: (key: string) => Promise<{ id: string; name: string } | null>; + getApiKeyById: (id: string) => Promise<{ isActive?: unknown; revokedAt?: unknown } | null>; + requireApiKey: () => boolean; + listCursorConnections: () => Promise; + resolveBearer: (credentials: { + apiKey?: string | null; + accessToken?: string | null; + }) => Promise; + invalidateBearer: (apiKey: string) => void; + saveCallLog: (entry: Record) => Promise; + upstreamBaseUrl: string; +}; + +const defaultDeps: CursorCliProxyDeps = { + fetchImpl: (input, init) => fetch(input, init), + now: () => Date.now(), + getSecret: () => process.env.JWT_SECRET, + validateApiKey: (key) => validateApiKey(key), + getApiKeyMetadata: async (key) => { + const meta = await getApiKeyMetadata(key); + return meta ? { id: meta.id, name: meta.name } : null; + }, + getApiKeyById: (id) => getApiKeyById(id), + requireApiKey: () => isRequireApiKeyEnabled(), + listCursorConnections: async () => + (await getProviderConnections({ + provider: PROVIDER_ID, + isActive: true, + })) as CursorCliConnectionLike[], + resolveBearer: (credentials) => resolveCursorBearerToken(credentials), + invalidateBearer: (apiKey) => invalidateCursorSessionToken(apiKey), + saveCallLog: (entry) => saveCallLog(entry), + upstreamBaseUrl: CURSOR_API_BASE_URL, +}; + +function jsonResponse(status: number, body: Record): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function connectError(status: number, code: string, message: string): Response { + return jsonResponse(status, { code, message: sanitizeErrorMessage(message) }); +} + +function extractBearer(request: Request): string | null { + const header = request.headers.get("authorization") ?? ""; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + return match ? match[1].trim() : null; +} + +function secretKey(secret: string): Uint8Array { + return new TextEncoder().encode(secret); +} + +export function normalizeCursorCliPath(segments: readonly string[]): string { + return "/" + segments.map((segment) => encodeURIComponent(decodeURIComponent(segment))).join("/"); +} + +async function authenticateExchange( + request: Request, + deps: CursorCliProxyDeps +): Promise { + const bearer = extractBearer(request); + if (bearer && (await deps.validateApiKey(bearer))) { + const meta = await deps.getApiKeyMetadata(bearer); + return { apiKeyId: meta?.id ?? null, apiKeyName: meta?.name ?? null }; + } + if (!deps.requireApiKey()) { + return { apiKeyId: null, apiKeyName: null }; + } + return connectError( + HTTP_STATUS.UNAUTHORIZED, + "unauthenticated", + "CURSOR_API_KEY must be an OmniRoute API key when OmniRoute requires API keys" + ); +} + +export async function mintCursorCliSessionToken( + principal: CursorCliPrincipal, + secret: string, + nowMs: number +): Promise { + const nowSeconds = Math.floor(nowMs / 1000); + return new SignJWT({ name: principal.apiKeyName }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuer(CURSOR_CLI_SESSION_ISSUER) + .setAudience(CURSOR_CLI_SESSION_AUDIENCE) + .setSubject(principal.apiKeyId ?? ANONYMOUS_SUBJECT) + .setIssuedAt(nowSeconds) + .setExpirationTime(nowSeconds + CURSOR_CLI_SESSION_TTL_SECONDS) + .sign(secretKey(secret)); +} + +async function verifyCursorCliSessionToken( + token: string, + secret: string, + nowMs: number +): Promise { + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(token, secretKey(secret), { + issuer: CURSOR_CLI_SESSION_ISSUER, + audience: CURSOR_CLI_SESSION_AUDIENCE, + currentDate: new Date(nowMs), + })); + } catch { + return null; + } + const claims = sessionClaimsSchema.safeParse(payload); + if (!claims.success) return null; + return { + apiKeyId: claims.data.sub === ANONYMOUS_SUBJECT ? null : claims.data.sub, + apiKeyName: claims.data.name ?? null, + }; +} + +async function isPrincipalStillValid( + principal: CursorCliPrincipal, + deps: CursorCliProxyDeps +): Promise { + if (!principal.apiKeyId) return !deps.requireApiKey(); + const row = await deps.getApiKeyById(principal.apiKeyId); + if (!row) return false; + if (row.isActive === false) return false; + return !(typeof row.revokedAt === "string" && row.revokedAt.trim() !== ""); +} + +type ResolvedConnection = { + connectionId: string | null; + bearer: string; + apiKey: string | null; +}; + +function connectionPriority(connection: CursorCliConnectionLike): number { + return typeof connection.priority === "number" ? connection.priority : Number.MAX_SAFE_INTEGER; +} + +function isCoolingDown(connection: CursorCliConnectionLike, nowMs: number): boolean { + if (typeof connection.rateLimitedUntil !== "string") return false; + const until = Date.parse(connection.rateLimitedUntil); + return Number.isFinite(until) && until > nowMs; +} + +async function resolveUpstreamConnection( + deps: CursorCliProxyDeps +): Promise { + const connections = (await deps.listCursorConnections()) + .filter((connection) => !isCoolingDown(connection, deps.now())) + .sort((a, b) => connectionPriority(a) - connectionPriority(b)); + if (connections.length === 0) { + return connectError( + HTTP_STATUS.SERVICE_UNAVAILABLE, + "unavailable", + "No active Cursor API connection configured in OmniRoute" + ); + } + let lastError: unknown = null; + for (const connection of connections) { + const apiKey = isCursorApiKey(connection.apiKey) ? connection.apiKey : null; + const accessToken = typeof connection.accessToken === "string" ? connection.accessToken : null; + try { + const bearer = await deps.resolveBearer({ apiKey, accessToken }); + return { + connectionId: typeof connection.id === "string" ? connection.id : null, + bearer, + apiKey, + }; + } catch (err) { + lastError = err; + } + } + const status = + lastError instanceof CursorApiKeyExchangeError ? lastError.status : HTTP_STATUS.BAD_GATEWAY; + const message = lastError instanceof Error ? lastError.message : "Cursor credential unavailable"; + return connectError( + status, + status === HTTP_STATUS.UNAUTHORIZED ? "unauthenticated" : "unavailable", + message + ); +} + +function buildUpstreamHeaders(request: Request, bearer: string): Headers { + const headers = new Headers(); + request.headers.forEach((value, name) => { + if (!REQUEST_HEADER_DENYLIST.has(name.toLowerCase())) headers.set(name, value); + }); + headers.set("authorization", `Bearer ${bearer}`); + return headers; +} + +function buildDownstreamHeaders(upstream: Response): Headers { + const headers = new Headers(); + upstream.headers.forEach((value, name) => { + if (!RESPONSE_HEADER_DENYLIST.has(name.toLowerCase())) headers.set(name, value); + }); + return headers; +} + +type CallLogInput = { + method: string; + path: string; + status: number; + startedAt: number; + principal: CursorCliPrincipal | null; + connectionId: string | null; + error?: string | null; +}; + +function recordCall(deps: CursorCliProxyDeps, input: CallLogInput): void { + void deps + .saveCallLog({ + method: input.method, + path: `${CURSOR_CLI_PROXY_PREFIX}${input.path}`, + status: input.status, + model: "-", + provider: PROVIDER_ID, + connectionId: input.connectionId, + duration: Math.max(0, deps.now() - input.startedAt), + apiKeyId: input.principal?.apiKeyId ?? null, + apiKeyName: input.principal?.apiKeyName ?? null, + requestType: CURSOR_CLI_REQUEST_TYPE, + sourceFormat: CURSOR_CLI_REQUEST_TYPE, + targetFormat: CURSOR_CLI_REQUEST_TYPE, + error: input.error ? { message: sanitizeErrorMessage(input.error) } : null, + }) + .catch(() => undefined); +} + +function streamWithCompletionLog( + body: ReadableStream, + onDone: (error?: string) => void +): ReadableStream { + const reader = body.getReader(); + let settled = false; + const settle = (error?: string) => { + if (settled) return; + settled = true; + onDone(error); + }; + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + if (done) { + settle(); + controller.close(); + return; + } + controller.enqueue(value); + } catch (err) { + settle(err instanceof Error ? err.message : "upstream stream failed"); + controller.error(err); + } + }, + cancel(reason) { + settle(reason instanceof Error ? reason.message : "stream cancelled"); + return reader.cancel(reason); + }, + }); +} + +async function handleExchange( + request: Request, + startedAt: number, + deps: CursorCliProxyDeps +): Promise { + if (request.method !== "POST") { + return connectError(405, "unimplemented", "Use POST"); + } + const rawBody = await request.text(); + if (rawBody.trim().length > 0) { + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return connectError(HTTP_STATUS.BAD_REQUEST, "invalid_argument", "Body must be JSON"); + } + if (!exchangeBodySchema.safeParse(parsed).success) { + return connectError( + HTTP_STATUS.BAD_REQUEST, + "invalid_argument", + "Body must be a JSON object" + ); + } + } + + const principal = await authenticateExchange(request, deps); + if (principal instanceof Response) { + recordCall(deps, { + method: request.method, + path: CURSOR_API_KEY_EXCHANGE_PATH, + status: principal.status, + startedAt, + principal: null, + connectionId: null, + error: "OmniRoute API key rejected", + }); + return principal; + } + + const secret = deps.getSecret(); + if (!secret || secret.trim().length === 0) { + return connectError( + HTTP_STATUS.SERVICE_UNAVAILABLE, + "unavailable", + "JWT_SECRET is not configured; the Cursor CLI passthrough cannot mint session tokens" + ); + } + + const token = await mintCursorCliSessionToken(principal, secret, deps.now()); + recordCall(deps, { + method: request.method, + path: CURSOR_API_KEY_EXCHANGE_PATH, + status: 200, + startedAt, + principal, + connectionId: null, + }); + return jsonResponse(200, { accessToken: token, refreshToken: token }); +} + +async function handleForward( + request: Request, + path: string, + startedAt: number, + deps: CursorCliProxyDeps +): Promise { + const secret = deps.getSecret(); + const bearer = extractBearer(request); + const principal = + bearer && secret ? await verifyCursorCliSessionToken(bearer, secret, deps.now()) : null; + if (!principal || !(await isPrincipalStillValid(principal, deps))) { + return connectError( + HTTP_STATUS.UNAUTHORIZED, + "unauthenticated", + "Missing or expired OmniRoute Cursor CLI session token" + ); + } + + const resolved = await resolveUpstreamConnection(deps); + if (resolved instanceof Response) { + recordCall(deps, { + method: request.method, + path, + status: resolved.status, + startedAt, + principal, + connectionId: null, + error: "No usable Cursor connection", + }); + return resolved; + } + + const search = new URL(request.url).search; + const upstreamUrl = `${deps.upstreamBaseUrl}${path}${search}`; + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + let upstream: Response; + try { + upstream = await deps.fetchImpl(upstreamUrl, { + method: request.method, + headers: buildUpstreamHeaders(request, resolved.bearer), + body: hasBody ? request.body : undefined, + signal: request.signal, + redirect: "manual", + ...(hasBody ? { duplex: "half" } : {}), + } as RequestInit); + } catch (err) { + const message = err instanceof Error ? err.message : "upstream request failed"; + recordCall(deps, { + method: request.method, + path, + status: HTTP_STATUS.BAD_GATEWAY, + startedAt, + principal, + connectionId: resolved.connectionId, + error: message, + }); + return connectError(HTTP_STATUS.BAD_GATEWAY, "unavailable", message); + } + + if (upstream.status === HTTP_STATUS.UNAUTHORIZED && resolved.apiKey) { + deps.invalidateBearer(resolved.apiKey); + } + + const logInput: CallLogInput = { + method: request.method, + path, + status: upstream.status, + startedAt, + principal, + connectionId: resolved.connectionId, + }; + const headers = buildDownstreamHeaders(upstream); + if (!upstream.body) { + recordCall(deps, logInput); + return new Response(null, { status: upstream.status, headers }); + } + const body = streamWithCompletionLog(upstream.body, (error) => + recordCall(deps, { ...logInput, error: error ?? null }) + ); + return new Response(body, { status: upstream.status, headers }); +} + +export async function handleCursorCliProxy( + request: Request, + segments: readonly string[], + overrides: Partial = {} +): Promise { + const deps: CursorCliProxyDeps = { ...defaultDeps, ...overrides }; + const startedAt = deps.now(); + const path = normalizeCursorCliPath(segments); + if (path === CURSOR_API_KEY_EXCHANGE_PATH) { + return handleExchange(request, startedAt, deps); + } + return handleForward(request, path, startedAt, deps); +} diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 7fb6357db1..c8312ab974 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -44,7 +44,7 @@ import { handleImagen3ImageGeneration } from "./imageGeneration/providers/imagen import { handleIdeogramImageGeneration } from "./imageGeneration/providers/ideogram.ts"; import { handleHaiperImageGeneration } from "./imageGeneration/providers/haiper.ts"; import { handleLeonardoImageGeneration } from "./imageGeneration/providers/leonardo.ts"; -import { handleFreepikImageGeneration } from "./imageGeneration/providers/freepik.ts"; +import { handleMagnificImageGeneration } from "./imageGeneration/providers/magnific.ts"; import { handleChatGptWebImageGeneration, extractMarkdownImageUrls, @@ -54,6 +54,7 @@ import { handleGeminiWebImageGeneration } from "./imageGeneration/providers/gemi import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts"; +import { handleCursorAgentImageGeneration } from "./imageGeneration/providers/cursorAgentImage.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts"; import { handleAlibabaImageGeneration } from "./imageGeneration/providers/alibabaImage.ts"; @@ -184,6 +185,29 @@ function sanitizeImageProviderError(errorText: string): unknown { return sanitizeErrorMessage(errorText); } +// #8307 — some ChatGPT accounts can run Codex but lack entitlement for the specific +// requested image model. Upstream signals this as a 400 with an exact, stable message +// (not a generic "invalid request"). Classify it so the caller can mark the failure +// `retryable: true`, which routes it through the same sibling-account fallback that +// already handles 401s (executeImageWithCredentialFallback, src/sse/services/imageCredentialRetry.ts). +function isCodexChatGptModelAccessError(status: number, errorText: string, model: string): boolean { + if (status !== 400) return false; + const parsed = parseJsonOrNull(errorText); + let detail: string | null = null; + if (typeof parsed === "string") { + detail = parsed; + } else if (parsed && typeof parsed === "object") { + const obj = parsed as Record; + if (typeof obj.detail === "string") detail = obj.detail; + else if (typeof obj.message === "string") detail = obj.message; + else if (obj.error && typeof obj.error === "object") { + const nested = (obj.error as Record).message; + if (typeof nested === "string") detail = nested; + } + } + return detail === `The '${model}' model is not supported when using Codex with a ChatGPT account.`; +} + const BFL_MODEL_ENDPOINTS = { "flux-2-max": "/v1/flux-2-max", "flux-2-pro": "/v1/flux-2-pro", @@ -277,6 +301,10 @@ const FAL_PRESET_SIZES = { * @param {object} options.credentials - Provider credentials { apiKey, accessToken } * @param {object} options.log - Logger * @param {string} [options.resolvedProvider] - Pre-resolved provider ID (from route layer custom model resolution) + * @param {string|null} [options.peerLocality] - Trusted "loopback"|"lan"|"remote" verdict + * forwarded from `AUTHZ_HEADER_PEER_LOCALITY` (src/server/authz/headers.ts). Only consumed by + * spawn-capable providers (e.g. cursor-agent-image) to enforce Hard Rules #15/#17 without + * loopback-gating the whole route for every non-spawning image provider. */ export async function handleImageGeneration({ body, @@ -285,6 +313,7 @@ export async function handleImageGeneration({ resolvedProvider = null, signal = null, clientHeaders = null, + peerLocality = null, }) { let provider, model; @@ -495,6 +524,18 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "cursor-agent-image") { + return handleCursorAgentImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + peerLocality, + }); + } + if (providerConfig.format === "designer-web") { return handleDesignerWebImageGeneration({ model, @@ -590,8 +631,8 @@ export async function handleImageGeneration({ log, }); } - if (providerConfig.format === "freepik-image") { - return handleFreepikImageGeneration({ + if (providerConfig.format === "magnific-image" || providerConfig.format === "freepik-image") { + return handleMagnificImageGeneration({ model, provider, providerConfig, @@ -1305,6 +1346,107 @@ export async function handleOpenAIImageEdit({ return result; } +/** + * Handle OpenRouter's unified Image API reference-image flow. + * + * OpenRouter does not expose `/images/edits`; image-to-image requests use + * `POST /api/v1/images` with `input_references` containing data-URL images. + * Keep this separate from the generic multipart `/images/edits` forwarder, + * whose contract is used by custom OpenAI-compatible nodes (#10197). + */ +export async function handleOpenRouterImageEdit({ + model, + provider, + baseUrl, + credentials, + prompt, + imageBytes, + imageMime, + size, + n = 1, + log, +}: { + model: string; + provider: string; + baseUrl: string; + credentials: + | { + apiKey?: string; + accessToken?: string; + } + | null + | undefined; + prompt: string; + imageBytes: Buffer; + imageMime?: string | null; + size?: string | null; + n?: number; + log?: { info: (tag: string, message: string) => void } | null; +}) { + const startTime = Date.now(); + let url = baseUrl.trim(); + while (url.endsWith("/")) url = url.slice(0, -1); + if (url.endsWith("/images/generations")) { + url = url.slice(0, -"/images/generations".length) + "/images"; + } else if (!url.endsWith("/images")) { + url += "/images"; + } + + const mime = imageMime || "image/png"; + const upstreamBody: Record = { + model, + prompt, + input_references: [ + { + type: "image_url", + image_url: { + url: `data:${mime};base64,${imageBytes.toString("base64")}`, + }, + }, + ], + n: n || 1, + }; + if (size) upstreamBody.size = size; + + const headers: Record = { + "Content-Type": "application/json", + }; + const token = credentials?.apiKey || credentials?.accessToken; + if (token) headers.Authorization = `Bearer ${token}`; + + log?.info( + "IMAGE", + `${provider}/${model} (reference edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}` + ); + + const result = await fetchImageEndpoint( + url, + headers, + JSON.stringify(upstreamBody), + provider, + log + ); + + saveCallLog({ + method: "POST", + path: "/v1/images/edits", + status: result.status || (result.success ? 200 : 502), + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + error: result.success + ? null + : typeof result.error === "string" + ? result.error.slice(0, 500) + : null, + requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 }, + responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, + }).catch(() => {}); + + return result; +} + export async function handleImageEdit({ provider, model, @@ -2532,6 +2674,7 @@ async function handleCodexImageGeneration({ const safeErrorLog = typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {}); if (log) log.error("IMAGE", `${provider} error ${response.status}: ${safeErrorLog}`); + const retryable = isCodexChatGptModelAccessError(response.status, errorText, model); return { ok: false as const, error: { @@ -2542,6 +2685,7 @@ async function handleCodexImageGeneration({ error: safeError, requestBody: requestBodyForLog, path: logPath, + ...(retryable ? { retryable: true } : {}), }, }; } diff --git a/open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts b/open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts new file mode 100644 index 0000000000..a05b7ef854 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts @@ -0,0 +1,488 @@ +/** + * Cursor Agent image generation — OpenAI `/v1/images/generations` backed by the + * Cursor Agent CLI's native `generateImage` tool (real diffusion, not SVG). + * + * Why CLI (not AgentService/Run): OmniRoute's Cursor chat executor talks to + * `agent.v1.AgentService/Run` over protobuf and **rejects** built-in tools + * (shell/write/…). Image generation is a Cursor-native client tool that the + * `agent` binary executes locally against the seat. Spawning the CLI with a + * locked prompt + per-request workspace mirrors the proven seat bridge shape + * and reuses the same `provider_connections` row as chat (`provider: "cursor"`). + * + * Auth: `credentials.accessToken` / `apiKey` from the Cursor OAuth (or API-key) + * connection. Tokens matching `crsr_…` are exported as `CURSOR_API_KEY`; other + * session JWTs as `CURSOR_AUTH_TOKEN`. The `account::token` composite used by + * the chat executor is normalized the same way (`split("::")[1]`). + * + * Binary: `CURSOR_AGENT_BIN` → `providerSpecificData.agentBin` → PATH / default + * shim under `~/.local/bin/agent`. Missing binary → HTTP 501 with install hint. + */ + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; +import { IMAGE_PROVIDERS } from "../../../config/imageRegistry.ts"; + +export const CURSOR_AGENT_IMAGE_FORMAT = "cursor-agent-image"; + +const DEFAULT_TIMEOUT_MS = 210_000; +const DEFAULT_MAX_CONCURRENT = 2; +const DEFAULT_MODEL = "auto"; +const MAX_N = 4; + +// Upper bound on a caller-supplied `timeout_ms`. The Cursor seat is shared and +// CURSOR_IMG_MAX_CONCURRENT defaults to only 2 slots, so a huge per-request +// timeout must not hog a slot and starve every other caller. +const MAX_TIMEOUT_MS = 300_000; + +// Models the Agent CLI `--model` argv may receive — kept in sync with the +// registry entry (auto | composer-2 | composer-2.5). The request `model` is +// untrusted input forwarded straight into a spawned CLI, so we mirror the +// auggie executor: anything outside this set (unknown model, or a flag-shaped +// value like "--foo" / "-x") is clamped to DEFAULT_MODEL and never reaches argv. +const CURSOR_IMAGE_MODEL_ALLOWLIST: ReadonlySet = new Set( + (IMAGE_PROVIDERS.cursor?.models ?? []).map((m) => m.id) +); + +/** Clamp a model candidate to the allowlist; unknown/flag-shaped → "auto". */ +export function resolveCursorImageModel(candidate: unknown): string { + const requested = typeof candidate === "string" ? candidate.trim() : ""; + return CURSOR_IMAGE_MODEL_ALLOWLIST.has(requested) ? requested : DEFAULT_MODEL; +} + +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]); + +/** + * Localities allowed to trigger the `agent` binary spawn below (Hard Rules + * #15 + #17). `/v1/images/generations` is a normal remote-reachable inference + * route shared by ~40 image providers that only proxy HTTP — the ONLY branch + * here that spawns a child process is this one, so the whole route cannot be + * classified in `LOCAL_ONLY_API_PREFIXES` (routeGuard.ts) without blocking + * every other, non-spawning image provider for remote callers. Instead this + * handler enforces its OWN loopback/LAN gate using the trusted locality + * verdict the authz pipeline already stamps on every request + * (`AUTHZ_HEADER_PEER_LOCALITY`, src/server/authz/headers.ts, computed from + * the real TCP peer IP — never the spoofable Host header). Mirrors the + * loopback-or-private-LAN policy `managementPolicy` applies to every other + * LOCAL_ONLY route (src/server/authz/policies/management.ts). + */ +const SPAWN_ALLOWED_LOCALITIES = new Set(["loopback", "lan"]); + +/** Locked instruction — ingress callers can only trigger image gen, never a shell. */ +export function buildCursorAgentImagePrompt(userPrompt: string, outPath: string, size?: unknown): string { + const sizeHint = + typeof size === "string" && size.trim() ? ` Target size/aspect: ${size.trim()}.` : ""; + return [ + "You have a native image-generation tool. Use it to generate ONE image.", + "Do NOT write code, do NOT hand-author SVG, do NOT install packages — use your built-in image generation.", + `Image to generate: ${userPrompt}.${sizeHint}`, + `Save the resulting image to exactly this path: ${outPath}.`, + "When the file exists at that exact path, reply with only the word DONE.", + ].join(" "); +} + +/** Strip OmniRoute `account::token` composites the same way CursorExecutor does. */ +export function normalizeCursorSeatToken(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return trimmed; + return trimmed.includes("::") ? trimmed.split("::").slice(1).join("::").trim() || trimmed : trimmed; +} + +/** + * Map a Cursor connection token into the env vars the Agent CLI reads. + * Prefer API keys (`crsr_…`) as `CURSOR_API_KEY`; otherwise session JWT → `CURSOR_AUTH_TOKEN`. + */ +export function buildCursorAgentAuthEnv(token: string): Record { + const clean = normalizeCursorSeatToken(token); + if (clean.startsWith("crsr_")) { + return { CURSOR_API_KEY: clean }; + } + return { CURSOR_AUTH_TOKEN: clean }; +} + +export function resolveCursorAgentBin(override?: string | null): string | null { + // Explicit connection override wins even when the path is missing — the handler + // returns 501 so operators see a clear misconfiguration instead of a silent fallback. + if (typeof override === "string" && override.trim()) { + return override.trim(); + } + const envBin = process.env.CURSOR_AGENT_BIN?.trim(); + if (envBin) return envBin; + + const defaultShim = join(homedir(), ".local", "bin", "agent"); + if (existsSync(defaultShim)) return defaultShim; + + // Last resort: bare `agent` on PATH (spawn fails with ENOENT → 501). + return "agent"; +} + +export function isRasterImageBuffer(buf: Buffer): boolean { + if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return true; + if (buf.length >= 3 && buf.subarray(0, 3).equals(JPEG_MAGIC)) return true; + return false; +} + +export async function findCursorAgentImageOutput( + workspace: string, + preferredPath: string +): Promise { + if (existsSync(preferredPath)) return preferredPath; + try { + const entries = await readdir(workspace); + const match = entries.find((name) => /\.(png|jpe?g|webp)$/i.test(name)); + return match ? join(workspace, match) : null; + } catch { + return null; + } +} + +function normalizePositiveInt(value: unknown, fallback: number, max?: number): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return fallback; + const i = Math.floor(n); + return typeof max === "number" ? Math.min(i, max) : i; +} + +/** + * Effective per-image wall clock: a caller-supplied `timeout_ms` clamped to + * MAX_TIMEOUT_MS. When the request omits it, fall back to the operator default + * (CURSOR_IMG_TIMEOUT_MS) / DEFAULT_TIMEOUT_MS uncapped — operator config is + * trusted; only the untrusted request value is clamped. + */ +export function resolveCursorImageTimeoutMs(rawTimeout: unknown): number { + return normalizePositiveInt( + rawTimeout, + normalizePositiveInt(process.env.CURSOR_IMG_TIMEOUT_MS, DEFAULT_TIMEOUT_MS), + MAX_TIMEOUT_MS + ); +} + +type CursorAgentImageCredentials = { + apiKey?: string; + accessToken?: string; + providerSpecificData?: Record | null; +}; + +function extractSeatToken(credentials: CursorAgentImageCredentials): string { + const raw = credentials?.accessToken || credentials?.apiKey || ""; + return typeof raw === "string" ? raw.trim() : ""; +} + +function extractAgentBinOverride(credentials: CursorAgentImageCredentials): string | null { + const psd = credentials?.providerSpecificData; + if (!psd || typeof psd !== "object" || Array.isArray(psd)) return null; + const bin = psd.agentBin; + return typeof bin === "string" && bin.trim() ? bin.trim() : null; +} + +function extractAgentModel(credentials: CursorAgentImageCredentials, requestModel: string): string { + const psd = credentials?.providerSpecificData; + if (psd && typeof psd === "object" && !Array.isArray(psd)) { + const fromPsd = psd.imageModel; + if (typeof fromPsd === "string" && fromPsd.trim()) return fromPsd.trim(); + } + if (process.env.CURSOR_IMG_MODEL?.trim()) return process.env.CURSOR_IMG_MODEL.trim(); + // The request's `model=cursor/<…>` field is untrusted and flows into the CLI + // `--model` argv — clamp it to the registry allowlist (unknown/flag-shaped → + // "auto"). The operator overrides above (connection psd / CURSOR_IMG_MODEL) + // are trusted deployment config and pass through unchanged. + return resolveCursorImageModel( + requestModel && requestModel !== "cursor" ? requestModel : DEFAULT_MODEL + ); +} + +// ─── process-wide concurrency gate (one shared Cursor seat) ───────────────── + +type Waiter = () => void; +let activeGenerations = 0; +const waitQueue: Waiter[] = []; + +export function __resetCursorAgentImageConcurrencyForTests(): void { + activeGenerations = 0; + waitQueue.length = 0; +} + +function maxConcurrent(): number { + return normalizePositiveInt(process.env.CURSOR_IMG_MAX_CONCURRENT, DEFAULT_MAX_CONCURRENT); +} + +async function acquireSlot(): Promise { + if (activeGenerations < maxConcurrent()) { + activeGenerations += 1; + return; + } + await new Promise((resolve) => { + waitQueue.push(() => { + activeGenerations += 1; + resolve(); + }); + }); +} + +function releaseSlot(): void { + activeGenerations = Math.max(0, activeGenerations - 1); + const next = waitQueue.shift(); + if (next) next(); +} + +export type RunCursorAgentImageOptions = { + agentBin: string; + workspace: string; + prompt: string; + model: string; + authEnv: Record; + timeoutMs: number; + spawnImpl?: typeof spawn; +}; + +/** Spawn `agent -p --force …` and resolve when it exits 0 (or reject on timeout/error). */ +export function runCursorAgentImageProcess(opts: RunCursorAgentImageOptions): Promise<{ + stdout: string; + stderr: string; +}> { + const spawnImpl = opts.spawnImpl ?? spawn; + const args = [ + "-p", + "--force", + "--model", + opts.model, + "--workspace", + opts.workspace, + "--output-format", + "text", + opts.prompt, + ]; + + return new Promise((resolve, reject) => { + const child = spawnImpl(opts.agentBin, args, { + cwd: opts.workspace, + env: { + ...process.env, + ...opts.authEnv, + HOME: process.env.HOME || homedir(), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`Cursor Agent image generation timed out after ${opts.timeoutMs}ms`)); + }, opts.timeoutMs); + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += String(chunk); + }); + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject( + new Error( + `Cursor Agent exited ${code}: ${(stderr || stdout).trim().slice(0, 400) || "no output"}` + ) + ); + }); + }); +} + +async function generateOneImage(params: { + userPrompt: string; + size: unknown; + agentBin: string; + model: string; + authEnv: Record; + timeoutMs: number; + spawnImpl?: typeof spawn; +}): Promise { + const workspace = await mkdtemp(join(tmpdir(), "omni-cursor-img-")); + const outPath = join(workspace, "out.png"); + const prompt = buildCursorAgentImagePrompt(params.userPrompt, outPath, params.size); + + try { + await runCursorAgentImageProcess({ + agentBin: params.agentBin, + workspace, + prompt, + model: params.model, + authEnv: params.authEnv, + timeoutMs: params.timeoutMs, + spawnImpl: params.spawnImpl, + }); + + const found = await findCursorAgentImageOutput(workspace, outPath); + if (!found) { + throw new Error("Cursor Agent produced no image file in the workspace"); + } + const buf = await readFile(found); + if (!isRasterImageBuffer(buf)) { + throw new Error("Cursor Agent output is not a PNG/JPEG raster"); + } + return buf; + } finally { + await rm(workspace, { recursive: true, force: true }).catch(() => {}); + } +} + +export async function handleCursorAgentImageGeneration({ + model, + provider, + providerConfig: _providerConfig, + body, + credentials, + log, + spawnImpl, + peerLocality, +}: { + model: string; + provider: string; + providerConfig: { baseUrl?: string }; + body: { + prompt?: unknown; + size?: unknown; + n?: unknown; + timeout_ms?: unknown; + }; + credentials: CursorAgentImageCredentials; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + /** Test seam — defaults to node:child_process.spawn */ + spawnImpl?: typeof spawn; + /** + * Trusted locality verdict ("loopback" | "lan" | "remote") forwarded by the + * route layer from `AUTHZ_HEADER_PEER_LOCALITY` (stamped by the authz + * pipeline from the real TCP peer, never the spoofable Host header). Absent + * or unrecognized → fail closed (treated as "remote"). + */ + peerLocality?: string | null; +}) { + const startTime = Date.now(); + + // Hard Rules #15 + #17: reject before doing ANY other work — credential + // lookup, prompt validation, and the `agent` binary spawn itself must never + // run for a non-loopback/non-LAN caller. A leaked API key tunneled from the + // public internet must not be able to trigger a child-process spawn on the + // OmniRoute host. + if (!peerLocality || !SPAWN_ALLOWED_LOCALITIES.has(peerLocality)) { + return saveImageErrorResult({ + provider, + model, + status: 403, + startTime, + error: + "Cursor Agent image generation spawns a local process and is only available from localhost or the private LAN OmniRoute runs on.", + }); + } + + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for Cursor Agent image generation", + }); + } + + const token = extractSeatToken(credentials); + if (!token) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "Cursor credentials missing accessToken — reconnect the Cursor provider", + }); + } + + const agentBin = resolveCursorAgentBin(extractAgentBinOverride(credentials)); + if (!agentBin || (agentBin !== "agent" && !existsSync(agentBin))) { + // Bare "agent" may still resolve via PATH; only hard-fail when an explicit path is missing. + if (agentBin !== "agent") { + return saveImageErrorResult({ + provider, + model, + status: 501, + startTime, + error: + "Cursor Agent CLI not found. Install the Cursor `agent` binary and set CURSOR_AGENT_BIN, or set providerSpecificData.agentBin on the Cursor connection.", + }); + } + } + + const timeoutMs = resolveCursorImageTimeoutMs(body.timeout_ms); + const count = normalizePositiveInt(body.n, 1, MAX_N); + const agentModel = extractAgentModel(credentials, model); + const authEnv = buildCursorAgentAuthEnv(token); + + if (log?.info) { + log.info( + "IMAGE", + `${provider}/${model} (cursor-agent-image) | n=${count} model=${agentModel} bin=${agentBin}` + ); + } + + const images: Array<{ b64_json: string; revised_prompt: string }> = []; + + try { + for (let i = 0; i < count; i++) { + await acquireSlot(); + try { + const buf = await generateOneImage({ + userPrompt: prompt, + size: body.size, + agentBin: agentBin || "agent", + model: agentModel, + authEnv, + timeoutMs, + spawnImpl, + }); + images.push({ b64_json: buf.toString("base64"), revised_prompt: prompt }); + } finally { + releaseSlot(); + } + } + + return saveImageSuccessResult({ + provider, + model, + startTime, + images, + }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + if (log?.error) { + log.error("IMAGE", `${provider} cursor-agent-image error: ${errorText}`); + } + // ENOENT from spawn → treat as missing CLI + const status = + err && typeof err === "object" && "code" in err && (err as { code?: string }).code === "ENOENT" + ? 501 + : 502; + return saveImageErrorResult({ + provider, + model, + status, + startTime, + error: + status === 501 + ? "Cursor Agent CLI not found on PATH. Set CURSOR_AGENT_BIN to the `agent` binary." + : errorText, + }); + } +} diff --git a/open-sse/handlers/imageGeneration/providers/freepik.ts b/open-sse/handlers/imageGeneration/providers/magnific.ts similarity index 74% rename from open-sse/handlers/imageGeneration/providers/freepik.ts rename to open-sse/handlers/imageGeneration/providers/magnific.ts index 2f3320cf06..31ea686327 100644 --- a/open-sse/handlers/imageGeneration/providers/freepik.ts +++ b/open-sse/handlers/imageGeneration/providers/magnific.ts @@ -1,12 +1,11 @@ -// Freepik (Magnific Mystic) image generation adapter. +// Magnific Mystic image generation adapter. // Async submit->poll flow modeled on leonardo.ts's generationId pattern: // POST /v1/ai/mystic returns { data: { task_id, status } }, then // GET /v1/ai/mystic/{task_id} is polled until status is COMPLETED/FAILED. -// Docs: https://docs.magnific.com/api-reference/mystic (Freepik rebranded to -// Magnific in April 2026; both `api.freepik.com` and the newer -// `api.magnific.com` domain/header pair are in circulation during the -// transition, so the base URL and auth header both come from providerConfig -// rather than being hardcoded here). +// Docs: https://docs.magnific.com/api-reference/mystic +// Official host/header: api.magnific.com + x-magnific-api-key. +// Both come from providerConfig so a local override can still use the +// legacy api.freepik.com / x-freepik-api-key pair if needed. import { saveCallLog } from "@/lib/usageDb"; import { sleep } from "../../../utils/sleep.ts"; @@ -21,34 +20,34 @@ function normalizePositiveNumber(value: unknown, fallback: number): number { return Math.floor(n); } -interface FreepikProviderConfig { +interface MagnificProviderConfig { baseUrl: string; statusUrl?: string; authHeader?: string; } -interface FreepikCredentials { +interface MagnificCredentials { apiKey?: string; } -interface FreepikGenerationParams { +interface MagnificGenerationParams { model: string; provider: string; - providerConfig: FreepikProviderConfig; + providerConfig: MagnificProviderConfig; body: Record; - credentials: FreepikCredentials; + credentials: MagnificCredentials; log?: { info: (tag: string, msg: string) => void; error: (tag: string, msg: string) => void }; } -interface FreepikImageResult { +interface MagnificImageResult { success: boolean; status?: number; error?: string; data?: { created: number; data: Array<{ b64_json: string }> }; } -function freepikAuthHeader(providerConfig: FreepikProviderConfig, token: string) { - const headerName = providerConfig.authHeader || "x-freepik-api-key"; +function magnificAuthHeader(providerConfig: MagnificProviderConfig, token: string) { + const headerName = providerConfig.authHeader || "x-magnific-api-key"; return { [headerName]: token }; } @@ -58,7 +57,7 @@ async function logAndFail(params: { startTime: number; status: number; error: string; -}): Promise { +}): Promise { const { provider, model, startTime, status, error } = params; const sanitized = sanitizeErrorMessage(error); saveCallLog({ @@ -74,7 +73,7 @@ async function logAndFail(params: { } async function submitMysticTask(params: { - providerConfig: FreepikProviderConfig; + providerConfig: MagnificProviderConfig; token: string; model: string; prompt: string; @@ -85,7 +84,7 @@ async function submitMysticTask(params: { method: "POST", headers: { "Content-Type": "application/json", - ...freepikAuthHeader(providerConfig, token), + ...magnificAuthHeader(providerConfig, token), }, body: JSON.stringify({ prompt, @@ -97,14 +96,14 @@ async function submitMysticTask(params: { } async function pollMysticTask(params: { - providerConfig: FreepikProviderConfig; + providerConfig: MagnificProviderConfig; token: string; taskId: string; }): Promise<{ status: string; imageUrl?: string }> { const { providerConfig, token, taskId } = params; const statusBase = providerConfig.statusUrl || providerConfig.baseUrl; const res = await fetch(`${statusBase}/${taskId}`, { - headers: { ...freepikAuthHeader(providerConfig, token) }, + headers: { ...magnificAuthHeader(providerConfig, token) }, }); const json = await res.json(); const task = json?.data || json; @@ -113,9 +112,9 @@ async function pollMysticTask(params: { return { status, imageUrl: typeof generated[0] === "string" ? generated[0] : undefined }; } -async function downloadGeneratedImage(imageUrl: string): Promise< - { state: "ok"; b64: string } | { state: "failed"; status: number; error: string } -> { +async function downloadGeneratedImage( + imageUrl: string +): Promise<{ state: "ok"; b64: string } | { state: "failed"; status: number; error: string }> { const imgRes = await fetch(imageUrl); if (!imgRes.ok) { return { @@ -133,7 +132,7 @@ async function resolveCompletedResult(params: { model: string; startTime: number; imageUrl?: string; -}): Promise { +}): Promise { const { provider, model, startTime, imageUrl } = params; if (!imageUrl) { return logAndFail({ @@ -141,7 +140,7 @@ async function resolveCompletedResult(params: { model, startTime, status: 502, - error: "Freepik Mystic completed without a generated image URL", + error: "Magnific Mystic completed without a generated image URL", }); } const downloaded = await downloadGeneratedImage(imageUrl); @@ -163,7 +162,7 @@ async function resolveCompletedResult(params: { } async function pollUntilDone(params: { - providerConfig: FreepikProviderConfig; + providerConfig: MagnificProviderConfig; token: string; taskId: string; provider: string; @@ -171,9 +170,17 @@ async function pollUntilDone(params: { startTime: number; pollIntervalMs: number; pollTimeoutMs: number; -}): Promise { - const { providerConfig, token, taskId, provider, model, startTime, pollIntervalMs, pollTimeoutMs } = - params; +}): Promise { + const { + providerConfig, + token, + taskId, + provider, + model, + startTime, + pollIntervalMs, + pollTimeoutMs, + } = params; const deadline = Date.now() + pollTimeoutMs; while (Date.now() < deadline) { @@ -189,7 +196,7 @@ async function pollUntilDone(params: { model, startTime, status: 502, - error: "Freepik Mystic image generation failed", + error: "Magnific Mystic image generation failed", }); } } @@ -199,24 +206,32 @@ async function pollUntilDone(params: { model, startTime, status: 504, - error: "Freepik Mystic image generation timed out", + error: "Magnific Mystic image generation timed out", }); } async function submitAndGetTaskId(params: { - providerConfig: FreepikProviderConfig; + providerConfig: MagnificProviderConfig; token: string; model: string; prompt: string; body: Record; provider: string; startTime: number; -}): Promise<{ taskId: string } | { failed: FreepikImageResult }> { +}): Promise<{ taskId: string } | { failed: MagnificImageResult }> { const { providerConfig, token, model, prompt, body, provider, startTime } = params; const res = await submitMysticTask({ providerConfig, token, model, prompt, body }); if (!res.ok) { const errorText = await res.text(); - return { failed: await logAndFail({ provider, model, startTime, status: res.status, error: errorText }) }; + return { + failed: await logAndFail({ + provider, + model, + startTime, + status: res.status, + error: errorText, + }), + }; } const submitJson = await res.json(); @@ -228,28 +243,31 @@ async function submitAndGetTaskId(params: { model, startTime, status: 502, - error: "Freepik Mystic did not return a task_id", + error: "Magnific Mystic did not return a task_id", }), }; } return { taskId }; } -export async function handleFreepikImageGeneration({ +export async function handleMagnificImageGeneration({ model, provider, providerConfig, body, credentials, log, -}: FreepikGenerationParams): Promise { +}: MagnificGenerationParams): Promise { const startTime = Date.now(); const token = credentials?.apiKey || ""; const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); const pollIntervalMs = normalizePositiveNumber(body.poll_interval_ms, DEFAULT_POLL_INTERVAL_MS); const pollTimeoutMs = normalizePositiveNumber(body.poll_timeout_ms, DEFAULT_POLL_TIMEOUT_MS); if (log) { - log.info("IMAGE", `${provider}/${model} (freepik-mystic) | prompt: "${prompt.slice(0, 60)}..."`); + log.info( + "IMAGE", + `${provider}/${model} (magnific-mystic) | prompt: "${prompt.slice(0, 60)}..."` + ); } try { @@ -276,7 +294,7 @@ export async function handleFreepikImageGeneration({ }); } catch (err) { const message = (err as Error)?.message || String(err); - if (log) log.error("IMAGE", `${provider} freepik error: ${sanitizeErrorMessage(message)}`); + if (log) log.error("IMAGE", `${provider} magnific error: ${sanitizeErrorMessage(message)}`); return logAndFail({ provider, model, diff --git a/open-sse/handlers/mediaGeneration/minimaxMusic.ts b/open-sse/handlers/mediaGeneration/minimaxMusic.ts new file mode 100644 index 0000000000..3486676058 --- /dev/null +++ b/open-sse/handlers/mediaGeneration/minimaxMusic.ts @@ -0,0 +1,358 @@ +/** + * MiniMax music generation handler (format: "minimax-music"). + * + * The provider entry has been in musicRegistry since the media registries were + * introduced, but handleMusicGeneration never grew a branch for its format — so + * every registered `minimax/*` music model fell through the dispatch chain to + * `Unsupported music format: minimax-music` (400) and the models were + * advertised by /v1/models while being impossible to call. + * + * The upstream contract is a single synchronous POST — unlike the vendor's + * task-based media endpoints there is no task id and no query endpoint, so a + * request is either finished (`data.status` 2, audio in `data.audio`) or still + * generating (`data.status` 1), which can only be reported back, never awaited. + * Failures are carried in the `base_resp` envelope (`status_code` 0 = success) + * even on HTTP 200. + * + * `output_format` selects how the audio comes back: `url` (a short-lived link, + * valid for 24h — callers must download it before it expires) or `hex` (the raw + * container inline, normalized here to base64 so the response matches the + * OpenAI-shaped payload the other music branches return). + */ + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +type MinimaxMusicBody = Record; + +interface MinimaxMusicProviderConfig { + baseUrl: string; + /** Regional deployment of the same contract — see resolveEndpoint below. */ + regionalBaseUrl?: string; +} + +interface MinimaxMusicCredentials { + apiKey?: unknown; + accessToken?: unknown; + providerSpecificData?: { baseUrl?: unknown } | null; +} + +interface MinimaxMusicLog { + info?: (scope: string, message: string) => void; + error?: (scope: string, message: string) => void; +} + +interface MinimaxMusicArgs { + model: string; + provider: string; + providerConfig: MinimaxMusicProviderConfig; + body: MinimaxMusicBody; + credentials?: MinimaxMusicCredentials | null; + log?: MinimaxMusicLog | null; +} + +/** Containers accepted by `audio_setting.format`. */ +const AUDIO_FORMATS = new Set(["mp3", "wav", "pcm"]); +/** Accepted `output_format` values. */ +const OUTPUT_FORMATS = new Set(["url", "hex"]); +/** Container assumed when the request does not pin `audio_setting.format`. */ +const DEFAULT_AUDIO_FORMAT = "mp3"; +/** `data.status`: 1 = still generating, 2 = finished. */ +const STATUS_IN_PROGRESS = 1; +/** String request fields forwarded verbatim when the caller provides them. */ +const STRING_REQUEST_FIELDS = [ + "prompt", + "lyrics", + "audio_url", + "audio_base64", + "cover_feature_id", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +/** Fire-and-forget usage log for a MiniMax music-generation call. */ +function logMinimaxMusicCall(params: { + status: number; + model: string; + provider: string; + duration: number; + error?: string; + requestBody?: unknown; + responseBody?: unknown; +}): void { + saveCallLog({ + method: "POST", + path: "/v1/music/generations", + ...params, + }).catch(() => {}); +} + +/** + * Endpoint for this call: the per-connection `providerSpecificData.baseUrl` + * override (the same storage every configurable-base-URL provider uses) wins + * over the registry default. That override is how a connection targets the + * regional deployment declared as `regionalBaseUrl`. + */ +function resolveEndpoint( + providerConfig: MinimaxMusicProviderConfig, + credentials?: MinimaxMusicCredentials | null +): string { + const psd = credentials?.providerSpecificData; + const override = isRecord(psd) ? stringValue(psd.baseUrl) : undefined; + return override || providerConfig.baseUrl; +} + +/** True when `endpoint` is the regional deployment declared by the registry. */ +function isRegionalEndpoint(endpoint: string, regionalBaseUrl?: string): boolean { + if (!regionalBaseUrl) return false; + try { + return new URL(endpoint).host === new URL(regionalBaseUrl).host; + } catch { + return false; + } +} + +/** Forwards only the recognized `audio_setting` members, dropping unknown containers. */ +function buildAudioSetting(body: MinimaxMusicBody): Record | undefined { + const provided: Record = isRecord(body.audio_setting) ? body.audio_setting : {}; + const setting: Record = {}; + + const sampleRate = numberValue(provided.sample_rate); + if (sampleRate !== undefined) setting.sample_rate = sampleRate; + + const bitrate = numberValue(provided.bitrate); + if (bitrate !== undefined) setting.bitrate = bitrate; + + const format = stringValue(provided.format)?.toLowerCase(); + if (format && AUDIO_FORMATS.has(format)) setting.format = format; + + return Object.keys(setting).length > 0 ? setting : undefined; +} + +/** Container reported back to the caller — mirrors what was asked upstream. */ +function resolveAudioFormat(body: MinimaxMusicBody): string { + const provided: Record = isRecord(body.audio_setting) ? body.audio_setting : {}; + const format = stringValue(provided.format)?.toLowerCase(); + return format && AUDIO_FORMATS.has(format) ? format : DEFAULT_AUDIO_FORMAT; +} + +function resolveOutputFormat(body: MinimaxMusicBody): string { + const requested = stringValue(body.output_format)?.toLowerCase(); + return requested && OUTPUT_FORMATS.has(requested) ? requested : "url"; +} + +/** + * Upstream request body. `stream` is pinned false: this route answers with a + * single JSON payload, and streaming responses would also be restricted to the + * hex output format. + */ +function buildUpstreamBody( + model: string, + body: MinimaxMusicBody, + regional: boolean +): Record { + const request: Record = { + model, + stream: false, + output_format: resolveOutputFormat(body), + }; + + for (const field of STRING_REQUEST_FIELDS) { + const value = stringValue(body[field]); + if (value !== undefined) request[field] = value; + } + + const audioSetting = buildAudioSetting(body); + if (audioSetting) request.audio_setting = audioSetting; + + const lyricsOptimizer = booleanValue(body.lyrics_optimizer); + if (lyricsOptimizer !== undefined) request.lyrics_optimizer = lyricsOptimizer; + + // `instrumental` is the spelling the other music branches already accept. + const isInstrumental = booleanValue(body.is_instrumental) ?? booleanValue(body.instrumental); + if (isInstrumental !== undefined) request.is_instrumental = isInstrumental; + + // Only the regional endpoint accepts a watermark flag. + if (regional) { + const watermark = booleanValue(body.aigc_watermark); + if (watermark !== undefined) request.aigc_watermark = watermark; + } + + return request; +} + +async function readPayload(response: Response): Promise> { + const rawText = await response.text(); + if (!rawText) return {}; + try { + const parsed: unknown = JSON.parse(rawText); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +/** Hex payloads are normalized to base64; Buffer would silently drop bad nibbles. */ +function hexAudioToBase64(audioHex: string): string { + if (audioHex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(audioHex)) { + throw new Error("MiniMax music generation returned invalid hex audio"); + } + return Buffer.from(audioHex, "hex").toString("base64"); +} + +/** `base_resp.status_code` is non-zero on failures that still answer HTTP 200. */ +function readEnvelopeError(payload: Record): string | undefined { + const baseResp: Record = isRecord(payload.base_resp) ? payload.base_resp : {}; + const statusCode = numberValue(baseResp.status_code); + if (statusCode === undefined || statusCode === 0) return undefined; + return stringValue(baseResp.status_msg) || `upstream status code ${statusCode}`; +} + +export async function handleMinimaxMusicGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: MinimaxMusicArgs) { + const startTime = Date.now(); + const token = stringValue(credentials?.apiKey) || stringValue(credentials?.accessToken); + if (!token) { + return { success: false as const, status: 401, error: "MiniMax API key is required" }; + } + + const modelId = stringValue(model); + if (!modelId) { + return { success: false as const, status: 400, error: "MiniMax music model is required" }; + } + + const endpoint = resolveEndpoint(providerConfig, credentials); + const upstreamBody = buildUpstreamBody( + modelId, + body, + isRegionalEndpoint(endpoint, providerConfig.regionalBaseUrl) + ); + const audioFormat = resolveAudioFormat(body); + const modelLabel = `${provider}/${modelId}`; + + log?.info?.( + "MUSIC", + `${modelLabel} (minimax-music) | prompt: "${String(body.prompt ?? "").slice(0, 60)}..." | ` + + `output_format: ${upstreamBody.output_format} | audio_format: ${audioFormat}` + ); + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + const payload = await readPayload(response); + + if (!response.ok) { + const errorMessage = + readEnvelopeError(payload) || `MiniMax music generation failed (${response.status})`; + log?.error?.("MUSIC", `${provider} minimax-music error ${response.status}: ${errorMessage}`); + logMinimaxMusicCall({ + status: response.status, + model: modelLabel, + provider, + duration: Date.now() - startTime, + error: errorMessage, + requestBody: upstreamBody, + }); + return { success: false as const, status: response.status, error: errorMessage }; + } + + const envelopeError = readEnvelopeError(payload); + if (envelopeError) { + log?.error?.("MUSIC", `${provider} minimax-music rejected the request: ${envelopeError}`); + logMinimaxMusicCall({ + status: 502, + model: modelLabel, + provider, + duration: Date.now() - startTime, + error: envelopeError, + requestBody: upstreamBody, + }); + return { success: false as const, status: 502, error: envelopeError }; + } + + const data: Record = isRecord(payload.data) ? payload.data : {}; + + // No task id and no query endpoint exist for this operation, so an + // unfinished generation cannot be polled — surface it instead of hanging. + if (numberValue(data.status) === STATUS_IN_PROGRESS) { + const pending = "MiniMax music generation is still in progress; retry the request"; + logMinimaxMusicCall({ + status: 502, + model: modelLabel, + provider, + duration: Date.now() - startTime, + error: pending, + }); + return { success: false as const, status: 502, error: pending }; + } + + const audio = stringValue(data.audio); + if (!audio) { + const errorMessage = "MiniMax music generation returned no audio"; + logMinimaxMusicCall({ + status: 502, + model: modelLabel, + provider, + duration: Date.now() - startTime, + error: errorMessage, + }); + return { success: false as const, status: 502, error: errorMessage }; + } + + const track = + upstreamBody.output_format === "hex" + ? { b64_json: hexAudioToBase64(audio), format: audioFormat } + : { url: audio, format: audioFormat }; + + logMinimaxMusicCall({ + status: 200, + model: modelLabel, + provider, + duration: Date.now() - startTime, + responseBody: { audio_count: 1 }, + }); + + return { + success: true as const, + data: { created: Math.floor(Date.now() / 1000), data: [track] }, + }; + } catch (err: unknown) { + const errorMessage = sanitizeErrorMessage(err) || "Music provider error"; + log?.error?.("MUSIC", `${provider} minimax-music error: ${errorMessage}`); + logMinimaxMusicCall({ + status: 502, + model: modelLabel, + provider, + duration: Date.now() - startTime, + error: errorMessage, + }); + return { success: false as const, status: 502, error: errorMessage }; + } +} diff --git a/open-sse/handlers/musicGeneration.ts b/open-sse/handlers/musicGeneration.ts index 92ee333c2e..89052b3765 100644 --- a/open-sse/handlers/musicGeneration.ts +++ b/open-sse/handlers/musicGeneration.ts @@ -33,6 +33,7 @@ import { } from "../utils/kieTask.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { handleFalMusicGeneration } from "./mediaGeneration/fal.ts"; +import { handleMinimaxMusicGeneration } from "./mediaGeneration/minimaxMusic.ts"; function normalizeKieSunoModel(model: string): string { const map: Record = { @@ -153,6 +154,17 @@ export async function handleMusicGeneration({ body, credentials, log }) { return handleUdioMusicGeneration({ model, provider, providerConfig, body, credentials, log }); } + if (providerConfig.format === "minimax-music") { + return handleMinimaxMusicGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + return { success: false, status: 400, diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 06d3202f5f..a2210681d7 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -8,7 +8,10 @@ import { collapseExcessiveNewlines, extractThinkingFromContent, } from "./responseSanitizer/reasoning.ts"; -import { applyCacheHitTokensToUsage, applyCacheHitTokensToResponsesUsage } from "./responseSanitizer/cacheHitTokens.ts"; +import { + applyCacheHitTokensToUsage, + applyCacheHitTokensToResponsesUsage, +} from "./responseSanitizer/cacheHitTokens.ts"; export { extractThinkingFromContent, shouldParseTextualReasoningTags, @@ -31,7 +34,9 @@ const ALLOWED_USAGE_FIELDS = new Set([ "total_tokens", "cached_tokens", "prompt_tokens_details", - "completion_tokens_details", "cache_read_input_tokens", "cache_creation_input_tokens", + "completion_tokens_details", + "cache_read_input_tokens", + "cache_creation_input_tokens", // Keep through sanitize → applyClientUsageBuffer so heuristic web usage is // not inflated by the default USAGE_TOKEN_BUFFER (2000). "estimated", @@ -550,7 +555,7 @@ function sanitizeResponsesUsage(usage: unknown): unknown { !(toRecord(normalized.input_tokens_details) ?? {}).cached_tokens ) { normalized.input_tokens_details = { - ...(normalized.input_tokens_details as Record || {}), + ...((normalized.input_tokens_details as Record) || {}), cached_tokens: normalized.prompt_cache_hit_tokens, }; } @@ -562,7 +567,7 @@ function sanitizeResponsesUsage(usage: unknown): unknown { !(toRecord(normalized.input_tokens_details) ?? {}).cached_tokens ) { normalized.input_tokens_details = { - ...(normalized.input_tokens_details as Record || {}), + ...((normalized.input_tokens_details as Record) || {}), cached_tokens: normalized.cache_read_input_tokens, }; } @@ -863,6 +868,7 @@ function sanitizeResponsesOutputItem(item: unknown, index: number): JsonRecord | : []; return { + ...itemRecord, id: toString(itemRecord.id) || `rs_${index}`, type: "reasoning", summary, diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 407393966d..01bdd4c14a 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -10,6 +10,7 @@ import { caseInsensitiveToolNameLookup, restoreOpenAIToolNames, } from "../translator/helpers/toolCallHelper.ts"; +import { extractReplayableResponsesReasoningText } from "../services/reasoningInputPolicy.ts"; import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts"; type JsonRecord = Record; @@ -178,7 +179,8 @@ export function translateNonStreamingResponse( const messageSelection = findBestMessageText(output); let textContent = messageSelection.text; - let reasoningContent = ""; + let replayableReasoningContent = ""; + let reasoningSummary = ""; const toolCalls: JsonRecord[] = []; for (const item of output) { @@ -192,16 +194,22 @@ export function translateNonStreamingResponse( if (partObj.type === "summary_text" && typeof partObj.text === "string") { // #9500 — reasoning summary parts are discrete segments; join with "\n\n" // (matches extractThinkingFromContent convention) so they don't glue back-to-back. - reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text; + reasoningSummary += reasoningSummary ? `\n\n${partObj.text}` : partObj.text; } } - } else if (itemObj.type === "reasoning" && Array.isArray(itemObj.summary)) { - for (const part of itemObj.summary) { - const partObj = toRecord(part); - if (partObj.type === "summary_text" && typeof partObj.text === "string") { - // #9500 — reasoning summary parts are discrete segments; join with "\n\n" - // (matches extractThinkingFromContent convention) so they don't glue back-to-back. - reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text; + } else if (itemObj.type === "reasoning") { + const replayable = extractReplayableResponsesReasoningText(itemObj); + if (replayable) { + replayableReasoningContent += replayableReasoningContent + ? `\n\n${replayable}` + : replayable; + } + if (Array.isArray(itemObj.summary)) { + for (const part of itemObj.summary) { + const partObj = toRecord(part); + if (partObj.type === "summary_text" && typeof partObj.text === "string") { + reasoningSummary += reasoningSummary ? `\n\n${partObj.text}` : partObj.text; + } } } } else if (itemObj.type === "function_call") { @@ -238,8 +246,11 @@ export function translateNonStreamingResponse( if (textContent) { message.content = textContent; } - if (reasoningContent) { - message.reasoning_content = reasoningContent; + if (replayableReasoningContent) { + message.reasoning_content = replayableReasoningContent; + } + if (reasoningSummary) { + message.reasoning_summary = [{ type: "summary_text", text: reasoningSummary }]; } if (toolCalls.length > 0) { message.tool_calls = toolCalls; diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index 42974dc8fa..ca6221538a 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -31,6 +31,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { z } from "zod"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { resolveSearchProxy, executeProviderFetch } from "./search/searchProxy.ts"; +import { formatSearchProviderFailure } from "./search/providerFailure.ts"; export interface SearchResult { title: string; @@ -1177,11 +1178,7 @@ async function tryZaiMCPProvider( /* non-critical — logging must not block search response */ }); - return { - success: false, - status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`, - }; + return formatSearchProviderFailure(config.id, err, isTimeout); } } diff --git a/open-sse/handlers/search/providerFailure.ts b/open-sse/handlers/search/providerFailure.ts new file mode 100644 index 0000000000..e021c2fa80 --- /dev/null +++ b/open-sse/handlers/search/providerFailure.ts @@ -0,0 +1,26 @@ +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +export interface SearchProviderFailure { + success: false; + status: number; + error: string; +} + +/** Named 502/504 for /v1/search — provider id + sanitized cause, no hostnames/URLs. */ +export function formatSearchProviderFailure( + providerId: string, + err: unknown, + isTimeout: boolean +): SearchProviderFailure { + const rec = err && typeof err === "object" ? (err as Record) : {}; + const cause = rec.cause && typeof rec.cause === "object" ? (rec.cause as Record) : {}; + const code = + typeof cause.code === "string" && /^[A-Z][A-Z0-9_]{1,39}$/.test(cause.code) ? cause.code : ""; + const msg = + sanitizeErrorMessage(typeof rec.message === "string" ? rec.message : "fetch failed") || "fetch failed"; + return { + success: false, + status: isTimeout ? 504 : 502, + error: `Search provider ${providerId} ${isTimeout ? "timeout" : "error"}: ${code ? `${msg} (cause: ${code})` : msg}`, + }; +} diff --git a/open-sse/handlers/search/searchProxy.ts b/open-sse/handlers/search/searchProxy.ts index f134b4a3bd..75faea4db7 100644 --- a/open-sse/handlers/search/searchProxy.ts +++ b/open-sse/handlers/search/searchProxy.ts @@ -10,6 +10,7 @@ import { saveCallLog } from "@/lib/usageDb"; import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { formatSearchProviderFailure } from "./providerFailure.ts"; import type { SearchProviderConfig } from "../../config/searchRegistry.ts"; import type { SearchResult } from "../search.ts"; @@ -231,15 +232,12 @@ export async function executeProviderFetch(p: ExecuteProviderFetchParams): Promi clearTimeout(timer); const error = err instanceof Error ? err : new Error(String(err)); const isTimeout = error.name === "AbortError"; + const safeMsg = sanitizeErrorMessage(error.message) || "fetch failed"; if (log) { - log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${error.message}`); + log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${safeMsg}`); } - logCall({ status: isTimeout ? 504 : 502, duration: Date.now() - startTime, error: error.message }); + logCall({ status: isTimeout ? 504 : 502, duration: Date.now() - startTime, error: safeMsg }); await emitEvent(isTimeout ? "timeout" : "error"); - return { - success: false, - status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(error.message)}`, - }; + return formatSearchProviderFailure(config.id, error, isTimeout); } } diff --git a/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts b/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts index 52ce967e1f..8b7f3cee32 100644 --- a/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts +++ b/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts @@ -87,6 +87,9 @@ describe("GLM Coding provider registry surfaces", () => { expect(PROVIDER_ID_TO_ALIAS.glm).toBe("glm"); expect(byProviderId).toEqual(byAlias); expect(byProviderId.map((model) => model.id)).toEqual([ + "glm-5.3", + "glm-5.3-high", + "glm-5.3-low", "glm-5.2", "glm-5.2-high", "glm-5.2-max", diff --git a/open-sse/mcp-server/fetchTimeout.ts b/open-sse/mcp-server/fetchTimeout.ts new file mode 100644 index 0000000000..e3cea5bcce --- /dev/null +++ b/open-sse/mcp-server/fetchTimeout.ts @@ -0,0 +1,72 @@ +/** + * #9717 — timeout policy for the MCP server's internal server→server fetches. + * + * `omniRouteFetch` serves two call shapes with very different latency budgets: + * fast local management reads (health, resilience, combos, quota, usage) and + * calls that wait on an upstream provider. A single 10s default aborted + * `omniroute_route_request` while the upstream request was still in flight, + * even though `omniroute_web_search` / `omniroute_web_fetch` already carried + * their own explicit 60s signal in the same file for exactly that reason. + * + * Kept as a pure, dependency-free module so the policy is unit-testable without + * starting the MCP server, mirroring how `tools/poolTools.ts` keeps handlers + * separate from server wiring. + */ + +/** Local management reads — a stalled one should fail fast, not hold a tool call open. */ +export const MCP_FETCH_TIMEOUT_MS = 10_000; + +/** + * Calls that wait on an upstream provider. 60s is not a new number: it is the + * value `web_search`/`web_fetch` already used, now shared with model routing + * instead of each call site picking its own literal. + */ +export const MCP_UPSTREAM_FETCH_TIMEOUT_MS = 60_000; + +export const MCP_FETCH_TIMEOUT_ENV = "OMNIROUTE_MCP_FETCH_TIMEOUT_MS"; +export const MCP_UPSTREAM_FETCH_TIMEOUT_ENV = "OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS"; + +export type McpFetchTimeoutKind = "management" | "upstream"; + +function readPositiveIntEnv(raw: string | undefined): number | null { + if (typeof raw !== "string" || raw.trim() === "") return null; + const parsed = Number(raw); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} + +function readMcpTimeoutOverride( + kind: McpFetchTimeoutKind, + env: Record +): string | undefined { + // Direct process.env member access so fabricated-docs / env-doc-sync see + // the operator knobs. Tests inject a fake env object and keep using the + // exported constant keys. + if (env === process.env) { + return kind === "upstream" + ? process.env.OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS + : process.env.OMNIROUTE_MCP_FETCH_TIMEOUT_MS; + } + return env[kind === "upstream" ? MCP_UPSTREAM_FETCH_TIMEOUT_ENV : MCP_FETCH_TIMEOUT_ENV]; +} + +/** + * Resolve the timeout for one internal fetch class. An unset, malformed or + * non-positive override falls back to the built-in default rather than + * disabling the timeout — a bad env value must not turn a bounded wait into an + * unbounded one. + */ +export function resolveMcpFetchTimeoutMs( + kind: McpFetchTimeoutKind, + env: Record = process.env +): number { + const override = readPositiveIntEnv(readMcpTimeoutOverride(kind, env)); + return override ?? (kind === "upstream" ? MCP_UPSTREAM_FETCH_TIMEOUT_MS : MCP_FETCH_TIMEOUT_MS); +} + +/** `AbortSignal` for one internal fetch of the given class. */ +export function mcpFetchTimeoutSignal( + kind: McpFetchTimeoutKind, + env?: Record +): AbortSignal { + return AbortSignal.timeout(resolveMcpFetchTimeoutMs(kind, env)); +} diff --git a/open-sse/mcp-server/httpTransport.ts b/open-sse/mcp-server/httpTransport.ts index ab742858c6..d8826c738c 100644 --- a/open-sse/mcp-server/httpTransport.ts +++ b/open-sse/mcp-server/httpTransport.ts @@ -284,12 +284,30 @@ export async function handleMcpStreamableHTTP(request: Request): Promise { + if (request.method === "POST") { + try { + const body = await request.clone().json(); + const isInitialize = Array.isArray(body) + ? body.some((req: RpcRequest) => req?.method === "initialize") + : (body as RpcRequest)?.method === "initialize"; + + if (isInitialize) { + console.log("[MCP] New client initialize detected, resetting SSE singleton..."); + closeSseTransport(); + } + } catch (err) {} + } const { transport } = ensureSseServer(); try { diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index f5634816e1..b2a866e997 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -92,6 +92,7 @@ import { getDbInstance } from "../../src/lib/db/core.ts"; import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { mcpFetchTimeoutSignal } from "./fetchTimeout.ts"; import { getMcpModelsCatalog } from "./catalog.ts"; import { registerRadarCatalogTool } from "./radarCatalog.ts"; import type { TextToolResult } from "./toolResult.ts"; @@ -213,7 +214,7 @@ export async function omniRouteFetch(path: string, options: RequestInit = {}): P ...getInternalServiceAuthHeaders(), }; - const signal = options.signal || AbortSignal.timeout(10000); + const signal = options.signal || mcpFetchTimeoutSignal("management"); const response = await fetch(url, { ...options, headers, signal }); if (!response.ok) { @@ -518,6 +519,10 @@ async function handleRouteRequest(args: { const raw = (await omniRouteFetch("/v1/chat/completions", { method: "POST", body: JSON.stringify(body), + // #9717: this hop waits on an upstream provider (and on auto-combo + // candidate probing before one is even chosen), so it must not inherit + // the management-read budget. + signal: mcpFetchTimeoutSignal("upstream"), })) as JsonRecord; const choices = toArray(raw.choices); const firstChoice = toRecord(choices[0]); @@ -648,7 +653,7 @@ async function handleWebSearch(args: { const result = await omniRouteFetch("/v1/search", { method: "POST", body: JSON.stringify(body), - signal: AbortSignal.timeout(60000), + signal: mcpFetchTimeoutSignal("upstream"), }); await logToolCall("omniroute_web_search", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; @@ -681,7 +686,7 @@ async function handleWebFetch(args: { const result = await omniRouteFetch("/v1/web/fetch", { method: "POST", body: JSON.stringify(body), - signal: AbortSignal.timeout(60000), + signal: mcpFetchTimeoutSignal("upstream"), }); await logToolCall("omniroute_web_fetch", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; diff --git a/open-sse/mcp-server/tools/memoryTools.ts b/open-sse/mcp-server/tools/memoryTools.ts index 908970f2e1..16c835fd8e 100644 --- a/open-sse/mcp-server/tools/memoryTools.ts +++ b/open-sse/mcp-server/tools/memoryTools.ts @@ -7,9 +7,23 @@ import { toMemoryRetrievalConfig, DEFAULT_MEMORY_SETTINGS, } from "@/lib/memory/settings"; +import { resolveMcpCallerApiKeyId } from "../mcpCallerIdentity.ts"; + +/** + * Resolve the memory owner id for an MCP tool call: + * explicit arg wins, otherwise fall back to the authenticated caller's + * principal id (HTTP auth headers on SSE/Streamable HTTP transports, + * OMNIROUTE_API_KEY env var on stdio). Keeps MCP-stored memories under + * the same owner id that chat-context memory uses, so retrieval in the + * chat pipeline finds entries written via MCP. + */ +async function resolveMemoryOwnerId(explicit?: string): Promise { + if (explicit && explicit.trim() !== "") return explicit.trim(); + return (await resolveMcpCallerApiKeyId().catch(() => undefined)) || "mcp"; +} export const MemorySearchSchema = z.object({ - apiKeyId: z.string(), + apiKeyId: z.string().optional(), query: z.string().optional(), type: z.enum(["factual", "episodic", "procedural", "semantic"]).optional(), maxTokens: z.number().int().positive().max(8000).optional(), @@ -17,7 +31,7 @@ export const MemorySearchSchema = z.object({ }); export const MemoryAddSchema = z.object({ - apiKeyId: z.string(), + apiKeyId: z.string().optional(), sessionId: z.string().optional(), type: z.enum(["factual", "episodic", "procedural", "semantic"]), key: z.string().min(1), @@ -26,7 +40,7 @@ export const MemoryAddSchema = z.object({ }); export const MemoryClearSchema = z.object({ - apiKeyId: z.string(), + apiKeyId: z.string().optional(), type: z.enum(["factual", "episodic", "procedural", "semantic"]).optional(), olderThan: z.string().optional(), }); @@ -38,6 +52,7 @@ export const memoryTools = { scopes: ["read:memory"], inputSchema: MemorySearchSchema, handler: async (args: z.infer) => { + const apiKeyId = await resolveMemoryOwnerId(args.apiKeyId); // Plan 21 D16/Bug#7 fix: even on the error path the fallback must // respect DEFAULT_MEMORY_SETTINGS.strategy instead of hardcoding "exact". const memorySettings = @@ -54,7 +69,7 @@ export const memoryTools = { (memorySettings.enabled ? memorySettings.maxTokens : DEFAULT_MEMORY_SETTINGS.maxTokens), }; - const memories = await retrieveMemories(args.apiKeyId, config); + const memories = await retrieveMemories(apiKeyId, config); const filtered = args.type ? memories.filter((m) => m.type === args.type) : memories; @@ -77,8 +92,9 @@ export const memoryTools = { scopes: ["write:memory"], inputSchema: MemoryAddSchema, handler: async (args: z.infer) => { + const apiKeyId = await resolveMemoryOwnerId(args.apiKeyId); const memory = await createMemory({ - apiKeyId: args.apiKeyId, + apiKeyId, sessionId: args.sessionId || "", type: args.type as MemoryType, key: args.key, @@ -103,8 +119,9 @@ export const memoryTools = { scopes: ["write:memory"], inputSchema: MemoryClearSchema, handler: async (args: z.infer) => { + const apiKeyId = await resolveMemoryOwnerId(args.apiKeyId); const result = await listMemories({ - apiKeyId: args.apiKeyId, + apiKeyId, type: args.type as MemoryType | undefined, }); const existingMemories = Array.isArray(result) diff --git a/open-sse/services/autoCombo/scoring.ts b/open-sse/services/autoCombo/scoring.ts index b8f66a797e..4c939501c7 100644 --- a/open-sse/services/autoCombo/scoring.ts +++ b/open-sse/services/autoCombo/scoring.ts @@ -23,6 +23,12 @@ export interface ScoringFactors { sessionAvailability?: number; resetWindowAffinity: number; connectionDensity: number; + /** + * Feedback-driven quality signal [0,1] from the routing-event quality tracker + * (open-sse/services/routing/quality.ts). Optional so cold candidates with no + * observed events default to neutral (1.0) and are never penalized. + */ + quality?: number; } export interface ScoringWeights { @@ -40,11 +46,13 @@ export interface ScoringWeights { sessionAvailability?: number; resetWindowAffinity: number; connectionDensity: number; + /** Weight for the feedback-driven quality factor (#feedback-foundation). */ + quality?: number; } export const DEFAULT_WEIGHTS: ScoringWeights = { quota: 0.1429, - health: 0.1905, + health: 0.1605, costInv: 0.1429, latencyInv: 0.1143, taskFit: 0.0762, @@ -57,6 +65,10 @@ export const DEFAULT_WEIGHTS: ScoringWeights = { sessionAvailability: 0.0476, resetWindowAffinity: 0, connectionDensity: 0.0476, + // Shifted from `health` (0.1905 → 0.1605): availability stays dominant, and + // the new quality signal (observed output quality over time) gets a real, + // if smaller, vote. Sum remains exactly 1.0. + quality: 0.03, }; /** Normalize independently configured UI weights into a scoring distribution. */ @@ -107,6 +119,12 @@ export interface ProviderCandidate { sessionAvailability?: number; /** Score [0..1] for quota reset-window preference; sooner selected reset windows score higher. */ resetWindowAffinity?: number; + /** + * Feedback-driven quality score [0..1] for this provider/model from the + * routing-event quality tracker (open-sse/services/routing). Omitted/undefined + * candidates default to a neutral 1.0 in calculateFactors. + */ + quality?: number; connectionPoolSize?: number; connectionId?: string; } @@ -141,7 +159,10 @@ export function calculateScore(factors: ScoringFactors, weights: ScoringWeights) (weights.cacheAffinity ?? 0) * (factors.cacheAffinity ?? 0) + (weights.sessionAvailability ?? 0) * (factors.sessionAvailability ?? 1) + (weights.resetWindowAffinity ?? 0) * factors.resetWindowAffinity + - (weights.connectionDensity ?? 0) * factors.connectionDensity + (weights.connectionDensity ?? 0) * factors.connectionDensity + + // Missing quality factor → neutral 0.5: a cold candidate is neither boosted + // (which would let optimistic initialization dominate) nor penalized. + (weights.quality ?? 0) * (factors.quality ?? 0.5) ); } @@ -268,6 +289,9 @@ export function calculateFactors( sessionAvailability: clamp01(candidate.sessionAvailability ?? 1), resetWindowAffinity: clamp01(candidate.resetWindowAffinity ?? 0.5), connectionDensity: clamp01(((candidate.connectionPoolSize ?? 1) - 1) / 10), + // Feedback quality signal; neutral 0.5 when the tracker has no data yet + // (cold providers are neither boosted nor unfairly penalized). + quality: clamp01(candidate.quality ?? 0.5), }; } diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index a55a70686b..f468f23005 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -44,21 +44,19 @@ export interface AutoComboSpec { family?: ModelFamily; } -/** Rate-limit empty-pool AUTO warns (same label can be resolved many times/min). */ -const emptyPoolWarnAt = new Map(); -export const EMPTY_POOL_WARN_INTERVAL_MS = 60_000; +/** Once-per-process empty-pool AUTO warns (steady empty is not a metronome). */ +const emptyPoolWarned = new Set(); -export function warnEmptyAutoPoolOnce(label: string, message: string, now = Date.now()): boolean { - const last = emptyPoolWarnAt.get(label) ?? 0; - if (now - last < EMPTY_POOL_WARN_INTERVAL_MS) return false; - emptyPoolWarnAt.set(label, now); +export function warnEmptyAutoPoolOnce(label: string, message: string, _now = Date.now()): boolean { + if (emptyPoolWarned.has(label)) return false; + emptyPoolWarned.add(label); log.warn("AUTO", message); return true; } -/** Test-only: reset the debounce map. */ +/** Test-only: reset the once-per-label set (also models emptiness reappearing). */ export function resetEmptyAutoPoolWarnStateForTests(): void { - emptyPoolWarnAt.clear(); + emptyPoolWarned.clear(); } /** Minimal connection shape needed for virtual auto-combo factory */ diff --git a/open-sse/services/claudeCodeConstraints.ts b/open-sse/services/claudeCodeConstraints.ts index 42ed52f996..094727e11b 100644 --- a/open-sse/services/claudeCodeConstraints.ts +++ b/open-sse/services/claudeCodeConstraints.ts @@ -130,27 +130,34 @@ export function ensureCacheControlOnLastUserMessage(body: Record> | undefined; - const systemCacheControlCount = Array.isArray(system) + let cacheControlCount = Array.isArray(system) ? system.filter((block) => block.cache_control).length : 0; + let hasFiveMinuteCacheControl = Array.isArray(system) + ? system.some( + (block) => (block.cache_control as Record | undefined)?.ttl === "5m" + ) + : false; for (const message of messages) { const content = message.content as Array> | undefined; - if (Array.isArray(content) && content.some((block) => block.cache_control)) { - return; - } + if (!Array.isArray(content)) continue; + cacheControlCount += content.filter((block) => block.cache_control).length; + hasFiveMinuteCacheControl ||= content.some( + (block) => (block.cache_control as Record | undefined)?.ttl === "5m" + ); } - if (systemCacheControlCount >= MAX_CACHE_CONTROL_BLOCKS) return; - // Find the last user message for (let i = messages.length - 1; i >= 0; i--) { if (String(messages[i].role) === "user") { const content = messages[i].content; if (Array.isArray(content) && content.length > 0) { const lastBlock = content[content.length - 1] as Record; - if (!lastBlock.cache_control) { - lastBlock.cache_control = { type: "ephemeral" }; + if (!lastBlock.cache_control && cacheControlCount < MAX_CACHE_CONTROL_BLOCKS) { + lastBlock.cache_control = hasFiveMinuteCacheControl + ? { type: "ephemeral", ttl: "5m" } + : { type: "ephemeral" }; } } break; @@ -158,38 +165,31 @@ export function ensureCacheControlOnLastUserMessage(body: Record): void { + let hasFiveMinuteCacheControl = false; + const defaultMissingTtl = (block: Record | null | undefined) => { const cc = block?.cache_control as Record | undefined; - if (cc && cc.type === "ephemeral" && cc.ttl === undefined) { - cc.ttl = "1h"; + if (!cc || cc.type !== "ephemeral") return; + + if (cc.ttl === "5m") { + hasFiveMinuteCacheControl = true; + } else if (cc.ttl === undefined) { + cc.ttl = hasFiveMinuteCacheControl ? "5m" : "1h"; } }; - const system = body.system as Array> | undefined; - if (Array.isArray(system)) { - for (const block of system) defaultMissingTtl(block); - } - const tools = body.tools as Array> | undefined; if (Array.isArray(tools)) { for (const tool of tools) defaultMissingTtl(tool); } + const system = body.system as Array> | undefined; + if (Array.isArray(system)) { + for (const block of system) defaultMissingTtl(block); + } + const messages = body.messages as Array> | undefined; if (Array.isArray(messages)) { for (const message of messages) { diff --git a/open-sse/services/codexAccount/index.ts b/open-sse/services/codexAccount/index.ts new file mode 100644 index 0000000000..b9c82838e7 --- /dev/null +++ b/open-sse/services/codexAccount/index.ts @@ -0,0 +1,189 @@ +import { getCodexModelScope } from "../../config/codexQuotaScopes.ts"; +import { + getCodexChildQuotaHydration, + getEarliestCodexChildCooldown, + inspectCodexAccount, +} from "./state.ts"; +import type { + CodexAccount, + CodexAccountConnection, + CodexAccountPool, + CodexChildAccount, + CodexParentAccount, + CodexAccountPoolProjection, + CodexQuotaWindowSnapshot, +} from "./types.ts"; + +function createParentAccount(connection: CodexAccountConnection): CodexParentAccount { + return { + kind: "parent", + key: { parentConnectionId: connection.id, scope: null }, + connectionId: connection.id, + scope: null, + connection, + }; +} + +function createChildAccount( + connection: CodexAccountConnection, + scope: CodexChildAccount["scope"] +): CodexChildAccount { + return { + kind: "child", + key: { parentConnectionId: connection.id, scope }, + connectionId: connection.id, + scope, + connection, + }; +} + +/** Build one parent and two virtual children around a single DB connection. */ +export function createCodexAccountPool(connection: CodexAccountConnection): CodexAccountPool { + const parent = createParentAccount(connection); + const codex = createChildAccount(connection, "codex"); + const spark = createChildAccount(connection, "spark"); + return { + parent, + children: [codex, spark], + accounts: [parent, codex, spark], + }; +} + +/** Project one persisted connection into the safe parent/child account read model. */ +export function projectCodexAccountPool( + connection: CodexAccountConnection, + now = Date.now() +): CodexAccountPoolProjection { + const pool = createCodexAccountPool(connection); + const children = pool.children.map((child) => { + const state = inspectCodexAccount(pool, child, now); + const hydration = getCodexChildQuotaHydration(child); + const quotaWindow = (window: "5h" | "7d"): CodexQuotaWindowSnapshot | null => { + const quota = hydration.quotaState; + if (!quota) return null; + const usage = quota[window === "5h" ? "usage5h" : "usage7d"]; + const limit = quota[window === "5h" ? "limit5h" : "limit7d"]; + const resetAt = quota[window === "5h" ? "resetAt5h" : "resetAt7d"] ?? null; + if (typeof usage !== "number" && typeof limit !== "number" && !resetAt) return null; + return { + usage: typeof usage === "number" ? usage : null, + limit: typeof limit === "number" ? limit : null, + resetAt, + usedPercentage: + typeof usage === "number" && typeof limit === "number" && limit > 0 + ? (usage / limit) * 100 + : null, + }; + }; + const cooldownActive = Boolean( + state.rateLimitedUntil && new Date(state.rateLimitedUntil).getTime() > now + ); + const exhaustedWindow = hydration.exhaustedWindow; + const exhaustedResetAt = + exhaustedWindow === "5h" + ? hydration.quotaState?.resetAt5h + : exhaustedWindow === "7d" + ? hydration.quotaState?.resetAt7d + : null; + const exhaustionActive = Boolean( + exhaustedWindow && exhaustedResetAt && new Date(exhaustedResetAt).getTime() > now + ); + const unavailable = cooldownActive || exhaustionActive; + return { + key: child.key, + unavailable, + cooldown: { + active: cooldownActive, + rateLimitedUntil: cooldownActive ? state.rateLimitedUntil : null, + }, + quota: { + exhaustedWindow: exhaustionActive ? exhaustedWindow : null, + observedAt: hydration.quotaState?.observedAt ?? null, + windows: { "5h": quotaWindow("5h"), "7d": quotaWindow("7d") }, + }, + }; + }) as [CodexAccountPoolProjection["children"][0], CodexAccountPoolProjection["children"][1]]; + const limitedChildCount = children.filter((child) => child.unavailable).length; + return { + parentConnectionId: connection.id, + aggregate: { + status: + limitedChildCount === 0 + ? "available" + : limitedChildCount === children.length + ? "fully_limited" + : "partially_limited", + limitedChildCount, + }, + children, + }; +} + +/** Resolve the scoped child whose quota owns a nonblank model, or the parent otherwise. */ +export function resolveCodexAccount( + pool: CodexAccountPool, + model: string | null | undefined +): CodexAccount { + if (typeof model !== "string" || model.trim().length === 0) return pool.parent; + const scope = getCodexModelScope(model); + return pool.children.find((account) => account.scope === scope) || pool.parent; +} + +function inspectResolvedCodexChild( + connection: CodexAccountConnection, + model: string | null | undefined, + now = Date.now() +) { + const pool = createCodexAccountPool(connection); + const state = inspectCodexAccount(pool, resolveCodexAccount(pool, model), now); + return state.kind === "child" ? state : null; +} + +/** Return whether the requested model's virtual child is currently unavailable. */ +export function isCodexChildUnavailable( + connection: CodexAccountConnection, + model: string | null | undefined, + now = Date.now() +): boolean { + return inspectResolvedCodexChild(connection, model, now)?.unavailable ?? false; +} + +/** Return the active cooldown for the requested model's virtual child. */ +export function getCodexChildCooldown( + connection: CodexAccountConnection, + model: string | null | undefined, + now = Date.now() +): string | null { + return inspectResolvedCodexChild(connection, model, now)?.rateLimitedUntil ?? null; +} + +export { + getCodexAccountPoolState, + getCodexChildQuotaHydration, + getCodexParentAccountDiagnostic, + getEarliestCodexChildCooldown, + inspectCodexAccount, +} from "./state.ts"; +export { persistCodexChildCooldown } from "./write.ts"; +export type { PersistCodexChildCooldownResult } from "./write.ts"; +export { persistCodexChildQuotaResponse } from "./quota.ts"; +export type { PersistCodexChildQuotaResult } from "./quota.ts"; +export type { + CodexAccount, + CodexAccountConnection, + CodexAccountKey, + CodexAccountPool, + CodexChildAccount, + CodexAccountPoolState, + CodexAccountPoolStatus, + CodexAccountState, + CodexChildAccountState, + CodexChildCooldown, + CodexChildQuotaHydration, + CodexAccountPoolProjection, + CodexChildAccountProjection, + CodexQuotaWindowSnapshot, + CodexParentAccount, + CodexParentAccountDiagnostic, + CodexPersistedQuotaState, +} from "./types.ts"; diff --git a/open-sse/services/codexAccount/quota.ts b/open-sse/services/codexAccount/quota.ts new file mode 100644 index 0000000000..3317ebd5be --- /dev/null +++ b/open-sse/services/codexAccount/quota.ts @@ -0,0 +1,71 @@ +import { + getCodexDualWindowCooldownMs, + getCodexModelScope, + parseCodexQuotaHeaders, +} from "../../executors/codex.ts"; +import { updateCodexScopedQuotaState } from "@/lib/db/providers"; +import type { CodexQuotaScope } from "../../config/codexQuotaScopes.ts"; + +export interface PersistCodexChildQuotaResult { + readonly scope: CodexQuotaScope; + readonly providerSpecificData: Record; + readonly exhaustionLog: string | null; +} + +/** Parse and atomically persist one virtual child's quota response evidence. */ +export async function persistCodexChildQuotaResponse(params: { + connectionId: string; + model: string; + headers: Record; + status: number; + fallbackRateLimitedUntil?: string | null; +}): Promise { + if (params.model.trim().length === 0) return null; + const quota = parseCodexQuotaHeaders(params.headers); + if (!quota) return null; + + const scope = getCodexModelScope(params.model); + const quotaState = { + usage5h: quota.usage5h, + limit5h: quota.limit5h, + resetAt5h: quota.resetAt5h, + usage7d: quota.usage7d, + limit7d: quota.limit7d, + resetAt7d: quota.resetAt7d, + observedAt: new Date().toISOString(), + }; + let exhaustedWindow: "5h" | "7d" | undefined; + let rateLimitedUntil: string | undefined; + + if (params.status === 429) { + const exhausted = getCodexDualWindowCooldownMs(quota); + if (exhausted.cooldownMs > 0 && exhausted.window !== "none") { + exhaustedWindow = exhausted.window; + rateLimitedUntil = + exhausted.window === "7d" ? (quota.resetAt7d ?? undefined) : (quota.resetAt5h ?? undefined); + } else if (params.fallbackRateLimitedUntil) { + rateLimitedUntil = params.fallbackRateLimitedUntil; + } + } + + const providerSpecificData = await updateCodexScopedQuotaState(params.connectionId, scope, { + quotaState, + exhaustedWindow: exhaustedWindow ?? null, + ...(rateLimitedUntil + ? { + rateLimitedUntil, + rateLimitSource: exhaustedWindow ? ("quota_reset" as const) : ("fallback" as const), + } + : {}), + }); + if (!providerSpecificData) return null; + + return { + scope, + providerSpecificData, + exhaustionLog: + exhaustedWindow && rateLimitedUntil + ? `Quota exhaustion on ${exhaustedWindow} window, cooldown until ${rateLimitedUntil}` + : null, + }; +} diff --git a/open-sse/services/codexAccount/state.ts b/open-sse/services/codexAccount/state.ts new file mode 100644 index 0000000000..4777985781 --- /dev/null +++ b/open-sse/services/codexAccount/state.ts @@ -0,0 +1,180 @@ +import { getCodexModelScope, type CodexQuotaScope } from "../../config/codexQuotaScopes.ts"; +import type { + CodexAccountConnection, + CodexAccountPool, + CodexAccountPoolState, + CodexChildAccount, + CodexChildAccountState, + CodexChildCooldown, + CodexChildQuotaHydration, + CodexPersistedQuotaState, + CodexParentAccount, + CodexParentAccountDiagnostic, + CodexAccountState, + CodexAccount, +} from "./types.ts"; + +const CODEX_SCOPES: readonly CodexQuotaScope[] = ["codex", "spark"]; + +type LegacyStateOwner = Pick; + +function asRecord(value: unknown): Readonly> { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Readonly>) + : {}; +} + +function getLegacyCooldownMap(connection: LegacyStateOwner): Readonly> { + return asRecord(connection.providerSpecificData.codexScopeRateLimitedUntil); +} + +function getLegacyCooldown(account: CodexChildAccount): string | null { + const value = getLegacyCooldownMap(account.connection)[account.scope]; + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function asQuotaState(value: unknown): CodexPersistedQuotaState | null { + const record = asRecord(value); + return Object.keys(record).length > 0 ? (record as CodexPersistedQuotaState) : null; +} + +function asExhaustedWindow(value: unknown): "5h" | "7d" | null { + return value === "5h" || value === "7d" ? value : null; +} + +/** Decode persisted quota facts for exactly one virtual child. */ +export function getCodexChildQuotaHydration(account: CodexChildAccount): CodexChildQuotaHydration { + const data = account.connection.providerSpecificData; + const scopedQuota = asQuotaState(asRecord(data.codexQuotaStateByScope)[account.scope]); + const legacyQuota = asRecord(data.codexQuotaState); + const matchingLegacyQuota = + legacyQuota.scope === account.scope ? asQuotaState(legacyQuota) : null; + const exhaustedByScope = asRecord(data.codexExhaustedWindowByScope); + const scopedExhaustedWindow = asExhaustedWindow(exhaustedByScope[account.scope]); + const legacyExhaustedWindow = matchingLegacyQuota + ? asExhaustedWindow(data.codexExhaustedWindow) + : null; + + return { + scope: account.scope, + quotaState: scopedQuota ?? matchingLegacyQuota, + exhaustedWindow: scopedExhaustedWindow ?? legacyExhaustedWindow, + rateLimitedUntil: getLegacyCooldown(account), + }; +} + +function parseFutureTimestamp(value: string | null, nowMs: number): number | null { + if (!value) return null; + const timestampMs = new Date(value).getTime(); + return Number.isFinite(timestampMs) && timestampMs > nowMs ? timestampMs : null; +} + +function resolveChild(pool: CodexAccountPool, model: string): CodexChildAccount { + const scope = getCodexModelScope(model); + return pool.children.find((account) => account.scope === scope) ?? pool.children[0]; +} + +/** Inspect the read-only parent aggregate without exposing legacy storage parsing. */ +export function inspectCodexAccount( + pool: CodexAccountPool, + account: CodexParentAccount, + nowMs?: number +): CodexAccountPoolState; +/** Inspect one scoped child without exposing legacy storage parsing. */ +export function inspectCodexAccount( + pool: CodexAccountPool, + account: CodexChildAccount, + nowMs?: number +): CodexChildAccountState; +/** Inspect a runtime-selected parent or child account. */ +export function inspectCodexAccount( + pool: CodexAccountPool, + account: CodexAccount, + nowMs?: number +): CodexAccountState; +export function inspectCodexAccount( + pool: CodexAccountPool, + account: CodexParentAccount | CodexChildAccount, + nowMs = Date.now() +): CodexAccountPoolState | CodexChildAccountState { + if (account.connectionId !== pool.parent.connectionId) { + throw new Error("Codex account does not belong to this pool"); + } + if (account.kind === "parent") return getCodexAccountPoolState(pool, nowMs); + const rateLimitedUntil = getLegacyCooldown(account); + return { + kind: "child", + scope: account.scope, + rateLimitedUntil, + unavailable: parseFutureTimestamp(rateLimitedUntil, nowMs) !== null, + }; +} + +/** Return the earliest active child cooldown for a model across account pools. */ +export function getEarliestCodexChildCooldown( + pools: readonly CodexAccountPool[], + model: string | null | undefined, + nowMs = Date.now() +): CodexChildCooldown | null { + if (typeof model !== "string" || model.trim().length === 0) return null; + let earliest: CodexChildCooldown | null = null; + let earliestMs = Infinity; + for (const pool of pools) { + const child = resolveChild(pool, model); + const until = getLegacyCooldown(child); + const timestampMs = parseFutureTimestamp(until, nowMs); + if (timestampMs !== null && timestampMs < earliestMs && until !== null) { + earliest = { account: child, until }; + earliestMs = timestampMs; + } + } + return earliest; +} + +/** Build one parent-only diagnostic from virtual child state. */ +export function getCodexParentAccountDiagnostic( + pool: CodexAccountPool, + nowMs = Date.now() +): CodexParentAccountDiagnostic { + const state = getCodexAccountPoolState(pool, nowMs); + const retryTimestamps = pool.children + .map((child) => parseFutureTimestamp(getLegacyCooldown(child), nowMs)) + .filter((value): value is number => value !== null); + const observedScopeCount = pool.children.filter( + (child) => getCodexChildQuotaHydration(child).quotaState !== null + ).length; + return { + status: state.status, + limitedScopeCount: state.limitedScopes.length, + cooldown: { + coolingDown: state.status === "fully_limited", + soonestRetryAfterMs: + retryTimestamps.length > 0 ? Math.max(0, Math.min(...retryTimestamps) - nowMs) : 0, + }, + quota: { observedScopeCount }, + }; +} + +/** Aggregate the two virtual child states as a read-only parent view. */ +export function getCodexAccountPoolState( + pool: CodexAccountPool, + nowMs = Date.now() +): CodexAccountPoolState { + const limitedScopes = CODEX_SCOPES.filter((scope) => { + const child = pool.children.find((account) => account.scope === scope); + if (!child) return false; + const until = getLegacyCooldown(child); + return parseFutureTimestamp(until, nowMs) !== null; + }); + + return { + kind: "parent", + status: + limitedScopes.length === 0 + ? "available" + : limitedScopes.length === CODEX_SCOPES.length + ? "fully_limited" + : "partially_limited", + limitedScopes, + }; +} diff --git a/open-sse/services/codexAccount/types.ts b/open-sse/services/codexAccount/types.ts new file mode 100644 index 0000000000..bb1b0ae050 --- /dev/null +++ b/open-sse/services/codexAccount/types.ts @@ -0,0 +1,125 @@ +import type { CodexQuotaScope } from "../../config/codexQuotaScopes.ts"; + +/** The persisted Codex connection that owns credentials and provider state. */ +export interface CodexAccountConnection { + readonly id: string; + readonly provider: string; + readonly providerSpecificData: Readonly>; +} + +/** Structured identity for a virtual account; it can never be confused with a DB ID. */ +export interface CodexAccountKey { + readonly parentConnectionId: string; + readonly scope: TScope; +} + +interface CodexAccountBase { + readonly key: CodexAccountKey; + /** The actual persisted connection ID. Children never get a synthetic DB ID. */ + readonly connectionId: string; + readonly connection: CodexAccountConnection; +} + +/** The runtime view of the persisted credential-owning connection. */ +export interface CodexParentAccount extends CodexAccountBase { + readonly kind: "parent"; + readonly scope: null; +} + +/** One virtual runtime quota/cooldown child of the persisted connection. */ +export interface CodexChildAccount extends CodexAccountBase { + readonly kind: "child"; + readonly scope: CodexQuotaScope; +} + +export type CodexAccount = CodexParentAccount | CodexChildAccount; + +export interface CodexAccountPool { + readonly parent: CodexParentAccount; + readonly children: readonly [CodexChildAccount, CodexChildAccount]; + readonly accounts: readonly [CodexParentAccount, CodexChildAccount, CodexChildAccount]; +} + +export type CodexAccountPoolStatus = "available" | "partially_limited" | "fully_limited"; + +export interface CodexAccountPoolState { + readonly kind: "parent"; + readonly status: CodexAccountPoolStatus; + readonly limitedScopes: readonly CodexQuotaScope[]; +} + +export interface CodexChildAccountState { + readonly kind: "child"; + readonly scope: CodexQuotaScope; + readonly unavailable: boolean; + readonly rateLimitedUntil: string | null; +} + +export type CodexAccountState = CodexAccountPoolState | CodexChildAccountState; + +export interface CodexQuotaWindowSnapshot { + readonly usage: number | null; + readonly limit: number | null; + readonly resetAt: string | null; + readonly usedPercentage: number | null; +} + +export interface CodexChildAccountProjection { + readonly key: CodexAccountKey; + readonly unavailable: boolean; + readonly cooldown: { + readonly active: boolean; + readonly rateLimitedUntil: string | null; + }; + readonly quota: { + readonly exhaustedWindow: "5h" | "7d" | null; + readonly observedAt: string | null; + readonly windows: { + readonly "5h": CodexQuotaWindowSnapshot | null; + readonly "7d": CodexQuotaWindowSnapshot | null; + }; + }; +} + +export interface CodexAccountPoolProjection { + readonly parentConnectionId: string; + readonly aggregate: { + readonly status: CodexAccountPoolStatus; + readonly limitedChildCount: number; + }; + readonly children: readonly [CodexChildAccountProjection, CodexChildAccountProjection]; +} + +export interface CodexPersistedQuotaState { + readonly usage5h?: number; + readonly limit5h?: number; + readonly resetAt5h?: string | null; + readonly usage7d?: number; + readonly limit7d?: number; + readonly resetAt7d?: string | null; + readonly observedAt?: string | null; +} + +export interface CodexChildQuotaHydration { + readonly scope: CodexQuotaScope; + readonly quotaState: CodexPersistedQuotaState | null; + readonly exhaustedWindow: "5h" | "7d" | null; + readonly rateLimitedUntil: string | null; +} + +export interface CodexParentAccountDiagnostic { + readonly status: CodexAccountPoolStatus; + readonly limitedScopeCount: number; + readonly cooldown: { + readonly coolingDown: boolean; + readonly soonestRetryAfterMs: number; + }; + readonly quota: { + readonly observedScopeCount: number; + }; +} + +export interface CodexChildCooldown { + readonly account: CodexChildAccount; + readonly until: string; +} diff --git a/open-sse/services/codexAccount/write.ts b/open-sse/services/codexAccount/write.ts new file mode 100644 index 0000000000..f6f6e065b7 --- /dev/null +++ b/open-sse/services/codexAccount/write.ts @@ -0,0 +1,23 @@ +import { getCodexModelScope, type CodexQuotaScope } from "../../config/codexQuotaScopes.ts"; +import { updateCodexScopeCooldown } from "@/lib/db/providers"; + +export interface PersistCodexChildCooldownResult { + readonly scope: CodexQuotaScope; + readonly providerSpecificData: Record; +} + +/** Persist one virtual child's cooldown without mutating parent-level health state. */ +export async function persistCodexChildCooldown(params: { + connectionId: string; + model: string; + rateLimitedUntil: string; +}): Promise { + if (params.model.trim().length === 0) return null; + const scope = getCodexModelScope(params.model); + const providerSpecificData = await updateCodexScopeCooldown( + params.connectionId, + scope, + params.rateLimitedUntil + ); + return providerSpecificData ? { scope, providerSpecificData } : null; +} diff --git a/open-sse/services/codexQuotaFetcher.ts b/open-sse/services/codexQuotaFetcher.ts index eb588ac3ba..12f955906d 100644 --- a/open-sse/services/codexQuotaFetcher.ts +++ b/open-sse/services/codexQuotaFetcher.ts @@ -25,6 +25,7 @@ import { import { registerQuotaFetcher, registerQuotaWindows, type QuotaInfo } from "./quotaPreflight.ts"; import { registerMonitorFetcher } from "./quotaMonitor.ts"; import { throttleQuotaFetch } from "./quotaFetchThrottle.ts"; +import { getCodexBackendIdentityHeaders } from "../config/codexClient.ts"; /** * Stable identifiers for Codex's quota windows. These match the quota keys @@ -222,6 +223,9 @@ export async function fetchCodexQuota( Authorization: `Bearer ${meta.accessToken}`, "Content-Type": "application/json", Accept: "application/json", + // Canonical Codex backend identity (UA + originator + version), same + // chain as inference — see getCodexUsage. + ...getCodexBackendIdentityHeaders(), }; if (meta.workspaceId) { diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index c9ccae9a97..9b4069bbc0 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -36,6 +36,7 @@ import { import { buildNoUpstreamResponseDiagnostics, buildRecoveryHint } from "./combo/pinRecovery.ts"; import { buildTargetTimeoutRunner } from "./combo/targetTimeoutRunner.ts"; import { recordComboRequest, recordComboShadowRequest, getComboMetrics } from "./comboMetrics.ts"; +import { qualityScoreFor } from "./routing/index.ts"; import { expandComboSystemPromptIfPresent, resolveTargetFingerprint, @@ -63,6 +64,7 @@ import { getHiddenModelsByProvider } from "@/models"; import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLockoutSettings"; import { fetchCodexQuota } from "./codexQuotaFetcher.ts"; import { evaluateQuotaCutoff, getQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; +import { resolveProviderId } from "../../src/shared/constants/providers.ts"; import * as semaphore from "./rateLimitSemaphore.ts"; import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker"; import { parseModel } from "./model.ts"; @@ -87,7 +89,7 @@ import { selectQuotaShareTarget } from "./combo/quotaShareStrategy.ts"; import { makeConnectionConcurrencyResolver, lookupPositiveCap } from "./combo/concurrencyCaps.ts"; import { acquireQuotaShareConcurrencySlot } from "./combo/quotaShareConcurrency.ts"; import { canAffordRequest } from "../../src/lib/quota/quotaScheduler.ts"; -import { getCachedProviderConnectionById } from "../../src/lib/localDb.ts"; +import { getCachedProviderConnectionById } from "../../src/lib/db/readCache.ts"; import { orderTargetsByEvalScores } from "./evalRouting.ts"; /** @@ -96,11 +98,13 @@ import { orderTargetsByEvalScores } from "./evalRouting.ts"; * keeps the previously recorded limit (or 0 for a fresh row, meaning "no * budget enforced"). */ -function resolveTargetTokenLimit(target: { connectionId?: string | null }): number | undefined { +async function resolveTargetTokenLimit(target: { + connectionId?: string | null; +}): Promise { const connectionId = target?.connectionId; if (!connectionId) return undefined; try { - const connection = getCachedProviderConnectionById(connectionId); + const connection = await getCachedProviderConnectionById(connectionId); const overrides = (connection as { rateLimitOverrides?: Record | null } | null) ?.rateLimitOverrides; const tpm = overrides?.tpm; @@ -234,7 +238,14 @@ import { resolveComboRuntimeUnits, resolveComboTargets, } from "./combo/comboStructure.ts"; -import { getKnownContextOverflow } from "./combo/knownContextOverflow.ts"; +import { + createInvocationId, + finalizeComboTrace, + finishComboTrace, + getComboTrace, + recordComboDecision, + startComboTrace, +} from "./combo/decisionTrace.ts"; import { QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty, @@ -276,12 +287,7 @@ export { }; export { resolveShadowTargets, scheduleShadowRouting }; export { preScreenTargets }; -export { - resolveComboRuntimeUnits, - resolveComboTargets, - filterTargetsByRequestCompatibility, - getKnownContextOverflow, -}; +export { resolveComboRuntimeUnits, resolveComboTargets, filterTargetsByRequestCompatibility }; export { getComboFromData, getComboModelsFromData, @@ -486,7 +492,10 @@ export async function buildAutoCandidates( let quotaRemaining = 100; let quotaCutoffBlocked = false; let quotaCutoffReason: string | undefined; - const fetcher = getQuotaFetcher(provider); + // #10877: `provider` here may be a legacy/user-facing alias spelling + // (target.provider/parseModel output); canonicalize before the fetcher + // registry lookup so aliased combo members still hit quota-aware scoring. + const fetcher = getQuotaFetcher(resolveProviderId(provider)); const connection = target.connectionId ? connectionById.get(target.connectionId) : undefined; const authType = typeof connection?.authType === "string" ? connection.authType : null; const sessionAvailability = @@ -574,6 +583,9 @@ export async function buildAutoCandidates( connectionPoolSize: connectionPoolCounts.get(provider) ?? 1, connectionId: target.connectionId ?? undefined, authType, + // Feedback-driven quality signal (routing quality tracker). Neutral 1.0 + // before enough samples accumulate — a cold model is never penalized. + quality: qualityScoreFor(provider, model), }; }) ); @@ -607,7 +619,25 @@ export { pinIsDurablyUnhealthy }; /** @param {string} errorText */ /** @param {object} options */ -export async function handleComboChat({ +/** + * #10681 egress: every combo response carries the opaque trace id in an + * `X-OmniRoute-Combo-Trace` header so a post-incident lookup of the ordered + * per-target decisions is possible; the finalized summary is also emitted as + * one metadata-only log line for durability across restarts. + */ +export async function handleComboChat(options: HandleComboChatOptions): Promise { + const traceInvocationId = options.invocationId ?? createInvocationId(); + const response = await handleComboChatInner({ ...options, invocationId: traceInvocationId }); + response.headers.set("X-OmniRoute-Combo-Trace", traceInvocationId); + const trace = getComboTrace(traceInvocationId); + options.log.info( + "COMBO", + `combo trace ${traceInvocationId} terminal=${JSON.stringify(trace?.terminal ?? null)} decisions=${trace?.decisions.length ?? 0}` + ); + return response; +} + +async function handleComboChatInner({ body, combo, handleSingleModel, @@ -627,6 +657,7 @@ export async function handleComboChat({ sourceFormat = null, endpointPath = null, requestHeaders = null, + invocationId, }: HandleComboChatOptions): Promise { const comboCtx = createComboContext({ body, combo, settings, relayOptions, log }); const { @@ -643,6 +674,10 @@ export async function handleComboChat({ } = phaseComboSetup(comboCtx); body = comboCtx.body; + // #10681: opaque per-invocation decision trace (safe routing metadata only). + const traceInvocationId = invocationId ?? createInvocationId(); + startComboTrace(traceInvocationId, { strategy, comboName: combo.name }); + const handleSingleModelWithTimeout = buildTargetTimeoutRunner({ handleSingleModel, comboTargetTimeoutMs, @@ -804,12 +839,6 @@ export async function handleComboChat({ handleSingleModelWithTimeout, buildAutoCandidates, hiddenModelsByProvider, - clientManagedResponsesContext, - deferContextOverflowWhenCompressible, - compressionExclusions, - sourceFormat, - endpointPath, - requestHeaders, }); if ("earlyResponse" in targetResolution) return targetResolution.earlyResponse; const { stickyWeightedLimit, getWeightedStepKeyForTarget, preScreenMap } = targetResolution; @@ -1019,6 +1048,9 @@ export async function handleComboChat({ }); const runningTasks = new Set>(); let anySuccess = false; + // #10681: steps already recorded as dispatched (so per-target retries do not + // duplicate the decision). + const dispatchedTargets = new Set(); const abortControllers = new Map(); const zeroLatencyOptimizationsEnabled = config.zeroLatencyOptimizationsEnabled === true; const hasProtectedPriorityTarget = @@ -1044,6 +1076,12 @@ export async function handleComboChat({ const cb = getCircuitBreaker(provider); if (cb.getStatus().state === "OPEN") { log.info("COMBO", `Skipping ${modelStr} — circuit breaker OPEN for ${provider}`); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "skipped_before_dispatch", + reason: "circuit_open", + }); if (i > 0) fallbackCount++; return stopProtectedPriorityTarget(`Provider ${provider} circuit breaker is open`); } @@ -1054,6 +1092,12 @@ export async function handleComboChat({ isProviderInCooldown(provider, target.connectionId ?? undefined, resilienceSettings) ) { log.info("COMBO", `Skipping ${modelStr} — provider ${provider} in global cooldown`); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "skipped_before_dispatch", + reason: "provider_cooldown", + }); if (i > 0) fallbackCount++; return stopProtectedPriorityTarget(`Provider ${provider} is in cooldown`); } @@ -1081,6 +1125,12 @@ export async function handleComboChat({ ); if (exhaustedSkip) { log.info("COMBO", exhaustedSkip); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "skipped_before_dispatch", + reason: "request_exhaustion", + }); if (i > 0) fallbackCount++; return stopProtectedPriorityTarget(`Target ${modelStr} is unavailable`); } @@ -1088,6 +1138,12 @@ export async function handleComboChat({ // Pre-check: skip models locked by the resilience system (model-level lockout) if (provider && rawModel && isModelLocked(provider, target.connectionId || "", rawModel)) { log.info("COMBO", `Skipping ${modelStr} — model locked by resilience (cooldown active)`); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "skipped_before_dispatch", + reason: "model_lockout", + }); if (i > 0) fallbackCount++; return stopProtectedPriorityTarget(`Model ${modelStr} is locked`); } @@ -1114,6 +1170,12 @@ export async function handleComboChat({ "COMBO", `Skipping ${modelStr} — quota exhaustion cutoff (${quotaCutoff.reason || "quota_exhausted"})` ); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "skipped_before_dispatch", + reason: "quota_cutoff", + }); if (i > 0) fallbackCount++; observeFailure(true, target.executionKey); if (protectedPriorityTarget) { @@ -1162,6 +1224,12 @@ export async function handleComboChat({ "COMBO", `Skipping ${modelStr} — no credentials available or model excluded` ); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "skipped_before_dispatch", + reason: "availability", + }); if (i > 0) fallbackCount++; return stopProtectedPriorityTarget(`Model ${modelStr} is unavailable`); } @@ -1173,6 +1241,12 @@ export async function handleComboChat({ const gateResult = checkCredentialGate(connectionId, provider, modelStr); if (gateResult.allowed === false) { logCredentialSkip(log, modelStr, gateResult.reason || "Credential gate blocked"); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "skipped_before_dispatch", + reason: "credential_gate", + }); if (i > 0) fallbackCount++; return stopProtectedPriorityTarget(`Credential gate blocked ${modelStr}`); } @@ -1187,6 +1261,12 @@ export async function handleComboChat({ "COMBO", `Skipping ${modelStr} — connection ${connectionId} is at max concurrency cap (${maxConcurrentCap})` ); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "skipped_before_dispatch", + reason: "concurrency_cap", + }); if (i > 0) fallbackCount++; return stopProtectedPriorityTarget(`Connection capacity reached for ${modelStr}`); } @@ -1202,6 +1282,12 @@ export async function handleComboChat({ !(await perTargetAdmission({ modelStr, executionKey: target.executionKey, body })) ) { log.info("COMBO", `Skipping ${modelStr} — admission lane full (#9654)`); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "skipped_before_dispatch", + reason: "admission_lane", + }); if (i > 0) fallbackCount++; return null; } @@ -1257,6 +1343,12 @@ export async function handleComboChat({ "COMBO", `Predictive TTFT Circuit Breaker: skipping ${modelStr} (avg ${m.avgLatencyMs}ms > max ${config.predictiveTtftMs}ms)` ); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "skipped_before_dispatch", + reason: "predictive_ttft", + }); return stopProtectedPriorityTarget(`Predictive latency check rejected ${modelStr}`); } } @@ -1386,6 +1478,15 @@ export async function handleComboChat({ : "", fingerprint: resolveTargetFingerprint(target) ?? "", }); + // #10681: record dispatch once per target (retries keep the first decision). + if (!dispatchedTargets.has(target.executionKey)) { + dispatchedTargets.add(target.executionKey); + recordComboDecision(traceInvocationId, { + step: target.executionKey, + target: modelStr, + decision: "dispatched", + }); + } const result = await handleSingleModelWithTimeout(attemptBody, modelStr, { ...targetForAttempt, effectiveComboStrategy: strategy, @@ -2178,7 +2279,10 @@ export async function handleComboChat({ ); } } - log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); + log.warn("COMBO", `Model ${modelStr} failed, trying next`, { + status: result.status, + errorBody: redactConnectionLabel(errorText), + }); // #5976: per-model-quota providers (Gemini, GitHub, etc.) multiplex models // behind one connection. A model-level 500 or 429 (RPM) must NOT cool down @@ -2297,10 +2401,16 @@ export async function handleComboChat({ await Promise.race([globalPromise, Promise.all([...runningTasks])]); } + // #10681: finalize the decision trace (success). + finalizeComboTrace(traceInvocationId, orderedTargets); + finishComboTrace(traceInvocationId, { status: 200 }); if (anySuccess) { return await globalPromise; } + // #10681: finalize the decision trace (global timeout). + finalizeComboTrace(traceInvocationId, orderedTargets); + finishComboTrace(traceInvocationId, { status: 504 }); // Global combo timeout: return aggregated error immediately, skipping set retries. if (comboExpired) { const summary = buildRedactedSummary(comboErrors); @@ -2343,6 +2453,9 @@ export async function handleComboChat({ if (setTry < maxSetRetries) continue; // All set retries exhausted — return the final error + // #10681: finalize the decision trace (all targets failed or skipped). + finalizeComboTrace(traceInvocationId, orderedTargets); + finishComboTrace(traceInvocationId, { status: 503 }); if (!lastStatus) { if (recordedAttempts === 0) { notifyWebhookEvent("request.failed", { @@ -2443,6 +2556,9 @@ export async function handleComboChat({ } } + // #10681: finalize the decision trace with the aggregated terminal status. + finalizeComboTrace(traceInvocationId, orderedTargets); + finishComboTrace(traceInvocationId, { status }); // Retry-after decoration is separate from the wait decision above: only // rate-limit-class final statuses may carry a `(reset after ...)` suffix // (see unavailableRetryGate.ts — do not stitch a peer target's window onto @@ -2604,32 +2720,6 @@ async function handleRoundRobinCombo({ ); const tagFilteredTargets = await applyRequestTagRouting(orderedTargets, body, log); const evalRankedTargets = orderTargetsByEvalScores(tagFilteredTargets, config.evalRouting, log); - const knownContextOverflow = getKnownContextOverflow(evalRankedTargets, body, { - clientManagedResponsesContext, - deferContextOverflowWhenCompressible, - compressionExclusions, - sourceFormat, - endpointPath, - requestHeaders, - }); - if (knownContextOverflow) { - return errorResponseWithComboDiagnostics( - 400, - `Request requires approximately ${knownContextOverflow.requiredContextTokens} tokens, but the largest known context limit in this combo is ${knownContextOverflow.maxKnownContextTokens} tokens. Reduce or compact the request context.`, - { - poolSize: evalRankedTargets.length, - attempted: 0, - excluded: evalRankedTargets.map((target) => ({ - provider: target.provider, - model: target.modelStr, - reason: "context_window", - })), - attemptOrder: [], - terminalReason: "context_length_exceeded", - }, - { code: "context_length_exceeded", type: "invalid_request_error" } - ); - } // Align with the main/auto paths: combo config OR top-level settings (#8488 / #8494). const rrCompatFailOpen = (config as { compatFilterFailOpen?: unknown }).compatFilterFailOpen === true || @@ -3005,7 +3095,7 @@ async function handleRoundRobinCombo({ try { const { reserveQuota } = await import("../../src/lib/quota/quotaScheduler.ts"); reserveQuota(target.connectionId, modelStr, attemptBody as Record, { - tokenLimit: resolveTargetTokenLimit(target), + tokenLimit: await resolveTargetTokenLimit(target), }); } catch { // best-effort only @@ -3377,7 +3467,10 @@ async function handleRoundRobinCombo({ kind: classifyComboOutcome(result.status, errorText), }); if (offset > 0) fallbackCount++; - log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status }); + log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { + status: result.status, + errorBody: redactConnectionLabel(errorText), + }); if ( resilienceSettings.providerCooldown.enabled && diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index c125bc9052..258c92da71 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -25,7 +25,6 @@ import { dedupeTargetsByExecutionKey, isRecord } from "./comboData.ts"; import { isComboModelVisible } from "./comboVisibility.ts"; import { getTargetProvider, MAX_COMBO_DEPTH } from "./comboPredicates.ts"; import { evaluateContextLimit } from "./contextOverrideGate.ts"; -import { hasEstimableContent } from "./knownContextOverflow.ts"; import { normalizeModelEntry, orderTargetsForWeightedFallback, @@ -480,6 +479,13 @@ function requestRequiresStructuredOutput(body: Record): boolean return type === "json_object" || type === "json_schema"; } +export function hasEstimableContent(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; +} + function estimateRequestInputTokens(body: Record): number { const estimatePayload: Record = {}; for (const key of ["messages", "input", "tools", "functions", "response_format"]) { diff --git a/open-sse/services/combo/decisionTrace.ts b/open-sse/services/combo/decisionTrace.ts new file mode 100644 index 0000000000..7660af7ea0 --- /dev/null +++ b/open-sse/services/combo/decisionTrace.ts @@ -0,0 +1,175 @@ +/** + * #10681: opaque per-invocation combo decision trace. + * + * Priority combos can be impossible to audit after a mixed fallback: dispatched + * attempts are persisted in call_logs, but candidates excluded before dispatch + * (circuit open, provider cooldown, model lockout, quota cutoff, availability, + * credential gate, concurrency cap, admission lane, predictive TTFT) leave no + * correlated decision record. This module records one ordered, allowlisted + * decision per target per invocation so operators can reconstruct what the + * chain actually did. + * + * SAFETY CONTRACT: the trace contains ONLY routing metadata — invocation id, + * strategy, combo name, per-target provider/model, decision, allowlisted skip + * reason, timestamps, terminal status. Never prompts, request/response bodies, + * headers, credentials, account ids, or raw upstream error strings. + * + * Retention: bounded in-memory (TTL + LRU cap) — see TRACE_TTL_MS/MAX_TRACES. + */ +import { randomUUID } from "node:crypto"; + +export const COMBO_SKIP_REASONS = [ + "circuit_open", + "provider_cooldown", + "request_exhaustion", + "model_lockout", + "quota_cutoff", + "availability", + "credential_gate", + "concurrency_cap", + "admission_lane", + "predictive_ttft", +] as const; + +export type ComboSkipReason = (typeof COMBO_SKIP_REASONS)[number]; + +export type ComboDecision = "dispatched" | "skipped_before_dispatch" | "not_reached"; + +export interface ComboTraceEntry { + /** Safe internal identifier of the combo step (execution key). */ + step: string; + /** Safe routing metadata: "/". */ + target: string; + decision: ComboDecision; + reason?: ComboSkipReason; + ts: number; +} + +export interface ComboTrace { + invocationId: string; + createdAt: number; + strategy: string | null; + comboName: string | null; + decisions: ComboTraceEntry[]; + terminal: { status: number | null; errorClass: string | null } | null; +} + +const TRACE_TTL_MS = 30 * 60 * 1000; +const MAX_TRACES = 2000; +const traces = new Map(); + +export function createInvocationId(): string { + return `combo-${randomUUID()}`; +} + +function isComboSkipReason(value: unknown): value is ComboSkipReason { + return typeof value === "string" && (COMBO_SKIP_REASONS as readonly string[]).includes(value); +} + +/** Test hook: clear the in-memory store. */ +export function resetComboTraceStore(): void { + traces.clear(); +} + +export function startComboTrace( + invocationId: string, + meta: { strategy?: string | null; comboName?: string | null } +): void { + pruneExpired(); + if (traces.size >= MAX_TRACES) { + // Prefer evicting a FINALIZED trace so in-flight (unfinalized) invocations + // survive a burst; fall back to the oldest trace overall. + let victim: ComboTrace | null = null; + for (const trace of traces.values()) { + if (trace.terminal !== null && (!victim || trace.createdAt < victim.createdAt)) { + victim = trace; + } + } + if (!victim) { + for (const trace of traces.values()) { + if (!victim || trace.createdAt < victim.createdAt) victim = trace; + } + } + if (victim) traces.delete(victim.invocationId); + } + if (!traces.has(invocationId)) { + traces.set(invocationId, { + invocationId, + createdAt: Date.now(), + strategy: meta.strategy ?? null, + comboName: meta.comboName ?? null, + decisions: [], + terminal: null, + }); + } +} + +export function recordComboDecision( + invocationId: string, + entry: Omit & { reason?: unknown } +): void { + const trace = traces.get(invocationId); + if (!trace) return; + if (entry.reason !== undefined && !isComboSkipReason(entry.reason)) { + throw new Error( + `invalid combo skip reason: ${String(entry.reason)} (allowlist: ${COMBO_SKIP_REASONS.join(", ")})` + ); + } + trace.decisions.push({ + step: entry.step, + target: entry.target, + decision: entry.decision, + reason: entry.reason as ComboSkipReason | undefined, + ts: Date.now(), + }); +} + +export function finishComboTrace( + invocationId: string, + terminal: { status: number | null; errorClass?: string | null } +): void { + const trace = traces.get(invocationId); + if (!trace) return; + trace.terminal = { status: terminal.status, errorClass: terminal.errorClass ?? null }; +} + +/** + * Mark every target that received no decision as not_reached and return the + * trace. Safe to call on success and failure paths; idempotent. + */ +export function finalizeComboTrace( + invocationId: string, + orderedTargets: Array<{ executionKey: string; modelStr: string }> +): ComboTrace | null { + const trace = traces.get(invocationId); + if (!trace) return null; + const decided = new Set(trace.decisions.map((d) => d.step)); + for (const t of orderedTargets) { + if (!decided.has(t.executionKey)) { + trace.decisions.push({ + step: t.executionKey, + target: t.modelStr, + decision: "not_reached", + ts: Date.now(), + }); + } + } + return trace; +} + +export function getComboTrace(invocationId: string): ComboTrace | null { + const trace = traces.get(invocationId); + if (!trace) return null; + if (Date.now() - trace.createdAt > TRACE_TTL_MS) { + traces.delete(invocationId); + return null; + } + return trace; +} + +function pruneExpired(): void { + const now = Date.now(); + for (const [id, trace] of traces) { + if (now - trace.createdAt > TRACE_TTL_MS) traces.delete(id); + } +} diff --git a/open-sse/services/combo/dispatchPrelude.ts b/open-sse/services/combo/dispatchPrelude.ts index 2f5361a4cf..c9c62ddbe6 100644 --- a/open-sse/services/combo/dispatchPrelude.ts +++ b/open-sse/services/combo/dispatchPrelude.ts @@ -16,11 +16,18 @@ import { getCachedProviderConnections } from "../../../src/lib/db/readCache"; import { getCircuitBreaker } from "../../../src/shared/utils/circuitBreaker"; import { fisherYatesShuffle, getNextFromDeck } from "../../../src/shared/utils/shuffleDeck"; import { handleFusionChat, type FusionTuning } from "../fusion.ts"; +import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; +import { errorResponseWithComboDiagnostics } from "../../utils/error.ts"; import { parseModel } from "../model.ts"; import { handlePipelineChat, type PipelineStep } from "../pipeline.ts"; import type { resolveComboSetupConfig } from "../comboConfig.ts"; import { clampComboDepth, MAX_GLOBAL_ATTEMPTS, resolveDelayMs } from "./comboPredicates.ts"; -import { resolveComboRuntimeUnits, resolveComboTargets } from "./comboStructure.ts"; +import { + deriveRequestCompatibilityRequirements, + isVisionIncompatibleTarget, + resolveComboRuntimeUnits, + resolveComboTargets, +} from "./comboStructure.ts"; import { isComboModelVisible } from "./comboVisibility.ts"; import { buildFusionHandleSingleModel, extractFusionPanelSpec } from "./fusionPanel.ts"; import { @@ -65,6 +72,7 @@ type RunCombo = (options: HandleComboChatOptions) => Promise; * hand back to it when it dispatches a nested combo-ref. */ type PreludeBaseOptionArgs = { + invocationId?: string; body: Record; combo: ComboLike; handleSingleModel: HandleSingleModel; @@ -103,6 +111,7 @@ function buildBaseOptions(a: PreludeBaseOptionArgs): HandleComboChatOptions { signal: a.signal, apiKeyAllowedConnections: a.apiKeyAllowedConnections, hiddenModelsByProvider: a.hiddenModelsByProvider, + invocationId: a.invocationId, clientManagedResponsesContext: a.clientManagedResponsesContext, perTargetAdmission: a.perTargetAdmission, deferContextOverflowWhenCompressible: a.deferContextOverflowWhenCompressible, @@ -393,14 +402,27 @@ export async function tryFusionDispatch(args: { }): Promise { const { cfg, combo, config, strategy, log } = args; const configuredJudge = typeof cfg.judgeModel === "string" ? cfg.judgeModel : undefined; + const judgeFusionRequirements = deriveRequestCompatibilityRequirements(args.body); + // #3378: the judge stays in the original conversation (full history, including + // any image_url blocks) — a judge whose vision support cannot be confirmed is + // exactly as unsafe as an unconfirmed panel member (#8332). Drop it the same + // way an operator-hidden judge is dropped below, so fusion falls back to a + // (vision-confirmed) panel member instead of silently losing the image for + // the synthesis step. + const judgeLacksConfirmedVision = + judgeFusionRequirements.requiresVision && + !!configuredJudge && + getResolvedModelCapabilities(configuredJudge).supportsVision !== true; // The panel is filtered for hidden models by resolveComboTargets, but the // explicit judge is a bare string that never passes through it (#8878). Drop a // hidden judge so fusion falls back to a surviving panel member instead of // dispatching a model the operator hid. const judgeModel = - configuredJudge && !isComboModelVisible(configuredJudge, null, args.hiddenModelsByProvider) - ? undefined - : configuredJudge; + configuredJudge && + !judgeLacksConfirmedVision && + isComboModelVisible(configuredJudge, null, args.hiddenModelsByProvider) + ? configuredJudge + : undefined; const fusionTuning = cfg.fusionTuning && typeof cfg.fusionTuning === "object" ? (cfg.fusionTuning as FusionTuning) @@ -413,12 +435,50 @@ export async function tryFusionDispatch(args: { } if (strategy !== "fusion") return null; - const resolvedFusionTargets = resolveComboTargets( + const allResolvedFusionTargets = resolveComboTargets( combo, args.allCombos, clampComboDepth(config.maxComboDepth), args.hiddenModelsByProvider ); + // #3378 (ported from upstream decolua/9router): every non-fusion combo + // strategy runs candidates through filterTargetsByRequestCompatibility before + // dispatch, which excludes a target whose vision support cannot be *confirmed* + // `=== true` for an image-bearing request (#8332 — unknown is treated the same + // as unsupported, never silently forwarded). Fusion resolved its panel via the + // raw target list and skipped that filter entirely, so a panel member with an + // unrecognized model id (capability lookup misses -> supportsVision !== true) + // still received the unmodified image body while the panel silently lost a + // "confirmed vision" voice. Apply the same exclusion here so the fusion panel + // only fans an image request out to targets with confirmed vision support. + const fusionRequirements = judgeFusionRequirements; + const resolvedFusionTargets = fusionRequirements.requiresVision + ? allResolvedFusionTargets.filter( + (target) => !isVisionIncompatibleTarget(target, fusionRequirements) + ) + : allResolvedFusionTargets; + if (fusionRequirements.requiresVision && resolvedFusionTargets.length === 0) { + log.warn( + "COMBO", + `Combo "${combo.name}" fusion panel has no target with confirmed vision support for this image request — every candidate was excluded (#3378)` + ); + return errorResponseWithComboDiagnostics( + 400, + `No target in combo ${combo.name} has confirmed vision support for this image request`, + { + poolSize: allResolvedFusionTargets.length, + attempted: 0, + excluded: allResolvedFusionTargets.map((target) => ({ + provider: target.provider, + model: target.modelStr, + reason: "vision", + })), + attemptOrder: [], + terminalReason: "capability_mismatch", + }, + { code: "capability_mismatch", type: "invalid_request_error" } + ); + } // extractFusionPanelSpec only understands model strings / combo refs, so the // resolved targets have to be flattened before it runs. Keep them indexed so // the panel can be rehydrated below — dispatching the bare strings strips diff --git a/open-sse/services/combo/knownContextOverflow.ts b/open-sse/services/combo/knownContextOverflow.ts deleted file mode 100644 index 532e1a4be6..0000000000 --- a/open-sse/services/combo/knownContextOverflow.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Known context-overflow rejection, extracted from comboStructure.ts to keep - * that file under the file-size cap (#7177). - * - * Fixes: routing a request to a combo whose targets all have a KNOWN (not - * unknown/fail-open) context window too small for the request used to be - * discovered only after every target was tried and failed upstream — burning - * retries/cooldowns on a request that could never succeed. This lets the - * combo dispatcher reject it up front, before exhausting providers. - * - * getKnownContextLimit/hasEstimableContent also - * live here (moved from comboStructure.ts, same file-size-cap motivation): - * they are the "how big is a target's known context window" primitives, so - * they belong next to the overflow check that is their main consumer. - * comboStructure.ts's own compatibility filter now decides fit via its - * evaluateContextLimit (#7052); only hasEstimableContent is imported back. - */ - -import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; -import { isCompressionExcluded, type CompressionExclusions } from "../compression/exclusions.ts"; -import { shouldUseNativeCodexPassthrough } from "../../handlers/chatCore/passthroughHelpers.ts"; -import { deriveRequestCompatibilityRequirements } from "./comboStructure.ts"; -import type { ResolvedComboTarget } from "./types.ts"; - -export type KnownContextOverflow = { - estimatedInputTokens: number; - requestedOutputTokens: number; - requiredContextTokens: number; - maxKnownContextTokens: number; - targetCount: number; -}; - -export type KnownContextOverflowOptions = { - clientManagedResponsesContext?: boolean; - /** - * When prompt compression is enabled for this request (global compression switch - * AND not API-key opted-out), defer the hard preflight so chatCore's compression - * pipeline runs before the final context gate — instead of a raw-body estimate - * rejecting a compressible request up front. (#10225) - */ - deferContextOverflowWhenCompressible?: boolean; - /** Server-side compression exclusions (#8034) — targets matching one cannot run compression. */ - compressionExclusions?: CompressionExclusions; - /** - * #10503: the exact request-shape facts chatCore.ts uses to decide - * `shouldUseNativeCodexPassthrough` (open-sse/handlers/chatCore/passthroughHelpers.ts) — - * threaded down so the deferral decision below can be target-aware instead of - * relying on the looser `clientManagedResponsesContext` proxy. Reused verbatim - * (not re-derived) so the combo-layer decision can never drift from chatCore's own. - */ - sourceFormat?: string | null; - endpointPath?: string | null; - requestHeaders?: Headers | Record | null; -}; - -// #7177: an empty array/object (e.g. a default `messages: []` some combo entrypoints inject -// when the caller sent none) has no real content — counting it would charge a few phantom -// "structural" tokens (JSON.stringify braces/brackets) toward the estimate, which is enough -// to falsely trip the exact-boundary known-context-overflow check for a request that has no -// actual input at all. -export function hasEstimableContent(value: unknown): boolean { - if (value === undefined || value === null) return false; - if (Array.isArray(value)) return value.length > 0; - if (typeof value === "object") return Object.keys(value).length > 0; - return true; -} - -// #7177: known context limit that accounts for the request's own requested -// output tokens — a target whose input+output would together exceed -// maxInputTokens is exactly as incompatible as one whose contextWindow is too -// small, so both bounds go through the same min() so far the tightest wins. -export function getKnownContextLimit( - capabilities: { - maxInputTokens?: number | null; - contextWindow?: number | null; - }, - requestedOutputTokens = 0 -): number | null { - const limits: number[] = []; - if (capabilities.maxInputTokens != null) { - limits.push(capabilities.maxInputTokens + requestedOutputTokens); - } - if (capabilities.contextWindow != null) { - limits.push(capabilities.contextWindow); - } - return limits.length > 0 ? Math.min(...limits) : null; -} - -/** - * Return a hard context-overflow decision only when every target has a known - * context limit and every one of those limits is too small for the request. - * Unknown metadata deliberately keeps the legacy fail-open behavior. - */ -export function getKnownContextOverflow( - targets: ResolvedComboTarget[], - body: Record, - options: KnownContextOverflowOptions = {} -): KnownContextOverflow | null { - if (targets.length === 0) return null; - // Native Codex Responses clients compact their own item history. Let the concrete - // Codex target enforce its effective context limit (including operator overrides) - // instead of rejecting early against a smaller catalog hint. Keep this scoped to - // pools made exclusively from native Codex-capable targets so other Responses - // clients/providers retain the hard preflight. - if ( - options.clientManagedResponsesContext === true && - targets.every( - (target) => target.provider === "codex" || target.provider === "chatgpt-web-codex" - ) - ) { - return null; - } - // #10225 / #10499-sweep #10503: a conservative raw-body context estimate must not - // be treated as proof that a compression-enabled request cannot fit. When - // compression is available for this request AND at least one target can actually - // run it, defer the hard rejection so handleChatCore runs proactive compression - // (chatCore.ts) and its post-compression enforceOutputTokenBudget becomes the - // final context gate — returning a local `context_length_exceeded` only if the - // compressed body still cannot fit (no upstream dispatch). - // - // Target-awareness is load-bearing here: a target is only a valid reason to defer - // when handleChatCore will ACTUALLY attempt compression for it. Two classes are - // excluded from "can compress" even though `isCompressionExcluded` (operator - // exclusions) says nothing about them: - // - Operator-excluded targets (#8034, existing `isCompressionExcluded` check). - // - Native Codex Responses passthrough targets: chatCore.ts unconditionally sets - // `compressionExcluded = nativeCodexPassthrough || ...` for these, computed via - // `shouldUseNativeCodexPassthrough()` (chatCore/passthroughHelpers.ts) — called - // here with the SAME request-shape facts (sourceFormat/endpointPath/headers) - // chatCore itself uses, reused verbatim rather than re-derived from the looser - // `clientManagedResponsesContext` flag (which always requires a VERIFIED native - // client; chatCore's own gate does NOT for provider==="codex" — see - // shouldUseNativeCodexPassthrough's `provider === "codex" || isVerifiedNativeCodexRequest` - // short-circuit). Deferring on such a target's account would let an oversized - // body sail straight through to `fetch()` uncompressed instead of being caught - // by either preflight — silently defeating the whole point of this feature. - // If NO target can compress, the fast raw-body preflight is kept (unchanged). - if ( - options.deferContextOverflowWhenCompressible === true && - targets.some((target) => { - const isNativeCodexPassthroughTarget = shouldUseNativeCodexPassthrough({ - provider: target.provider, - sourceFormat: options.sourceFormat, - endpointPath: options.endpointPath, - body, - headers: options.requestHeaders, - }); - if (isNativeCodexPassthroughTarget) return false; - return !isCompressionExcluded( - { - provider: target.provider, - model: target.modelStr.includes("/") - ? target.modelStr.split("/").slice(1).join("/") - : target.modelStr, - }, - options.compressionExclusions - ); - }) - ) { - return null; - } - const requirements = deriveRequestCompatibilityRequirements(body); - if (requirements.requiredContextTokens <= 0) return null; - - const limits = targets.map((target) => - getKnownContextLimit( - getResolvedModelCapabilities(target.modelStr), - requirements.requestedOutputTokens - ) - ); - if (limits.some((limit) => limit === null)) return null; - - const knownLimits = limits as number[]; - const maxKnownContextTokens = Math.max(...knownLimits); - if (maxKnownContextTokens >= requirements.requiredContextTokens) return null; - - return { - estimatedInputTokens: requirements.estimatedInputTokens, - requestedOutputTokens: requirements.requestedOutputTokens, - requiredContextTokens: requirements.requiredContextTokens, - maxKnownContextTokens, - targetCount: targets.length, - }; -} diff --git a/open-sse/services/combo/quotaScoring.ts b/open-sse/services/combo/quotaScoring.ts index 4a853b1455..a107768278 100644 --- a/open-sse/services/combo/quotaScoring.ts +++ b/open-sse/services/combo/quotaScoring.ts @@ -14,6 +14,7 @@ import { isRecord } from "./comboData.ts"; import type { SlaRoutingPolicy } from "../autoCombo/routerStrategy.ts"; import { RESET_WINDOW_NAMES } from "./types.ts"; import type { ResolvedComboTarget } from "./types.ts"; +import { resolveProviderId } from "../../../src/shared/constants/providers.ts"; const RESET_AWARE_SESSION_WINDOW_MS = 5 * 60 * 60 * 1000; const RESET_AWARE_WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; @@ -138,7 +139,11 @@ export function resolveSlaRoutingPolicy( export function getResetAwareProvider(target: ResolvedComboTarget): string | null { const provider = (target.providerId || target.provider || "").toLowerCase(); - return provider || null; + // #10877: combo targets can carry a legacy/user-facing alias spelling + // (e.g. "ollamacloud", "cx") while quota fetchers register under the + // canonical provider id (e.g. "ollama-cloud", "codex"). Canonicalize here + // so getQuotaFetcher() lookups downstream (quotaStrategies.ts) find them. + return provider ? resolveProviderId(provider) : null; } function normalizeResetAt(value: unknown): string | null { diff --git a/open-sse/services/combo/quotaShareStrategy.ts b/open-sse/services/combo/quotaShareStrategy.ts index e9e4293543..e12958042c 100644 --- a/open-sse/services/combo/quotaShareStrategy.ts +++ b/open-sse/services/combo/quotaShareStrategy.ts @@ -181,6 +181,7 @@ function applyDrr(targets: ResolvedComboTarget[], comboName: string): ResolvedCo const deficits = getDrrDeficits(comboName); const totalWeight = targets.reduce((sum, t) => sum + normalizeWeight(t.weight), 0); + if (totalWeight <= 0) return targets.slice(); // Add each target's quantum (weight share) to its deficit. for (const target of targets) { @@ -206,8 +207,9 @@ function applyDrr(targets: ResolvedComboTarget[], comboName: string): ResolvedCo return [winner, ...rest]; } -/** Weights default to 1 and are floored at 1 to keep quantum math well-defined. */ +/** Weights default to 1. Explicit 0 stays 0 so the operator can disable a target. */ function normalizeWeight(weight: number | undefined): number { + if (weight === 0) return 0; return Number.isFinite(weight) && (weight as number) > 0 ? (weight as number) : 1; } diff --git a/open-sse/services/combo/resolveAutoStrategy.ts b/open-sse/services/combo/resolveAutoStrategy.ts index 1d6d2bf156..7d6fe2a682 100644 --- a/open-sse/services/combo/resolveAutoStrategy.ts +++ b/open-sse/services/combo/resolveAutoStrategy.ts @@ -179,32 +179,11 @@ export async function resolveAutoStrategyOrder( `Auto strategy: context-window filter kept ${filteredByContext.length}/${eligibleTargets.length} candidates (est. ${estimatedInputTokens} tokens)` ); eligibleTargets = filteredByContext; - } else if (compatFilterFailOpen) { + } else { log.warn( "COMBO", - `Auto strategy: all candidates filtered by context-window policy (est. ${estimatedInputTokens} tokens), falling back to full pool (compatFilterFailOpen)` + `Auto strategy: all candidates filtered by approximate context-window policy (est. ${estimatedInputTokens} tokens), falling back to full pool` ); - } else { - // #8488: every candidate has a known limit below the estimate — surface - // context_length_exceeded rather than dispatching oversized targets. - return { - earlyResponse: errorResponseWithComboDiagnostics( - 400, - `Request requires approximately ${estimatedInputTokens} tokens, but every auto-strategy candidate in combo ${combo.name} has a smaller known context limit`, - { - poolSize: eligibleTargets.length, - attempted: 0, - excluded: eligibleTargets.map((target) => ({ - provider: target.provider, - model: target.modelStr, - reason: "context_window", - })), - attemptOrder: [], - terminalReason: "context_length_exceeded", - }, - { code: "context_length_exceeded", type: "invalid_request_error" } - ), - }; } eligibleTargets = await expandAutoComboCandidatePool(eligibleTargets, combo); diff --git a/open-sse/services/combo/targetResolution.ts b/open-sse/services/combo/targetResolution.ts index 45aa57013f..ff3b0362f7 100644 --- a/open-sse/services/combo/targetResolution.ts +++ b/open-sse/services/combo/targetResolution.ts @@ -8,21 +8,19 @@ * 1. provider-wildcard expansion of the combo + the combos collection (#2562) * 2. weighted step-group resolution + sticky-weighted eligibility * 3. request-tag routing - * 4. known-context-overflow early return - * 5. smart/pipeline-enabled dispatch (auto strategy) - * 6. auto-strategy candidate build / scoring / ordering, or per-strategy ordering - * 7. prompt-cache strategy affinity, session stickiness, eval scores, + * 4. smart/pipeline-enabled dispatch (auto strategy) + * 5. auto-strategy candidate build / scoring / ordering, or per-strategy ordering + * 6. prompt-cache strategy affinity, session stickiness, eval scores, * request compatibility, context requirements - * 8. task-aware reordering - * 9. prompt-cache affinity application - * 10. the parallel pre-screen (priority strategy only) + * 7. task-aware reordering + * 8. prompt-cache affinity application + * 9. the parallel pre-screen (priority strategy only) * - * Behaviour is byte-identical to the inline block it replaces — the two early exits - * (context overflow, pipeline dispatch, auto-strategy `earlyResponse`) become an - * `{ earlyResponse }` result so the host decides to return them, and the values the - * attempt loop still consumes (`orderedTargets`, `stickyWeightedLimit`, - * `getWeightedStepKeyForTarget`, `sticky`, `preScreenMap`) are returned instead of - * closed over. + * Behaviour is byte-identical to the inline block it replaces — pipeline dispatch and + * auto-strategy `earlyResponse` become an `{ earlyResponse }` result so the host decides + * to return them, and the values the attempt loop still consumes (`orderedTargets`, + * `stickyWeightedLimit`, `getWeightedStepKeyForTarget`, `sticky`, `preScreenMap`) are + * returned instead of closed over. * * See _tasks/quality/2026-06-19-DESIGN-godfiles-decomposition.md §4. */ @@ -53,7 +51,6 @@ import { } from "./comboStructure.ts"; import { applyContextRequirements } from "./contextRequirements.ts"; import { recordComboFailure } from "./failureTracker.ts"; -import { getKnownContextOverflow } from "./knownContextOverflow.ts"; import { buildEmptyComboTargetsPayload, buildRecoveryHint } from "./pinRecovery.ts"; import { applyPromptCacheAffinity, @@ -113,16 +110,6 @@ export interface ResolveComboTargetPipelineDeps { */ buildAutoCandidates: ResolveAutoStrategyDeps["buildAutoCandidates"]; hiddenModelsByProvider?: HiddenModelsByProvider; - /** Native Responses clients (for example Codex CLI/Desktop) manage compaction themselves. */ - clientManagedResponsesContext?: boolean; - /** #10225 — defer the hard context-overflow preflight when compression is enabled for this request. */ - deferContextOverflowWhenCompressible?: boolean; - /** Server-side compression exclusions (#8034) — which targets can run compression. */ - compressionExclusions?: import("../compression/exclusions.ts").CompressionExclusions; - /** #10503 — request-shape facts for the target-aware deferral check (see knownContextOverflow.ts). */ - sourceFormat?: string | null; - endpointPath?: string | null; - requestHeaders?: Headers | Record | null; } export interface ResolvedComboTargetPipeline { @@ -323,35 +310,6 @@ function buildWeightedStepKeyMapper( }; } -/** 400 rejection for a request no target in the pool can physically accept. */ -function buildContextOverflowResponse( - overflow: { requiredContextTokens: number; maxKnownContextTokens: number }, - orderedTargets: ResolvedComboTarget[], - log: ComboLogger -): Response { - const { requiredContextTokens, maxKnownContextTokens } = overflow; - log.warn( - "COMBO", - `Request context exceeds every known target limit (${requiredContextTokens} > ${maxKnownContextTokens} tokens)` - ); - return errorResponseWithComboDiagnostics( - 400, - `Request requires approximately ${requiredContextTokens} tokens, but the largest known context limit in this combo is ${maxKnownContextTokens} tokens. Reduce or compact the request context.`, - { - poolSize: orderedTargets.length, - attempted: 0, - excluded: orderedTargets.map((target) => ({ - provider: target.provider, - model: target.modelStr, - reason: "context_window", - })), - attemptOrder: [], - terminalReason: "context_length_exceeded", - }, - { code: "context_length_exceeded", type: "invalid_request_error" } - ); -} - function logTargetPoolSize( strategy: string, allCombos: ComboCollectionLike, @@ -736,18 +694,6 @@ export async function resolveComboTargetPipeline( orderedTargets = await applyRequestTagRouting(orderedTargets, body, log); - const overflow = getKnownContextOverflow(orderedTargets, body, { - clientManagedResponsesContext: deps.clientManagedResponsesContext, - deferContextOverflowWhenCompressible: deps.deferContextOverflowWhenCompressible, - compressionExclusions: deps.compressionExclusions, - sourceFormat: deps.sourceFormat, - endpointPath: deps.endpointPath, - requestHeaders: deps.requestHeaders, - }); - if (overflow) { - return { earlyResponse: buildContextOverflowResponse(overflow, orderedTargets, log) }; - } - logTargetPoolSize(strategy, allCombos, orderedTargets, stickyWeightedKey, log); const pipelineResponse = await dispatchSmartPipeline( diff --git a/open-sse/services/combo/targetTimeoutRunner.ts b/open-sse/services/combo/targetTimeoutRunner.ts index 09a80559b2..4eb6cb8b68 100644 --- a/open-sse/services/combo/targetTimeoutRunner.ts +++ b/open-sse/services/combo/targetTimeoutRunner.ts @@ -19,6 +19,76 @@ import type { HandleSingleModel, SingleModelTarget, ComboLogger } from "./types. /** Stable internal classification for OmniRoute's own combo per-target timer. */ export const COMBO_TARGET_TIMEOUT_CODE = "combo_target_timeout"; +/** + * Diagnostic: track recent combo-per-model-timeout abort errors so an + * unhandledRejection handler can attribute the stack trace to a specific model + * and timeout value. Ring buffer of 4 — concurrent per-model timeouts are rare + * but possible (e.g. hedge + per-target timeout on different targets). + */ +const CONTEXT_RING_SIZE = 4; +const lastTimeoutContexts: Array<{ + modelStr: string; + timeoutMs: number; + abortError: Error; + timestamp: number; +}> = []; +let contextRingIndex = 0; + +function recordTimeoutContext(ctx: { + modelStr: string; + timeoutMs: number; + abortError: Error; + timestamp: number; +}): void { + if (lastTimeoutContexts.length < CONTEXT_RING_SIZE) { + lastTimeoutContexts.push(ctx); + } else { + lastTimeoutContexts[contextRingIndex] = ctx; + contextRingIndex = (contextRingIndex + 1) % CONTEXT_RING_SIZE; + } +} + +/** Retrieve (and clear) all pending combo-per-model-timeout diagnostic contexts. */ +export function drainLastTimeoutContexts(): typeof lastTimeoutContexts { + const out = lastTimeoutContexts.splice(0); + contextRingIndex = 0; + return out; +} + +/** + * Install a persistent unhandledRejection listener that logs combo-per-model-timeout + * diagnostics. Call once at module load. The listener stays installed permanently — + * it only acts on combo-per-model-timeout rejections and returns early for everything + * else, so there is no handler leak and no remove/re-install race window. + */ +let diagnosticInstalled = false; +function ensureDiagnosticListener(): void { + if (diagnosticInstalled) return; + diagnosticInstalled = true; + process.on("unhandledRejection", (reason: unknown) => { + try { + const isComboTimeout = + reason instanceof Error && reason.message === COMBO_PER_MODEL_TIMEOUT_REASON; + if (!isComboTimeout) return; + const contexts = drainLastTimeoutContexts(); + // Log the full stack trace so the next production incident is diagnosable. + // Without this, Node's default unhandledRejection warning shows only + // "Error: combo-per-model-timeout" with no caller context. + const summary = + contexts.length > 0 + ? contexts.map((c) => ` model=${c.modelStr} timeout=${c.timeoutMs}ms`).join("\n") + : " (no context recorded)"; + console.error( + "[COMBO-TIMEOUT-DIAGNOSTIC] unhandledRejection from combo per-model timeout.\n" + + `${summary}\n` + + ` abortError stack:\n${reason.stack ?? reason}` + ); + } catch { + // Diagnostic logging failed — never let this break the process. + } + }); +} + export function buildTargetTimeoutRunner(deps: { handleSingleModel: HandleSingleModel; comboTargetTimeoutMs: number; @@ -29,6 +99,7 @@ export function buildTargetTimeoutRunner(deps: { target?: SingleModelTarget ) => Promise { const { handleSingleModel, comboTargetTimeoutMs, log } = deps; + ensureDiagnosticListener(); return async ( b: Record, modelStr: string, @@ -46,11 +117,18 @@ export function buildTargetTimeoutRunner(deps: { const timeoutPromise = new Promise((resolve) => { timeoutId = setTimeout(() => { timedOut = true; + const abortErr = new Error(COMBO_PER_MODEL_TIMEOUT_REASON); + recordTimeoutContext({ + modelStr, + timeoutMs: comboTargetTimeoutMs, + abortError: abortErr, + timestamp: Date.now(), + }); log.warn( "COMBO", `Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back` ); - timeoutController.abort(new Error(COMBO_PER_MODEL_TIMEOUT_REASON)); + timeoutController.abort(abortErr); // HTTP 504 (not proprietary 524): this is OmniRoute's own per-target timer. // Typed as combo_target_timeout so request-scoped classification can keep the // connection eligible for fallback instead of treating it like Cloudflare 524 @@ -88,6 +166,13 @@ export function buildTargetTimeoutRunner(deps: { } } try { + // Both branches of the race resolve (never reject): the inner + // handleSingleModel call has a .catch() that converts rejections into + // responses, and timeoutPromise always resolves. A defensive outer + // .catch() guards against unexpected throws in the .catch() handler + // itself (e.g. a broken Error.prototype.message getter) — without + // this, such a throw would surface as an unhandledRejection tagged + // "combo-per-model-timeout" in production logs. return await Promise.race([ handleSingleModel(b, modelStr, targetWithSignal).catch((err) => { if (timedOut) { @@ -99,7 +184,13 @@ export function buildTargetTimeoutRunner(deps: { return errorResponse(502, err?.message ?? "Upstream model error"); }), timeoutPromise, - ]); + ]).catch((raceErr) => { + // Defensive: should never fire — both race branches always resolve. + // Include the error message so the root cause is not masked. + const detail = raceErr instanceof Error ? raceErr.message : String(raceErr); + log.error?.("COMBO", `Unexpected rejection in combo timeout race for ${modelStr}: ${detail}`); + return errorResponse(502, `Combo timeout dispatch error: ${detail}`); + }); } finally { clearTimeout(timeoutId); if (parentHedgeSignal && onParentHedgeAbort) { diff --git a/open-sse/services/combo/types.ts b/open-sse/services/combo/types.ts index 03349e43c7..83a7f26693 100644 --- a/open-sse/services/combo/types.ts +++ b/open-sse/services/combo/types.ts @@ -100,6 +100,8 @@ export type ComboNestingContext = { export type HiddenModelsByProvider = ReadonlyMap>; export type HandleComboChatOptions = { + /** #10681: optional opaque parent invocation id for the decision trace. */ + invocationId?: string; body: Record; combo: ComboLike; handleSingleModel: HandleSingleModel; diff --git a/open-sse/services/compression/caveman.ts b/open-sse/services/compression/caveman.ts index 685d16decd..c629cb125e 100644 --- a/open-sse/services/compression/caveman.ts +++ b/open-sse/services/compression/caveman.ts @@ -204,187 +204,15 @@ export function applyRulesToText( } function cleanupArtifacts(text: string): string { - let result = text; - if (hasRepeatedHorizontalWhitespace(result)) { - result = collapseHorizontalWhitespaceRuns(result); - } - result = removeHorizontalWhitespaceBeforePunctuation(result); - result = collapseRepeatedSentencePunctuation(result); - if (result.includes(" \n") || result.includes("\t\n")) { - result = stripLineTrailingHorizontalWhitespace(result); - } - if (result.endsWith(" ") || result.endsWith("\t")) result = result.trimEnd(); - if (result.includes("\n\n\n")) result = collapseExcessNewlines(result); - if (result.startsWith("\n")) result = trimLeadingNewlines(result); - if (result.endsWith("\n")) result = trimTrailingNewlines(result); - return result; -} - -function isHorizontalWhitespace(char: string): boolean { - return char === " " || char === "\t"; -} - -function isSentencePunctuation(char: string): boolean { - return char === "." || char === "!" || char === "?"; -} - -function isCleanupPunctuation(char: string): boolean { - return ( - char === "," || char === "." || char === ";" || char === ":" || char === "!" || char === "?" - ); -} - -function hasRepeatedHorizontalWhitespace(text: string): boolean { - let previousWasWhitespace = false; - for (const char of text) { - const currentIsWhitespace = isHorizontalWhitespace(char); - if (currentIsWhitespace && previousWasWhitespace) return true; - previousWasWhitespace = currentIsWhitespace; - } - return false; -} - -function collapseHorizontalWhitespaceRuns(text: string): string { - let output = ""; - let changed = false; - - for (let index = 0; index < text.length; index++) { - const char = text[index]; - if (!isHorizontalWhitespace(char)) { - output += char; - continue; - } - - const start = index; - while (index + 1 < text.length && isHorizontalWhitespace(text[index + 1])) { - index++; - } - - if (index > start) { - output += " "; - changed = true; - } else { - output += char; - } - } - - return changed ? output : text; -} - -function removeHorizontalWhitespaceBeforePunctuation(text: string): string { - let output = ""; - let changed = false; - - for (let index = 0; index < text.length; index++) { - const char = text[index]; - if (!isHorizontalWhitespace(char)) { - output += char; - continue; - } - - const start = index; - while (index + 1 < text.length && isHorizontalWhitespace(text[index + 1])) { - index++; - } - - const nextChar = text[index + 1]; - if (nextChar && isCleanupPunctuation(nextChar)) { - changed = true; - continue; - } - - output += text.slice(start, index + 1); - } - - return changed ? output : text; -} - -function collapseRepeatedSentencePunctuation(text: string): string { - let output = ""; - let changed = false; - - for (let index = 0; index < text.length; index++) { - const char = text[index]; - if (!isSentencePunctuation(char)) { - output += char; - continue; - } - - let lastPunctuation = char; - const start = index; - while (index + 1 < text.length && isSentencePunctuation(text[index + 1])) { - index++; - lastPunctuation = text[index]; - } - - if (index > start) changed = true; - output += lastPunctuation; - } - - return changed ? output : text; -} - -function trimEndHorizontalWhitespace(text: string): string { - let end = text.length; - while (end > 0 && isHorizontalWhitespace(text[end - 1])) { - end--; - } - return end === text.length ? text : text.slice(0, end); -} - -function stripLineTrailingHorizontalWhitespace(text: string): string { - const lines = text.split("\n"); - let changed = false; - const cleanedLines = lines.map((line) => { - const cleaned = trimEndHorizontalWhitespace(line); - if (cleaned !== line) changed = true; - return cleaned; - }); - return changed ? cleanedLines.join("\n") : text; -} - -function collapseExcessNewlines(text: string): string { - let output = ""; - let changed = false; - - for (let index = 0; index < text.length; index++) { - const char = text[index]; - if (char !== "\n") { - output += char; - continue; - } - - const start = index; - while (index + 1 < text.length && text[index + 1] === "\n") { - index++; - } - - const newlineCount = index - start + 1; - if (newlineCount > 2) { - output += "\n\n"; - changed = true; - } else { - output += text.slice(start, index + 1); - } - } - - return changed ? output : text; -} - -function trimLeadingNewlines(text: string): string { - let start = 0; - while (start < text.length && text[start] === "\n") { - start++; - } - return start === 0 ? text : text.slice(start); -} - -function trimTrailingNewlines(text: string): string { - let end = text.length; - while (end > 0 && text[end - 1] === "\n") { - end--; - } - return end === text.length ? text : text.slice(0, end); + if (!text) return ""; + return text + .replace(/[ \t]{2,}/g, " ") + .replace(/[ \t]+([,.;:!?])/g, "$1") + .replace(/([.!?]){2,}/g, (m) => m[m.length - 1]) + .replace(/[ \t]+$/gm, "") + .replace(/\n{3,}/g, "\n\n") + .replace(/^\n+/, "") + .replace(/\n+$/, ""); } /** diff --git a/open-sse/services/compression/engines/llmlingua/worker.ts b/open-sse/services/compression/engines/llmlingua/worker.ts index bf6fba4963..00ba3e1255 100644 --- a/open-sse/services/compression/engines/llmlingua/worker.ts +++ b/open-sse/services/compression/engines/llmlingua/worker.ts @@ -8,7 +8,7 @@ * * ## Fail-open paths * 1. Optional-deps gate: if any of `@atjsh/llmlingua-2`, `@huggingface/transformers`, - * `@tensorflow/tfjs`, `js-tiktoken` does not resolve, return `text` immediately — + * `js-tiktoken` does not resolve, return `text` immediately — * NO worker spawn. This is the default in CI / most installs (deps are OPTIONAL). * 2. Per-call timeout: first call for a model gets `FIRST_CALL_TIMEOUT_MS` (one-time * model load); warm calls get `LLMLINGUA_WORKER_TIMEOUT_MS`. On timeout → original @@ -16,7 +16,7 @@ * 3. Worker error/exit → resolve all pending with their original text + respawn next. * * ## Serialization - * ONNX/tfjs are not reentrant — calls are queued FIFO and only one message is + * ONNX inference is not reentrant — calls are queued FIFO and only one message is * in-flight at a time (the next is posted after the previous reply or its timeout). * * ## Idle eviction @@ -45,7 +45,7 @@ const FIRST_CALL_TIMEOUT_MS = 60000; /** * Gate probe: `@atjsh/llmlingua-2` is the entry package that declares the others - * (`@huggingface/transformers`, `@tensorflow/tfjs`, `js-tiktoken`) as peers. We probe + * (`@huggingface/transformers`, `js-tiktoken`) as peers. We probe * ONLY it (by manifest existence) because the peers are ESM-only and `require.resolve` * throws for them even when installed; the worker still fail-opens if a peer is * genuinely missing at `import()` time. diff --git a/open-sse/services/compression/engines/rtk/configSchema.ts b/open-sse/services/compression/engines/rtk/configSchema.ts index e7699120e3..2af6385015 100644 --- a/open-sse/services/compression/engines/rtk/configSchema.ts +++ b/open-sse/services/compression/engines/rtk/configSchema.ts @@ -66,6 +66,22 @@ export const RTK_SCHEMA: EngineConfigField[] = [ { value: "always", label: "always" }, ], }, + { + key: "rawOutputMaxFiles", + type: "number", + label: "Max raw-output files (oldest purged beyond this)", + defaultValue: DEFAULT_RTK_CONFIG.rawOutputMaxFiles, + min: 1, + max: 10_000_000, + }, + { + key: "rawOutputMaxAgeDays", + type: "number", + label: "Max raw-output age (days)", + defaultValue: DEFAULT_RTK_CONFIG.rawOutputMaxAgeDays, + min: 1, + max: 3650, + }, { key: "enableRenderers", type: "boolean", @@ -113,5 +129,10 @@ export function validateRtkEngineConfig(config: Record): Engine ) { errors.push("rawOutputRetention must be never, failures, or always"); } + for (const key of ["rawOutputMaxFiles", "rawOutputMaxAgeDays"]) { + if (config[key] !== undefined && (typeof config[key] !== "number" || config[key] < 1)) { + errors.push(`${key} must be a positive number`); + } + } return { valid: errors.length === 0, errors }; } diff --git a/open-sse/services/compression/engines/rtk/index.ts b/open-sse/services/compression/engines/rtk/index.ts index 0d34980043..b3791bfc54 100644 --- a/open-sse/services/compression/engines/rtk/index.ts +++ b/open-sse/services/compression/engines/rtk/index.ts @@ -9,7 +9,11 @@ import { matchRtkFilter } from "./filterLoader.ts"; import { applyLineFilter } from "./lineFilter.ts"; import { smartTruncate } from "./smartTruncate.ts"; import { normalizeCodeLanguage, stripCode } from "./codeStripper.ts"; -import { maybePersistRtkRawOutput, type RtkRawOutputPointer } from "./rawOutput.ts"; +import { + maybePersistRtkRawOutput, + scheduleRtkRawOutputPurge, + type RtkRawOutputPointer, +} from "./rawOutput.ts"; import { applyRenderer } from "./renderers/index.ts"; import { isTextBlock } from "../../messageContent.ts"; import { adaptBodyForCompression } from "../../bodyAdapter.ts"; @@ -121,6 +125,15 @@ function mergeRtkConfig(base?: Partial, override?: Record message !== messages[index] + ); + if (!anyMessageChanged) { + return { body, compressed: false, stats: null }; + } + const compressedBody = { ...adapter.body, messages: compressedMessages }; const stats = createCompressionStats( adapter.body, diff --git a/open-sse/services/compression/engines/rtk/rawOutput.ts b/open-sse/services/compression/engines/rtk/rawOutput.ts index 57c2e4da34..655ec22ca7 100644 --- a/open-sse/services/compression/engines/rtk/rawOutput.ts +++ b/open-sse/services/compression/engines/rtk/rawOutput.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import fsp from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import crypto from "node:crypto"; @@ -71,6 +72,24 @@ export function isLikelyFailureOutput(value: string): boolean { ); } +/** + * #10659: the raw-output store used to grow unbounded and every pointer read did a full + * readdirSync over the whole store, freezing the event loop with millions of files. + * New writes now land in id-prefix buckets (`//...`) so reads are O(bucket), + * and a bounded async purge (see purgeRtkRawOutput) caps total files/age. + */ +const RAW_OUTPUT_BUCKET_LEN = 2; +/** Legacy flat-store entries beyond this size are not synchronously scanned (freeze guard). */ +const LEGACY_FLAT_SCAN_GUARD = 100_000; + +function rawOutputDir(): string { + return path.join(dataDir(), "rtk", "raw-output"); +} + +function bucketDir(id: string): string { + return path.join(rawOutputDir(), id.slice(0, RAW_OUTPUT_BUCKET_LEN)); +} + export function maybePersistRtkRawOutput( raw: string, options: { @@ -93,8 +112,9 @@ export function maybePersistRtkRawOutput( .replace(/^_+|_+$/g, "") .slice(0, 48); const id = safeId(`${now}:${commandSlug}:${raw.length}:${redaction.text}`); - const dir = path.join(dataDir(), "rtk", "raw-output"); - const filePath = path.join(dir, `${now}-${commandSlug || "tool-output"}-${id}.log`); + const dir = bucketDir(id); + const fileName = `${now}-${commandSlug || "tool-output"}-${id}.log`; + const filePath = path.join(dir, fileName); try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, redaction.text); @@ -135,11 +155,33 @@ export function maybePersistRtkRawOutput( } export function readRtkRawOutput(pointerId: string): string | null { - const dir = path.join(dataDir(), "rtk", "raw-output"); + const dir = rawOutputDir(); if (!fs.existsSync(dir)) return null; - const entry = fs - .readdirSync(dir) - .find((file) => file.endsWith(".log") && file.includes(pointerId)); + + // Bucketed layout first (new writes): one tiny subdir read instead of a full-store scan. + const bucket = bucketDir(pointerId); + if (fs.existsSync(bucket)) { + const entry = fs + .readdirSync(bucket) + .find((file) => file.endsWith(".log") && file.includes(pointerId)); + if (entry) { + const fullPath = path.join(bucket, entry); + if (!fullPath.startsWith(dir)) return null; + return fs.readFileSync(fullPath, "utf8"); + } + } + + // Legacy flat layout (pre-bucket writes). Guarded: scanning a multi-million-entry flat + // store synchronously is exactly the event-loop freeze #10659 reports, so refuse once + // the flat store is pathologically large instead of stalling the gateway. + const entries = fs.readdirSync(dir); + if (entries.length > LEGACY_FLAT_SCAN_GUARD) { + console.warn( + `[rtk-raw-output] legacy flat store has ${entries.length} entries; skipping O(n) pointer scan for ${pointerId}` + ); + return null; + } + const entry = entries.find((file) => file.endsWith(".log") && file.includes(pointerId)); if (!entry) return null; const fullPath = path.join(dir, entry); if (!fullPath.startsWith(dir)) return null; @@ -156,6 +198,51 @@ function commandFromSlug(fileName: string): string { return slug.replace(/_+/g, " ").trim(); } +/** + * Collect every `.log` path in the store (legacy flat + buckets). The flat store is + * guarded so a pathological legacy directory cannot freeze the loop; bucket dirs are + * small by construction (the purge cap keeps each bucket bounded). + */ +function collectRawOutputLogFiles(dir: string): Array<{ name: string; fullPath: string }> { + const logs: Array<{ name: string; fullPath: string }> = []; + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + return logs; + } + if (entries.length <= LEGACY_FLAT_SCAN_GUARD) { + for (const entry of entries) { + if (entry.endsWith(".log")) logs.push({ name: entry, fullPath: path.join(dir, entry) }); + } + } else { + console.warn( + `[rtk-raw-output] legacy flat store has ${entries.length} entries; skipping sample scan this run` + ); + } + for (const entry of entries) { + if (entry.length !== RAW_OUTPUT_BUCKET_LEN) continue; + const subPath = path.join(dir, entry); + let isDir = false; + try { + isDir = fs.statSync(subPath).isDirectory(); + } catch { + continue; + } + if (!isDir) continue; + let subEntries: string[]; + try { + subEntries = fs.readdirSync(subPath); + } catch { + continue; + } + for (const name of subEntries) { + if (name.endsWith(".log")) logs.push({ name, fullPath: path.join(subPath, name) }); + } + } + return logs; +} + /** * Read the opt-in RTK raw-output store (`DATA_DIR/rtk/raw-output/*.log`) into * `CommandSample[]` for the pure miners `discoverRepeatedNoise()` / `suggestFilter()`. @@ -166,24 +253,17 @@ function commandFromSlug(fileName: string): string { * memory. No throw: a corrupt entry is dropped, not propagated. */ export function listRtkCommandSamples(opts: { limit?: number } = {}): CommandSample[] { - const dir = path.join(dataDir(), "rtk", "raw-output"); + const dir = rawOutputDir(); if (!fs.existsSync(dir)) return []; const limit = Math.max(1, Math.floor(opts.limit ?? 500)); - let logs: string[]; - try { - logs = fs.readdirSync(dir).filter((f) => f.endsWith(".log")); - } catch { - return []; - } + const logs = collectRawOutputLogFiles(dir); // Newest first: the filename is timestamp-prefixed, so a reverse lexical sort works. - logs.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); + logs.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0)); const samples: CommandSample[] = []; - for (const fileName of logs) { + for (const { name, fullPath } of logs) { if (samples.length >= limit) break; - const fullPath = path.join(dir, fileName); - if (!fullPath.startsWith(dir)) continue; let output: string; try { output = fs.readFileSync(fullPath, "utf8"); @@ -191,7 +271,6 @@ export function listRtkCommandSamples(opts: { limit?: number } = {}): CommandSam continue; } if (output.trim().length === 0) continue; - let command = ""; try { const metaRaw = fs.readFileSync(fullPath.replace(/\.log$/, ".meta.json"), "utf8"); @@ -200,9 +279,158 @@ export function listRtkCommandSamples(opts: { limit?: number } = {}): CommandSam } catch { // No/!invalid sidecar → fall back to the filename slug below. } - if (!command) command = commandFromSlug(fileName) || "tool-output"; - + if (!command) command = commandFromSlug(name) || "tool-output"; samples.push({ command, output }); } return samples; } + +export interface RtkRawOutputPurgeOptions { + maxAgeDays?: number; + maxFiles?: number; +} + +export interface RtkRawOutputPurgeResult { + skipped: boolean; + scanned: number; + deleted: number; + errors: number; +} + +const PURGE_THROTTLE_MS = 60_000; +let lastRawOutputPurgeAt = 0; + +/** Test hook: clear the purge throttle so a test can exercise two consecutive purges. */ +export function resetRtkRawOutputPurgeThrottle(): void { + lastRawOutputPurgeAt = 0; +} + +async function mapLimit( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise { + let index = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (index < items.length) { + const item = items[index++]; + await fn(item); + } + }); + await Promise.all(workers); +} + +/** + * #10659: bounded retention for the raw-output store. Enforces max age and max file count + * asynchronously (never blocks the event loop), best-effort (never throws into callers), + * and throttled to once per minute from the scheduler. + * + * The legacy flat store is skipped when it is pathologically large (guard) — scanning it + * synchronously/async with millions of entries is what froze gateways; the operator does + * a one-off cleanup and the bucketized layout keeps new growth bounded. + */ +export async function purgeRtkRawOutput( + opts: RtkRawOutputPurgeOptions = {} +): Promise { + const now = Date.now(); + if (now - lastRawOutputPurgeAt < PURGE_THROTTLE_MS) { + return { skipped: true, scanned: 0, deleted: 0, errors: 0 }; + } + lastRawOutputPurgeAt = now; + + const maxAgeDays = Math.max(1, Math.floor(opts.maxAgeDays ?? 30)); + const maxFiles = Math.max(1, Math.floor(opts.maxFiles ?? 100_000)); + const maxAgeMs = maxAgeDays * 86_400_000; + const dir = rawOutputDir(); + const result: RtkRawOutputPurgeResult = { skipped: false, scanned: 0, deleted: 0, errors: 0 }; + if (!fs.existsSync(dir)) return result; + + try { + const candidates: Array<{ file: string; meta: string | null; ts: number }> = []; + const flat = await fsp.readdir(dir); + if (flat.length > LEGACY_FLAT_SCAN_GUARD) { + console.warn( + `[rtk-raw-output] legacy flat store has ${flat.length} entries; purge skips flat scan this run (one-off manual cleanup recommended)` + ); + } else { + for (const name of flat) { + if (!name.endsWith(".log")) continue; + candidates.push({ + file: path.join(dir, name), + meta: path.join(dir, name.replace(/\.log$/, ".meta.json")), + ts: parseInt(name, 10) || 0, + }); + } + } + for (const entry of flat) { + if (entry.length !== RAW_OUTPUT_BUCKET_LEN) continue; + const subPath = path.join(dir, entry); + let isDir = false; + try { + isDir = (await fsp.stat(subPath)).isDirectory(); + } catch { + continue; + } + if (!isDir) continue; + let subEntries: string[]; + try { + subEntries = await fsp.readdir(subPath); + } catch { + continue; + } + for (const name of subEntries) { + if (!name.endsWith(".log")) continue; + candidates.push({ + file: path.join(subPath, name), + meta: path.join(subPath, name.replace(/\.log$/, ".meta.json")), + ts: parseInt(name, 10) || 0, + }); + } + } + result.scanned = candidates.length; + + const agedOut = candidates.filter((c) => c.ts > 0 && now - c.ts > maxAgeMs); + const remaining = candidates.filter((c) => !agedOut.includes(c)); + remaining.sort((a, b) => b.ts - a.ts || (a.file < b.file ? 1 : -1)); + const keep = new Set(remaining.slice(0, maxFiles).map((c) => c.file)); + const overflow = remaining.filter((c) => !keep.has(c.file)); + + await mapLimit([...agedOut, ...overflow], 32, async (c) => { + try { + await fsp.unlink(c.file); + result.deleted++; + } catch { + result.errors++; + } + if (c.meta) { + try { + await fsp.unlink(c.meta); + } catch { + // Missing/never-written sidecar is fine. + } + } + }); + + if (result.deleted > 0 || result.errors > 0) { + console.log( + `[rtk-raw-output] purge: scanned=${result.scanned} deleted=${result.deleted} errors=${result.errors} (maxFiles=${maxFiles}, maxAgeDays=${maxAgeDays})` + ); + } + } catch (err) { + console.warn("[rtk-raw-output] purge failed:", (err as Error).message); + result.errors++; + } + return result; +} + +/** + * Schedule a throttled best-effort purge off the hot path. Safe to call on every write: + * purgeRtkRawOutput itself throttles to once per minute. + */ +export function scheduleRtkRawOutputPurge(opts: RtkRawOutputPurgeOptions = {}): void { + setImmediate(() => { + void purgeRtkRawOutput(opts).catch(() => { + /* best-effort */ + }); + }); +} diff --git a/open-sse/services/compression/lite.ts b/open-sse/services/compression/lite.ts index 4be635da0e..ade5858352 100644 --- a/open-sse/services/compression/lite.ts +++ b/open-sse/services/compression/lite.ts @@ -20,38 +20,9 @@ interface LiteCompressionOptions { compressToolResults?: boolean; } -function trimTrailingHorizontalWhitespace(line: string): string { - let end = line.length; - while (end > 0) { - const code = line.charCodeAt(end - 1); - if (code !== 32 && code !== 9) break; - end--; - } - return end === line.length ? line : line.slice(0, end); -} - -function collapseNewlineRuns(content: string): string { - let normalized = ""; - let newlineRun = 0; - - for (const char of content) { - if (char === "\n") { - newlineRun++; - if (newlineRun <= 2) { - normalized += char; - } - continue; - } - - newlineRun = 0; - normalized += char; - } - - return normalized; -} - function normalizeMessageWhitespace(content: string): string { - return collapseNewlineRuns(content).split("\n").map(trimTrailingHorizontalWhitespace).join("\n"); + if (!content) return ""; + return content.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+$/gm, ""); } // Vision detection is centralized in `@/shared/constants/visionModels` (#4072) so diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index f35dfac37f..70d457aa91 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -103,6 +103,10 @@ export interface RtkConfig { trustProjectFilters: boolean; rawOutputRetention: RtkRawOutputRetention; rawOutputMaxBytes: number; + /** #10659: cap on total raw-output files before the oldest are purged. Default: 100_000. */ + rawOutputMaxFiles?: number; + /** #10659: max age (days) of retained raw-output files. Default: 30. */ + rawOutputMaxAgeDays?: number; /** R5: enable grouping of near-equivalent consecutive lines. Default: false. */ enableGrouping?: boolean; /** R5: minimum consecutive similar-line run to trigger grouping. Default: 3. */ @@ -473,6 +477,8 @@ export const DEFAULT_RTK_CONFIG: RtkConfig = { trustProjectFilters: false, rawOutputRetention: "never", rawOutputMaxBytes: 1_048_576, + rawOutputMaxFiles: 100_000, + rawOutputMaxAgeDays: 30, enableGrouping: false, groupingThreshold: 3, stripCodeComments: false, diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index 919c0d1e66..a2d678f107 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -75,6 +75,13 @@ const CHARS_PER_TOKEN = 4; // see #8368 research notes. const IMAGE_TOKEN_ESTIMATE = 1200; +// #10840: same budget, deliberately. The Gemini `inlineData` matcher does not +// inspect media type, so a base64 PDF arriving in that shape is ALREADY measured +// at IMAGE_TOKEN_ESTIMATE today. Reusing it makes the OpenAI `file` and Claude +// `document` shapes agree with the estimate the same document already receives, +// rather than introducing a second constant with no grounding in this repo. +const DOCUMENT_TOKEN_ESTIMATE = IMAGE_TOKEN_ESTIMATE; + // Matches inline base64 data URLs, e.g. "data:image/png;base64,AAAA...". // Deliberately scoped to `data:image/...;base64,` so remote (http/https) // URLs and generic long base64 text strings stay on the text-estimation path. @@ -117,6 +124,45 @@ function matchesGeminiInlineDataShape(node: Record): boolean { return typeof (inlineData as Record).data === "string"; } +// Any inline base64 data URL, regardless of media type — file parts legitimately +// carry application/pdf, text/csv, and so on. +const INLINE_BASE64_DATA_RE = /^data:[^;,]+;base64,/; + +function isInlineBase64DataUrl(value: unknown): boolean { + return typeof value === "string" && INLINE_BASE64_DATA_RE.test(value); +} + +// OpenAI chat.completions: { type: 'file', file: { file_data | data: 'data:...;base64,...' } } +// Responses API: { type: 'input_file', file_data: 'data:...;base64,...' } +// Shapes mirror services/ccOpenAiMediaBlocks.ts::convertOpenAiMediaBlock. +function matchesOpenAIFileShape(node: Record): boolean { + if (node.type === "input_file") return isInlineBase64DataUrl(node.file_data); + if (node.type !== "file") return false; + const file = node.file; + if (!file || typeof file !== "object") return false; + const f = file as Record; + return isInlineBase64DataUrl(f.file_data) || isInlineBase64DataUrl(f.data); +} + +// Claude: { type: 'document', source: { type: 'base64', data: '...' } } +function matchesClaudeDocumentShape(node: Record): boolean { + if (node.type !== "document") return false; + const source = node.source; + if (!source || typeof source !== "object") return false; + const src = source as Record; + return src.type === "base64" && typeof src.data === "string"; +} + +/** + * Detect inline-base64 *document* blocks (#10840). Deliberately separate from + * {@link isInlineBase64ImageBlock}: that predicate also drives + * pruneOlderInlineImages, and dropping a user's attached PDF is not the same + * decision as dropping an old screenshot. This one only feeds token estimation. + */ +export function isInlineBase64DocumentBlock(node: Record): boolean { + return matchesOpenAIFileShape(node) || matchesClaudeDocumentShape(node); +} + /** * Detect the 5 documented inline-base64 image content-block shapes (see the * shape-specific matchers above). @@ -224,6 +270,10 @@ function extractImageTokens(node: unknown, seen: Set): { node: unknown; tokens += IMAGE_TOKEN_ESTIMATE; return { __image_token_estimate__: IMAGE_TOKEN_ESTIMATE }; } + if (record && isInlineBase64DocumentBlock(record)) { + tokens += DOCUMENT_TOKEN_ESTIMATE; + return { __document_token_estimate__: DOCUMENT_TOKEN_ESTIMATE }; + } const result = extractImageTokens(item, seen); tokens += result.tokens; return result.node; @@ -238,6 +288,12 @@ function extractImageTokens(node: unknown, seen: Set): { node: unknown; tokens: IMAGE_TOKEN_ESTIMATE, }; } + if (isInlineBase64DocumentBlock(record)) { + return { + node: { __document_token_estimate__: DOCUMENT_TOKEN_ESTIMATE }, + tokens: DOCUMENT_TOKEN_ESTIMATE, + }; + } let tokens = 0; const out: Record = {}; @@ -281,6 +337,29 @@ export function getTokenLimit( return resolveTokenLimit(provider, model, snapshot).limit; } +/** + * Context window from a known source only: an explicit canonical window, or a + * provider/model-specific `resolveTokenLimit` result. The generic 128000 + * catch-all (`specific: false`) is treated as unknown so combo `min()` does + * not advertise 128k when every real member is larger (#10734). + */ +export function getSourcedTokenLimit( + provider: string, + model: string | null = null, + canonicalWindow?: unknown, + snapshot?: ModelCapabilityResolutionSnapshot | null +): number | undefined { + if ( + typeof canonicalWindow === "number" && + Number.isFinite(canonicalWindow) && + canonicalWindow > 0 + ) { + return canonicalWindow; + } + const resolved = resolveTokenLimit(provider, model, snapshot); + return resolved.specific ? resolved.limit : undefined; +} + /** * Resolve a combo target's token limit without crashing when `parseModel(modelStr)` * returns `provider: null` (model id with no `provider/` prefix). @@ -315,7 +394,7 @@ export function getComboTargetTokenLimit(options: { * name heuristic, curated per-provider default) or only from the generic * catch-all default. */ -function resolveTokenLimit( +export function resolveTokenLimit( provider: string, model: string | null = null, snapshot?: ModelCapabilityResolutionSnapshot | null diff --git a/open-sse/services/conversationTracker.ts b/open-sse/services/conversationTracker.ts index fadc726fc4..e24489a000 100644 --- a/open-sse/services/conversationTracker.ts +++ b/open-sse/services/conversationTracker.ts @@ -265,8 +265,24 @@ export function hashTurnContent(turn: CanonicalTurn): string { return hashHex(`${turn.role} ${turn.text}`); } +/** + * Upper bound on chain-node id computations a single resolveConversationId + * call may spend across ALL fingerprint candidates, start turns and duplicate + * anchors (#7847-class stall). Real coding-agent histories combine 1000+ + * turns with heavily duplicated tool outputs, so the (start × anchor × walk) + * product is unbounded without a cap: measured on production traffic the + * walk blocked the request path for 10-130 s before this bound existed. + * Exhausting the budget degrades exactly like a no-match — the request mints + * a new conversation — never a wrong attachment. + */ +export const DEFAULT_RECONNECT_MAX_STEPS = 150_000; + +function chainNodeIdFromHash(parentId: string, turnHash: string): string { + return hashHex(`${parentId} ${turnHash}`); +} + function chainNodeId(parentId: string, turn: CanonicalTurn): string { - return hashHex(`${parentId} ${hashTurnContent(turn)}`); + return chainNodeIdFromHash(parentId, hashTurnContent(turn)); } interface NewTurnNode { @@ -281,27 +297,28 @@ function buildNewNodes( turns: CanonicalTurn[], fromIndex: number, chainAnchor: string, - rootId: string + rootId: string, + turnHashes?: string[] ): NewTurnNode[] { const nodes: NewTurnNode[] = []; let parent = chainAnchor; for (let i = fromIndex; i < turns.length; i++) { - const turn = turns[i]; - const nodeId = chainNodeId(parent, turn); + const turnHash = turnHashes ? turnHashes[i] : hashTurnContent(turns[i]); + const nodeId = chainNodeIdFromHash(parent, turnHash); nodes.push({ id: nodeId, // The root anchor is a hashing seed, not a real node — the first turn // of a tree has no parent turn. parentId: parent === rootId ? null : parent, - role: turn.role, - contentHash: hashTurnContent(turn), + role: turns[i].role, + contentHash: turnHash, }); parent = nodeId; } return nodes; } -interface ReconnectMatch { +export interface ReconnectMatch { /** Index into `chainTurns` where the reconnection was found (turns before * this index were dropped from the chain's view — a compacted summary the * client sent instead of resending them verbatim — and are not inserted @@ -318,6 +335,30 @@ interface ReconnectMatch { anchorHasChild: boolean; } +/** + * Mutable work budget shared across a single resolveConversationId call's + * candidate walks. `stepsLeft` counts DOWN one chain-node id computation per + * step; `stepsUsed` reports total spend for observability/tests. + */ +export interface ReconnectWalkBudget { + stepsLeft: number; + stepsUsed: number; +} + +export interface FindReconnectMatchOptions { + /** Memoized `hashTurnContent` per chain turn, computed once per request. */ + turnHashes?: string[]; + /** Per-call cap; omit to use a fresh DEFAULT_RECONNECT_MAX_STEPS budget. */ + maxSteps?: number; + /** Shared budget across several calls (resolveConversationId's candidate loop). */ + budget?: ReconnectWalkBudget; +} + +export interface FindReconnectMatchResult { + match: ReconnectMatch | null; + stepsUsed: number; +} + /** * Find where `chainTurns` reconnects to an existing chain, trying the * leftmost turn first (so a still-fully-present prefix — the common case — @@ -345,21 +386,44 @@ interface ReconnectMatch { * candidate anchor for every prefix start is now tried, and the one that * verifiably extends furthest into the actual request wins — the only * reliable signal of genuine continuation when content repeats. + * + * #7847-class stall fix: the (start × anchor × walk) product over a long + * duplicate-heavy history is bounded by a step budget (`maxSteps` / + * `DEFAULT_RECONNECT_MAX_STEPS`), and turn content hashes are memoized via + * `turnHashes` so each step hashes ~130 fixed-size bytes instead of re-hashing + * the turn's full text. Budget exhaustion returns the best match verified so + * far (possibly none) — degrading to "new conversation" downstream, never an + * unverified attachment. */ -function findReconnectMatch( +export function findReconnectMatch( chainTurns: CanonicalTurn[], - index: ConversationTurnIndex -): ReconnectMatch | null { + index: ConversationTurnIndex, + options: FindReconnectMatchOptions = {} +): FindReconnectMatchResult { + const turnHashes = options.turnHashes ?? chainTurns.map(hashTurnContent); + const budget: ReconnectWalkBudget = options.budget ?? { + stepsLeft: options.maxSteps ?? DEFAULT_RECONNECT_MAX_STEPS, + stepsUsed: 0, + }; let best: ReconnectMatch | null = null; for (let s = 0; s < chainTurns.length; s++) { - const anchors = index.byContentHash.get(hashTurnContent(chainTurns[s])); + if (budget.stepsLeft <= 0) break; + const anchors = index.byContentHash.get(turnHashes[s]); if (!anchors) continue; for (const anchorNodeId of anchors) { + if (budget.stepsLeft <= 0) break; + // The anchor claim itself costs one step: with no budget left to claim + // even the hash-bucket anchor, the walker must report no match rather + // than an unverified one. + budget.stepsLeft -= 1; + budget.stepsUsed += 1; let parent = anchorNodeId; let matchEndIndex = s + 1; - for (let i = s + 1; i < chainTurns.length; i++) { - const nodeId = chainNodeId(parent, chainTurns[i]); + while (matchEndIndex < chainTurns.length && budget.stepsLeft > 0) { + budget.stepsLeft -= 1; + budget.stepsUsed += 1; + const nodeId = chainNodeIdFromHash(parent, turnHashes[matchEndIndex]); if (!index.nodeIds.has(nodeId)) break; parent = nodeId; matchEndIndex++; @@ -380,10 +444,12 @@ function findReconnectMatch( best = { startIndex: s, matchEndIndex, anchorNodeId: parent, anchorHasChild }; } // Can't do better than matching every turn through to the end. - if (matchEndIndex === chainTurns.length) return best; + if (best && best.matchEndIndex === chainTurns.length) { + return { match: best, stepsUsed: budget.stepsUsed }; + } } } - return best; + return { match: best, stepsUsed: budget.stepsUsed }; } // ── Orchestration ───────────────────────────────────────────────────────── @@ -417,13 +483,23 @@ export async function resolveConversationId( // it sits) fail to match on every request — reintroducing the exact // always-new-conversation bug this chain design exists to fix. const chainTurns = turns.filter((t) => t.role !== "system"); + // #7847-class stall fix: hash each turn's content exactly once per request + // and bound the reconnect walk across ALL candidates with one shared budget + // — previously every (start × anchor × walk-step) re-hashed the turn's full + // text twice, which on long duplicate-heavy coding-agent histories blocked + // the pre-routing request path for 10-130 s. + const turnHashes = chainTurns.map(hashTurnContent); + const walkBudget: ReconnectWalkBudget = { stepsLeft: DEFAULT_RECONNECT_MAX_STEPS, stepsUsed: 0 }; const candidates = findAgenticConversationsByFingerprint(fingerprintHash); for (const candidate of candidates) { const index = getConversationTurnIndex(candidate.id); if (index.nodeIds.size === 0) continue; - const match = findReconnectMatch(chainTurns, index); + const { match } = findReconnectMatch(chainTurns, index, { + turnHashes, + budget: walkBudget, + }); // No match anywhere in the chain means this candidate isn't actually // this conversation's lineage — it only shares the coarse fingerprint // bucket (apiKeyId/model/toolNames), which real traffic proves is not @@ -451,7 +527,8 @@ export async function resolveConversationId( chainTurns, match.matchEndIndex, match.anchorNodeId, - candidate.id + candidate.id, + turnHashes ); insertConversationTurnNodes(candidate.id, input.correlationId, newNodes); updateAgenticConversation(candidate.id, { turnCount: candidate.turnCount + 1 }); @@ -479,6 +556,10 @@ export async function resolveConversationId( const id = `conv_${randomUUID()}`; createAgenticConversation({ id, apiKeyId: input.apiKeyId, fingerprintHash }); - insertConversationTurnNodes(id, input.correlationId, buildNewNodes(chainTurns, 0, id, id)); + insertConversationTurnNodes( + id, + input.correlationId, + buildNewNodes(chainTurns, 0, id, id, turnHashes) + ); return { conversationId: id, isNewConversation: true }; } diff --git a/open-sse/services/cursorApiKeyAuth.ts b/open-sse/services/cursorApiKeyAuth.ts new file mode 100644 index 0000000000..60c3385783 --- /dev/null +++ b/open-sse/services/cursorApiKeyAuth.ts @@ -0,0 +1,198 @@ +/** + * Cursor user API keys (`crsr_…`, minted at cursor.com/dashboard/api) are not + * accepted as a Bearer credential by api2.cursor.sh (401). cursor-agent first + * POSTs the key to `/auth/exchange_user_api_key` and receives a 1-hour session + * JWT (`type: "api_key_token"`); the accompanying refreshToken carries the same + * `exp`, so "refresh" simply means re-exchanging the key. This module owns that + * exchange plus a per-key cache so the executor and the Cursor CLI passthrough + * share one live session token per key. + */ + +import crypto from "node:crypto"; + +export const CURSOR_API_BASE_URL = "https://api2.cursor.sh"; +export const CURSOR_API_KEY_PREFIX = "crsr_"; +export const CURSOR_API_KEY_EXCHANGE_PATH = "/auth/exchange_user_api_key"; +export const CURSOR_API_KEY_EXCHANGE_URL = `${CURSOR_API_BASE_URL}${CURSOR_API_KEY_EXCHANGE_PATH}`; + +const REFRESH_SKEW_MS = 5 * 60 * 1000; +const FALLBACK_TTL_MS = 55 * 60 * 1000; +const EXCHANGE_TIMEOUT_MS = 15_000; + +export type CursorSessionToken = { + accessToken: string; + refreshToken: string | null; + expiresAt: number; +}; + +export class CursorApiKeyExchangeError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "CursorApiKeyExchangeError"; + this.status = status; + } +} + +type FetchLike = (input: string, init?: RequestInit) => Promise; + +export type CursorApiKeyAuthOptions = { + fetchImpl?: FetchLike; + signal?: AbortSignal; + now?: () => number; +}; + +const sessionCache = new Map(); +const inflightExchanges = new Map>(); + +export function isCursorApiKey(value: unknown): value is string { + return typeof value === "string" && value.startsWith(CURSOR_API_KEY_PREFIX); +} + +function cacheKeyFor(apiKey: string): string { + return crypto.createHash("sha256").update(apiKey).digest("hex"); +} + +export function readJwtExpiryMs(token: string): number | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as { + exp?: unknown; + }; + return typeof payload.exp === "number" && Number.isFinite(payload.exp) + ? payload.exp * 1000 + : null; + } catch { + return null; + } +} + +function parseExchangeBody(raw: string): { accessToken: string; refreshToken: string | null } { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new CursorApiKeyExchangeError("Cursor API key exchange returned a non-JSON body", 502); + } + if (!parsed || typeof parsed !== "object") { + throw new CursorApiKeyExchangeError("Cursor API key exchange returned an empty body", 502); + } + const { accessToken, refreshToken } = parsed as { accessToken?: unknown; refreshToken?: unknown }; + if (typeof accessToken !== "string" || accessToken.length === 0) { + throw new CursorApiKeyExchangeError("Cursor API key exchange returned no accessToken", 502); + } + return { + accessToken, + refreshToken: typeof refreshToken === "string" && refreshToken.length > 0 ? refreshToken : null, + }; +} + +export async function exchangeCursorApiKey( + apiKey: string, + options: CursorApiKeyAuthOptions = {} +): Promise { + if (!isCursorApiKey(apiKey)) { + throw new CursorApiKeyExchangeError( + `Cursor API keys start with "${CURSOR_API_KEY_PREFIX}"`, + 400 + ); + } + const fetchImpl = options.fetchImpl ?? fetch; + const now = options.now ?? Date.now; + const signal = options.signal ?? AbortSignal.timeout(EXCHANGE_TIMEOUT_MS); + + let response: Response; + try { + response = await fetchImpl(CURSOR_API_KEY_EXCHANGE_URL, { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + accept: "application/json", + }, + body: "{}", + signal, + }); + } catch { + throw new CursorApiKeyExchangeError("Cursor API key exchange request failed", 502); + } + + if (response.status === 401 || response.status === 403) { + throw new CursorApiKeyExchangeError("Cursor rejected the API key", 401); + } + if (!response.ok) { + throw new CursorApiKeyExchangeError( + `Cursor API key exchange failed with HTTP ${response.status}`, + response.status >= 500 ? 502 : response.status + ); + } + + const { accessToken, refreshToken } = parseExchangeBody(await response.text()); + const expiresAt = readJwtExpiryMs(accessToken) ?? now() + FALLBACK_TTL_MS; + return { accessToken, refreshToken, expiresAt }; +} + +function isFresh(token: CursorSessionToken, nowMs: number): boolean { + return token.expiresAt - REFRESH_SKEW_MS > nowMs; +} + +export async function resolveCursorSessionToken( + apiKey: string, + options: CursorApiKeyAuthOptions = {} +): Promise { + const now = options.now ?? Date.now; + const key = cacheKeyFor(apiKey); + const cached = sessionCache.get(key); + if (cached && isFresh(cached, now())) return cached; + + const pending = inflightExchanges.get(key); + if (pending) return pending; + + const exchange = exchangeCursorApiKey(apiKey, options) + .then((token) => { + sessionCache.set(key, token); + return token; + }) + .finally(() => { + inflightExchanges.delete(key); + }); + inflightExchanges.set(key, exchange); + return exchange; +} + +export function invalidateCursorSessionToken(apiKey: string): void { + sessionCache.delete(cacheKeyFor(apiKey)); +} + +export function stripCursorOAuthTokenPrefix(accessToken: string): string { + return accessToken.includes("::") ? accessToken.split("::")[1] : accessToken; +} + +export type CursorBearerCredentials = { + apiKey?: string | null; + accessToken?: string | null; +}; + +export async function resolveCursorBearerToken( + credentials: CursorBearerCredentials, + options: CursorApiKeyAuthOptions = {} +): Promise { + if (isCursorApiKey(credentials.apiKey)) { + const session = await resolveCursorSessionToken(credentials.apiKey, options); + return session.accessToken; + } + if (typeof credentials.accessToken === "string" && credentials.accessToken.length > 0) { + return stripCursorOAuthTokenPrefix(credentials.accessToken); + } + throw new CursorApiKeyExchangeError( + "Cursor connection has neither an API key nor a session token", + 401 + ); +} + +export function __resetCursorApiKeyAuthForTest(): void { + sessionCache.clear(); + inflightExchanges.clear(); +} diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 43c1aa3079..735f44eb08 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -80,6 +80,10 @@ export const PROVIDER_ERROR_TYPES = { MODEL_NOT_FOUND: "model_not_found", FINGERPRINT_REJECTION: "fingerprint_rejection", GEO_BLOCKED: "geo_blocked", + // Antigravity BYOP fast-fail (executor 422, code gcp_project_required): the + // Google account must Bring Its Own GCP Project. Account-specific and + // fixable by entering a Project ID — never a model lockout and never a ban. + GCP_PROJECT_REQUIRED: "gcp_project_required", }; export const CONTEXT_OVERFLOW_SIGNALS = [ @@ -385,6 +389,15 @@ export function classifyProviderError( } if (statusCode >= 500) return PROVIDER_ERROR_TYPES.SERVER_ERROR; + // Antigravity BYOP fast-fail (executor emits 422 with code + // gcp_project_required when the Google account must Bring Its Own GCP + // Project). Account-specific and fixable by entering a Project ID in the + // dashboard — classified separately so chatCore rotates to sibling accounts + // and excludes the connection instead of locking the model or banning it. + if (statusCode === 422 && bodyStr.includes("gcp_project_required")) { + return PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED; + } + if (statusCode === 400) { if (isContextOverflow(bodyStr)) { return PROVIDER_ERROR_TYPES.CONTEXT_OVERFLOW; diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 3582eec9c0..a7bdeb2b09 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -9,10 +9,7 @@ */ import Bottleneck from "bottleneck"; -import { - applyBottleneckDoExpirePatch, - applyBottleneckHeartbeatPatch, -} from "./bottleneckPatch.ts"; +import { applyBottleneckDoExpirePatch, applyBottleneckHeartbeatPatch } from "./bottleneckPatch.ts"; import { parseRetryAfterFromBody } from "./accountFallback.ts"; import { getAntigravityQuotaFamily } from "./antigravityQuotaFamily.ts"; import { getProviderCategory } from "../config/providerRegistry.ts"; @@ -550,12 +547,7 @@ export async function withRateLimit(provider, connectionId, model, fn, signal = // Proactive sliding-window fallback for header-less providers with a declared cap // (Fase 8.2). No-op unless PROVIDER_DEFAULT_RATE_LIMITS has an entry for `provider`. const maxWaitMs = resolveRequestQueueMaxWaitMs(provider); - await awaitProviderDefaultSlot( - provider, - connectionId, - signal, - maxWaitMs - ); + await awaitProviderDefaultSlot(provider, connectionId, signal, maxWaitMs); const limiter = getLimiter(provider, connectionId, model); // Bottleneck's `expiration` starts only after a job leaves QUEUED. The @@ -607,7 +599,14 @@ export async function withRateLimit(provider, connectionId, model, fn, signal = } try { - return await Promise.race([limiter.schedule(scheduleOpts, fn), abortPromise]); + // Race the work against the abort signal. When abort wins, fn is still + // running inside Bottleneck's limiter — its eventual rejection must not + // surface as an unhandledRejection. The .catch(noop) silences only the + // orphaned branch; the real rejection comes from abortPromise. + const scheduled = limiter.schedule(scheduleOpts, fn); + scheduled.catch(() => {}); // prevent unhandledRejection when abort wins + abortPromise.catch(() => {}); // prevent unhandledRejection when scheduled wins + return await Promise.race([scheduled, abortPromise]); } finally { if (abortListener) { signal.removeEventListener("abort", abortListener); diff --git a/open-sse/services/reasoningCache.ts b/open-sse/services/reasoningCache.ts index bdf870e3fb..23fcf09364 100644 --- a/open-sse/services/reasoningCache.ts +++ b/open-sse/services/reasoningCache.ts @@ -573,16 +573,16 @@ export function cleanupReasoningCache(): number { // ──────────────── Auto-start periodic cleanup ──────────────── // -// server-init.ts was supposed to start the cleanup job, but that module is -// never imported anywhere (it is stranded/dead code). As a result, the -// reasoning_cache SQLite table accumulates expired entries indefinitely. +// server-init.ts was supposed to start the cleanup job, but that module was +// never imported anywhere (it was stranded dead code, since removed). As a +// result, the reasoning_cache SQLite table accumulates expired entries +// indefinitely. // // Fix: start the periodic cleanup directly from this module so it runs // regardless of how the server boots. On first import we run one // immediate sweep, then schedule a 30-minute interval. // -// See: src/lib/jobs/reasoningCacheCleanupJob.ts (the original job module, -// which also remains valid if server-init.ts ever gets wired in). +// See: src/lib/jobs/reasoningCacheCleanupJob.ts (the original job module). const DEFAULT_CLEANUP_INTERVAL_MS = 30 * 60 * 1000; // 30 min diff --git a/open-sse/services/reasoningInputPolicy.ts b/open-sse/services/reasoningInputPolicy.ts new file mode 100644 index 0000000000..71a283a706 --- /dev/null +++ b/open-sse/services/reasoningInputPolicy.ts @@ -0,0 +1,339 @@ +import { REGISTRY } from "../config/providerRegistry.ts"; +import type { ReasoningTransport } from "../config/providerRegistry.ts"; + +type JsonRecord = Record; + +const REASONING_TRANSPORTS = new Map(); +for (const [id, entry] of Object.entries(REGISTRY)) { + if (!entry.reasoningTransport) continue; + REASONING_TRANSPORTS.set(id.toLowerCase(), entry.reasoningTransport); + if (entry.alias) { + REASONING_TRANSPORTS.set(entry.alias.toLowerCase(), entry.reasoningTransport); + } +} + +const CHAT_PLAINTEXT_REASONING_FIELDS = [ + "reasoning_content", + "reasoning", + "reasoning_text", + "thinking", + "thought", +] as const; + +export type ReasoningInputFormat = "chat" | "responses"; + +export interface ReasoningStateInspection { + hasPlaintext: boolean; + hasOpaque: boolean; +} + +export interface ReasoningInputPolicyOptions { + provider?: string | null; + preserveEncryptedReasoning?: boolean; + onIncompatibleReasoning?: "reject" | "drop"; +} + +export interface ReasoningInputPolicyResult { + incompatibleReasoning: boolean; +} + +export function resolveReasoningTransport( + provider: string | null | undefined, + preserveEncryptedReasoning = false +): ReasoningTransport { + const normalized = typeof provider === "string" ? provider.trim().toLowerCase() : ""; + const transport = REASONING_TRANSPORTS.get(normalized); + return transport ?? (preserveEncryptedReasoning ? "opaque" : "plaintext"); +} + +export function createReasoningTransportIncompatibleError(): Error & { + statusCode: number; + errorType: string; +} { + const error = new Error( + "Reasoning continuation is not compatible with the selected target" + ) as Error & { statusCode: number; errorType: string }; + error.statusCode = 400; + error.errorType = "reasoning_transport_incompatible"; + return error; +} + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function isNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function isSummaryDetail(record: JsonRecord): boolean { + const type = typeof record.type === "string" ? record.type.toLowerCase() : ""; + return ( + type.includes("summary") || record.summary !== undefined || record.summary_text !== undefined + ); +} + +function hasPlaintextReasoning(record: JsonRecord): boolean { + return ( + Array.isArray(record.content) && + record.content.some((part) => { + const value = asRecord(part); + return value?.type === "reasoning_text" && isNonEmptyString(value.text); + }) + ); +} + +function hasChatPlaintextReasoning(record: JsonRecord): boolean { + if (CHAT_PLAINTEXT_REASONING_FIELDS.some((field) => isNonEmptyString(record[field]))) { + return true; + } + if (!Array.isArray(record.reasoning_details)) return false; + return record.reasoning_details.some((detail) => { + const value = asRecord(detail); + return Boolean( + value && + !isSummaryDetail(value) && + (isNonEmptyString(value.text) || isNonEmptyString(value.content)) + ); + }); +} + +/** + * Returns only provider-authentic plaintext continuation state. Display summaries + * are excluded, and a record carrying opaque state is never cross-converted. + */ +export function extractReplayableResponsesReasoningText(value: unknown): string { + const record = asRecord(value); + if (!record || record.type !== "reasoning" || hasOpaqueReasoningState(record)) return ""; + if (!Array.isArray(record.content)) return ""; + + return record.content + .map((part) => { + const content = asRecord(part); + return content?.type === "reasoning_text" && typeof content.text === "string" + ? content.text + : ""; + }) + .filter((text) => text.trim().length > 0) + .join("\n\n"); +} + +export function hasOpaqueReasoningState(record: JsonRecord): boolean { + return ( + isNonEmptyString(record.encrypted_content) || + record.signature !== undefined || + record.format !== undefined + ); +} + +function hasOpaqueReasoningDetail(value: unknown): boolean { + const record = asRecord(value); + if (!record) return false; + const type = typeof record.type === "string" ? record.type.toLowerCase() : ""; + return ( + hasOpaqueReasoningState(record) || + ((type.includes("encrypted") || type.includes("opaque")) && isNonEmptyString(record.data)) + ); +} + +function hasChatOpaqueReasoning(record: JsonRecord): boolean { + return ( + hasOpaqueReasoningState(record) || + (Array.isArray(record.reasoning_details) && + record.reasoning_details.some(hasOpaqueReasoningDetail)) + ); +} + +export function inspectChatReasoning(messages: unknown): ReasoningStateInspection { + const inspection: ReasoningStateInspection = { hasPlaintext: false, hasOpaque: false }; + if (!Array.isArray(messages)) return inspection; + + for (const message of messages) { + const record = asRecord(message); + if (!record || record.role !== "assistant") continue; + inspection.hasPlaintext ||= hasChatPlaintextReasoning(record); + inspection.hasOpaque ||= hasChatOpaqueReasoning(record); + if (inspection.hasPlaintext && inspection.hasOpaque) break; + } + return inspection; +} + +export function inspectResponsesReasoning(input: unknown): ReasoningStateInspection { + const inspection: ReasoningStateInspection = { hasPlaintext: false, hasOpaque: false }; + if (!Array.isArray(input)) return inspection; + + for (const item of input) { + const record = asRecord(item); + if (!record || record.type !== "reasoning") continue; + inspection.hasPlaintext ||= hasPlaintextReasoning(record); + inspection.hasOpaque ||= hasOpaqueReasoningState(record); + if (inspection.hasPlaintext && inspection.hasOpaque) break; + } + return inspection; +} + +function isReasoningCompatible( + inspection: ReasoningStateInspection, + transport: ReasoningTransport +): boolean { + if (!inspection.hasPlaintext && !inspection.hasOpaque) return true; + if (transport === "plaintext") return !inspection.hasOpaque; + if (transport === "opaque") return !inspection.hasPlaintext; + return false; +} + +function stripOpaqueFields(record: JsonRecord): void { + delete record.encrypted_content; + delete record.signature; + delete record.format; + delete record.data; +} + +function stripChatReasoningDetails(details: unknown[], transport: ReasoningTransport): unknown[] { + return details.flatMap((detail) => { + const record = asRecord(detail); + if (!record) return [detail]; + + const plaintext = + !isSummaryDetail(record) && + (isNonEmptyString(record.text) || isNonEmptyString(record.content)); + const opaque = hasOpaqueReasoningDetail(record); + if ((!plaintext || transport === "plaintext") && (!opaque || transport === "opaque")) { + return [detail]; + } + + const next = { ...record }; + if (plaintext && transport !== "plaintext") { + delete next.text; + delete next.content; + } + if (opaque && transport !== "opaque") stripOpaqueFields(next); + const remainingKeys = Object.keys(next).filter((key) => key !== "type"); + return remainingKeys.length > 0 ? [next] : []; + }); +} + +function dropIncompatibleChatReasoning( + messages: unknown[], + transport: ReasoningTransport +): unknown[] { + return messages.map((message) => { + const record = asRecord(message); + if (!record || record.role !== "assistant") return message; + const next = { ...record }; + if (transport !== "plaintext") { + for (const field of CHAT_PLAINTEXT_REASONING_FIELDS) delete next[field]; + } + if (transport !== "opaque") stripOpaqueFields(next); + if (Array.isArray(record.reasoning_details)) { + const details = stripChatReasoningDetails(record.reasoning_details, transport); + if (details.length > 0) next.reasoning_details = details; + else delete next.reasoning_details; + } + return next; + }); +} + +function hasDisplaySummary(record: JsonRecord): boolean { + return record.summary !== undefined || record.summary_text !== undefined; +} + +function dropIncompatibleResponsesReasoning( + record: JsonRecord, + transport: ReasoningTransport +): JsonRecord | null { + const next = { ...record }; + if (transport !== "plaintext" && Array.isArray(record.content)) { + const content = record.content.filter((part) => asRecord(part)?.type !== "reasoning_text"); + if (content.length > 0) next.content = content; + else delete next.content; + } + if (transport !== "opaque") stripOpaqueFields(next); + const stillActive = hasPlaintextReasoning(next) || hasOpaqueReasoningState(next); + return stillActive || hasDisplaySummary(next) ? next : null; +} + +function sanitizeResponsesInput( + input: unknown[], + transport: ReasoningTransport, + dropIncompatible: boolean, + stripOrphanedSummaries: boolean +): unknown[] { + const filtered: unknown[] = []; + for (const item of input) { + if (typeof item === "string") continue; + const record = asRecord(item); + if (!record) { + filtered.push(item); + continue; + } + if (record.type === "item_reference") continue; + + if (record.type === "reasoning") { + const next = dropIncompatible + ? dropIncompatibleResponsesReasoning(record, transport) + : { ...record }; + if (!next) continue; + const hasPlaintext = hasPlaintextReasoning(next); + const hasOpaque = hasOpaqueReasoningState(next); + if (!hasPlaintext && !hasOpaque && (!hasDisplaySummary(next) || stripOrphanedSummaries)) { + continue; + } + if (!hasOpaque && typeof next.id === "string") delete next.id; + filtered.push(next); + continue; + } + + const cloned = { ...record }; + if (typeof cloned.id === "string") delete cloned.id; + filtered.push(cloned); + } + return filtered; +} + +/** + * Applies one protocol-independent compatibility decision before request translation. + * Plaintext is portable by default; opaque state requires an explicit target declaration. + * Display summaries do not affect compatibility; stateless input drops orphan summaries. + */ +export function applyReasoningInputPolicy( + body: Record, + inputFormat: ReasoningInputFormat, + options: ReasoningInputPolicyOptions = {} +): ReasoningInputPolicyResult { + const transport = resolveReasoningTransport(options.provider, options.preserveEncryptedReasoning); + const inspection = + inputFormat === "responses" + ? inspectResponsesReasoning(body.input) + : inspectChatReasoning(body.messages); + const incompatibleReasoning = !isReasoningCompatible(inspection, transport); + + if (incompatibleReasoning && options.onIncompatibleReasoning !== "drop") { + return { incompatibleReasoning: true }; + } + + if (inputFormat === "chat") { + if (incompatibleReasoning && Array.isArray(body.messages)) { + body.messages = dropIncompatibleChatReasoning(body.messages, transport); + } + return { incompatibleReasoning: false }; + } + + if (Array.isArray(body.input) && body.input.length === 0) { + body.input = [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }, + ]; + } + if (!Array.isArray(body.input)) return { incompatibleReasoning: false }; + body.input = sanitizeResponsesInput( + body.input, + transport, + incompatibleReasoning, + body.store === false + ); + return { incompatibleReasoning: false }; +} diff --git a/open-sse/services/responsesInputPolicy.ts b/open-sse/services/responsesInputPolicy.ts deleted file mode 100644 index d80dcc7bec..0000000000 --- a/open-sse/services/responsesInputPolicy.ts +++ /dev/null @@ -1,55 +0,0 @@ -type JsonRecord = Record; - -const SERVER_ITEM_ID_PATTERN = /^(rs|fc|resp|msg)_/; - -/** - * Applies the persistence-independent policy for replayed Responses input items. - * Stored references can only be resolved by the upstream that created them, so - * they are always removed. Self-contained encrypted reasoning is retained only - * when the selected connection explicitly opts in. - */ -export function applyResponsesInputPolicy( - body: Record, - preserveEncryptedReasoning = false -): void { - if (Array.isArray(body.input) && body.input.length === 0) { - body.input = [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "continue" }], - }, - ]; - } - - if (!Array.isArray(body.input)) return; - - body.input = body.input.filter((item) => { - if (typeof item === "string" && SERVER_ITEM_ID_PATTERN.test(item)) { - return false; - } - - const record = - item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null; - if (!record) return true; - - if (record.type === "item_reference") { - return false; - } - - if ( - record.type === "reasoning" && - (!preserveEncryptedReasoning || - typeof record.encrypted_content !== "string" || - record.encrypted_content.trim().length === 0) - ) { - return false; - } - - if (typeof record.id === "string" && SERVER_ITEM_ID_PATTERN.test(record.id)) { - delete record.id; - } - - return true; - }); -} diff --git a/open-sse/services/routing/events.ts b/open-sse/services/routing/events.ts new file mode 100644 index 0000000000..955bbe28e2 --- /dev/null +++ b/open-sse/services/routing/events.ts @@ -0,0 +1,220 @@ +/** + * Routing Events — first-class representation of routing outcomes. + * + * Every request that reaches a provider emits one `RoutingEvent` describing what + * happened: which provider/model was used, under which strategy, with what + * latency/tokens/cost, and whether the outcome was a success, an error, a + * malformed response, a timeout, a rate-limit, or a blocked request. + * + * This is the "feedback foundation": the event is cheap to produce (no I/O in + * the emitting call) and is fanned out synchronously to registered sinks, each + * of which must be O(1)-ish and must never perform synchronous I/O. Sinks can + * then do whatever they need asynchronously — buffer to an OTLP exporter, + * update in-memory quality statistics, keep a bounded ring buffer for + * explainability, etc. + * + * DESIGN NOTE (adapted from the Future-AGI-inspired mission, kept deliberately + * lean): the original proposal was a Rust `RoutingEvent` struct + a + * `RoutingEventSink` trait. This module is the TypeScript equivalent, sized to + * the existing codebase: we already persist rich per-request detail in + * `call_logs` (async) and keep per-combo counters in `comboMetrics.ts`. This + * module adds the *typed, structured, sink-based* outcome channel those systems + * lacked, without duplicating either of them. + * + * SAFETY CONTRACT: an event carries ONLY routing metadata — provider, model, + * strategy, timing, token/cost numbers, an allowlisted outcome, finish reason, + * HTTP status, connection id. Never prompts, request/response bodies, headers, + * credentials, or account ids. + */ + +/** + * Allowlisted routing outcomes. Keeping this an enum-like union prevents freeform + * strings from leaking into telemetry/quality logic and keeps sinks exhaustive. + */ +export const ROUTING_OUTCOMES = [ + "success", + "error", + "malformed", + "timeout", + "rate_limited", + "stream_interrupted", + "guardrail_blocked", + "cancelled", +] as const; + +export type RoutingOutcome = (typeof ROUTING_OUTCOMES)[number]; + +export interface RoutingEvent { + /** Correlation/request id — never a prompt or body. */ + requestId: string; + provider: string; + model: string; + /** Combo strategy (e.g. "auto") or "direct" when not routed through a combo. */ + strategy: string; + latencyMs: number; + /** + * Time-to-first-forwarded-SSE-chunk in ms (NOT token-level TTFT), or null + * for non-streaming requests / when nothing was forwarded. + */ + ttftMs: number | null; + /** + * Mean inter-chunk gap in ms — a chunk-latency proxy for inter-token latency, + * only meaningful for streaming requests. Null otherwise. + */ + itlMs: number | null; + inputTokens: number | null; + outputTokens: number | null; + cost: number | null; + retries: number; + fallbackUsed: boolean; + outcome: RoutingOutcome; + /** Upstream HTTP status; null when the request never reached a provider. */ + status: number | null; + /** finish_reason from the provider response (stop / length / tool_calls / ...). */ + finishReason: string | null; + connectionId: string | null; + ts: number; +} + +/** A sink consumes routing events. Implementations must never do sync I/O. */ +export interface RoutingEventSink { + readonly name: string; + record(event: RoutingEvent): void; +} + +const sinks = new Set(); + +/** + * Register a sink. Returns an unsubscribe function. Registering the same sink + * instance twice is a no-op (Set semantics). + */ +export function registerRoutingEventSink(sink: RoutingEventSink): () => void { + sinks.add(sink); + return () => { + sinks.delete(sink); + }; +} + +/** Test/ops hook: list currently registered sink names. */ +export function listRoutingEventSinks(): string[] { + return Array.from(sinks, (s) => s.name); +} + +/** Test/ops hook: remove every registered sink. */ +export function clearRoutingEventSinks(): void { + sinks.clear(); +} + +/** + * Emit a routing event to every registered sink. Synchronous and allocation- + * friendly so callers can invoke it at the end of the request hot path without + * measurable impact; each sink's `record()` must be cheap (enqueue/buffer only). + * A throwing sink is isolated so one misbehaving sink cannot break the router. + */ +export function dispatchRoutingEvent(event: RoutingEvent): void { + for (const sink of sinks) { + try { + sink.record(event); + } catch { + // Sinks are observability/best-effort — never let one break the data plane. + } + } +} + +/** + * Bounded in-memory ring-buffer sink. Holds the most recent N events for + * explainability/debugging (see GET /api/v1/explain/routing). Insert is O(1); + * no TTL sweep needed because the buffer is size-bounded by construction. + */ +export class MemoryRoutingEventStore implements RoutingEventSink { + readonly name = "memory"; + private buffer: RoutingEvent[] = []; + private cursor = 0; + + constructor(private readonly capacity = 500) {} + + record(event: RoutingEvent): void { + if (this.buffer.length < this.capacity) { + this.buffer.push(event); + } else { + this.buffer[this.cursor] = event; + } + this.cursor = (this.cursor + 1) % this.capacity; + } + + /** Most recent events, newest first, up to `limit`. */ + recent(limit = 50): RoutingEvent[] { + if (this.buffer.length < this.capacity) { + return this.buffer.slice(-limit).reverse(); + } + // Ring is full — walk backwards from the cursor. + const out: RoutingEvent[] = []; + for (let i = 0; i < Math.min(limit, this.buffer.length); i++) { + const idx = (this.cursor - 1 - i + this.buffer.length) % this.buffer.length; + out.push(this.buffer[idx]); + } + return out; + } + + clear(): void { + this.buffer = []; + this.cursor = 0; + } + + get size(): number { + return this.buffer.length; + } +} + +/** Create a well-formed event with defaults for unset observability fields. */ +export function createRoutingEvent(input: { + requestId: string; + provider: string; + model: string; + strategy?: string | null; + latencyMs: number; + ttftMs?: number | null; + itlMs?: number | null; + inputTokens?: number | null; + outputTokens?: number | null; + cost?: number | null; + retries?: number; + fallbackUsed?: boolean; + outcome: RoutingOutcome; + status?: number | null; + finishReason?: string | null; + connectionId?: string | null; + ts?: number; +}): RoutingEvent { + return { + requestId: input.requestId, + provider: input.provider || "unknown", + model: input.model || "unknown", + strategy: input.strategy ?? "direct", + latencyMs: Math.max(0, input.latencyMs || 0), + ttftMs: input.ttftMs ?? null, + itlMs: input.itlMs ?? null, + inputTokens: input.inputTokens ?? null, + outputTokens: input.outputTokens ?? null, + cost: input.cost ?? null, + retries: input.retries ?? 0, + fallbackUsed: input.fallbackUsed ?? false, + outcome: input.outcome, + status: input.status ?? null, + finishReason: input.finishReason ?? null, + connectionId: input.connectionId ?? null, + ts: input.ts ?? Date.now(), + }; +} + +/** + * Classify an upstream HTTP status into a RoutingOutcome. Status 200/201 → success; + * 429 → rate_limited; 408/504 → timeout; 4xx/5xx → error; anything else → error. + */ +export function outcomeFromStatus(status: number | null | undefined): RoutingOutcome { + if (status == null) return "error"; + if (status === 200 || status === 201) return "success"; + if (status === 429) return "rate_limited"; + if (status === 408 || status === 504) return "timeout"; + return "error"; +} diff --git a/open-sse/services/routing/index.ts b/open-sse/services/routing/index.ts new file mode 100644 index 0000000000..1cb3077036 --- /dev/null +++ b/open-sse/services/routing/index.ts @@ -0,0 +1,133 @@ +/** + * Routing feedback foundation — default wiring. + * + * Bootstraps the default routing-event sinks: + * 1. `MemoryRoutingEventStore` — bounded ring buffer for explainability. + * 2. `QualityTracker` consumer — feeds the auto-combo `quality` scoring factor. + * 3. Optional OTel/HTTP exporter — enabled only when an OTLP endpoint is set. + * + * The hot path only calls `emitRoutingEvent()`, which fans out synchronously to + * these cheap in-memory sinks. No synchronous I/O, no external dependencies. + * + * This is the adapter seam Future AGI (or any evaluation backend) can plug into + * later without becoming a dependency: an evaluator would be another + * `RoutingEventSink` (or a consumer of the ring buffer / quality snapshot). + */ + +import { + clearRoutingEventSinks, + dispatchRoutingEvent, + listRoutingEventSinks, + MemoryRoutingEventStore, + registerRoutingEventSink, + type RoutingEvent, + type RoutingEventSink, +} from "./events.ts"; +import { + getProviderQuality, + getQualityScore, + getQualitySnapshot, + recordQualityEvent, + resetQualityTracker, + setSemanticQuality, + type ProviderQuality, +} from "./quality.ts"; +import { isRoutingOtelEnabled, OtlpHttpsEventSink } from "./otel.ts"; + +const memoryStore = new MemoryRoutingEventStore(500); + +// The quality tracker is registered as a sink so it updates inline with the +// event (O(1) math) and the OTel exporter only ever enqueues. +const qualitySink: RoutingEventSink = { + name: "quality", + record(event: RoutingEvent): void { + recordQualityEvent(event); + }, +}; + +let otelSink: OtlpHttpsEventSink | null = null; + +let initialized = false; + +/** Register the default sinks. Idempotent; safe to call multiple times. */ +export function initRoutingObservability(env: NodeJS.ProcessEnv = process.env): { + sinks: string[]; + otelEnabled: boolean; +} { + if (initialized) { + return { sinks: listRoutingSinkNames(), otelEnabled: isRoutingOtelEnabled(env) }; + } + initialized = true; + + registerRoutingEventSink(memoryStore); + registerRoutingEventSink(qualitySink); + + if (isRoutingOtelEnabled(env)) { + const endpoint = (env.OMNIROUTE_OTEL_ENDPOINT ?? env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "").trim(); + otelSink = new OtlpHttpsEventSink({ + endpoint, + serviceName: env.OTEL_SERVICE_NAME ?? "omniroute", + maxBatchSize: 64, + flushIntervalMs: 10_000, + }); + registerRoutingEventSink(otelSink); + } + + return { sinks: listRoutingSinkNames(), otelEnabled: otelSink != null }; +} + +/** Emit a routing event to all registered sinks (fire-and-forget, cheap). */ +export function emitRoutingEvent(event: RoutingEvent): void { + if (!initialized) initRoutingObservability(); + dispatchRoutingEvent(event); +} + +/** Neutral default quality used when a model has no observed events. */ +export function qualityScoreFor(provider: string, model: string): number { + return getQualityScore(provider, model); +} + +/** Full per-provider/model quality view (operational + semantic + confidence). */ +export function providerQualityFor(provider: string, model: string): ProviderQuality { + return getProviderQuality(provider, model); +} + +/** + * Evaluator seam: record a semantic quality score. NEVER call this from the + * request hot path with HTTP-derived signals — semantic quality is reserved for + * actual evaluation (task success, tool-use correctness, groundedness). + */ +export { setSemanticQuality } from "./quality.ts"; + +export function routingQualitySnapshot(limit = 200): ReturnType { + return getQualitySnapshot(limit); +} + +export { classifyQuality, type QualityClassification } from "./quality.ts"; + +export function recentRoutingEvents(limit = 50): RoutingEvent[] { + return memoryStore.recent(limit); +} + +export function routingOtelStats(): { buffered: number; dropped: number } | null { + return otelSink ? otelSink.getStats() : null; +} + +function listRoutingSinkNames(): string[] { + return listRoutingEventSinks(); +} + +/** Test/ops hook: full reset of the routing observability layer. */ +export function resetRoutingObservability(): void { + clearRoutingEventSinks(); + memoryStore.clear(); + resetQualityTracker(); + if (otelSink) { + otelSink.stop(); + otelSink = null; + } + initialized = false; +} + +export type { RoutingEvent, RoutingOutcome, RoutingEventSink } from "./events.ts"; +export { createRoutingEvent, outcomeFromStatus } from "./events.ts"; diff --git a/open-sse/services/routing/otel.ts b/open-sse/services/routing/otel.ts new file mode 100644 index 0000000000..23578011c0 --- /dev/null +++ b/open-sse/services/routing/otel.ts @@ -0,0 +1,227 @@ +/** + * Optional OpenTelemetry / GenAI observability sink. + * + * A `RoutingEventSink` that forwards routing events to an OTLP/HTTP collector as + * GenAI semantic-convention spans (semconvgenai: `gen_ai.provider.name`, + * `gen_ai.request.model`, `gen_ai.operation.name`, `gen_ai.usage.input_tokens`, + * `gen_ai.usage.output_tokens`, etc.). + * + * Deliberately lightweight: + * - No `@opentelemetry/*` SDK dependency. Uses the collector's OTLP/HTTP JSON + * (traces) endpoint via global `fetch`, which is already available and async. + * - `record()` only enqueues into a bounded buffer (O(1), never I/O). A single + * background flush timer drains the buffer asynchronously. Under overload the + * oldest events are dropped (never backpressure the data plane). + * - Disabled unless `OMNIROUTE_OTEL_ENDPOINT` (or `OTEL_EXPORTER_OTLP_ENDPOINT`) + * is set — normal lightweight deployments run with zero OTel code executing. + * - No secrets/prompts are ever serialized; only RoutingEvent metadata. + */ + +export interface OtlpHttpsExporterConfig { + /** Collector base URL, e.g. https://collector:4318 — spans go to /v1/traces. */ + endpoint: string; + /** Export batch size / flush interval. */ + maxBatchSize?: number; + flushIntervalMs?: number; + serviceName?: string; +} + +/** Resolve whether OTLP export is configured. */ +export function isRoutingOtelEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const endpoint = (env.OMNIROUTE_OTEL_ENDPOINT ?? env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "").trim(); + return endpoint.length > 0; +} + +interface OtelSpan { + traceId: string; + spanId: string; + name: string; + kind: number; + startTimeUnixNano: string; + endTimeUnixNano: string; + attributes: Array<{ + key: string; + value: { stringValue?: string; intValue?: string; doubleValue?: number }; + }>; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function randomId(bytes: number): string { + const arr = new Uint8Array(bytes); + // Use crypto.getRandomValues when available (Node ≥ 19 global), else Math.random. + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + crypto.getRandomValues(arr); + } else { + for (let i = 0; i < bytes; i++) arr[i] = Math.floor(Math.random() * 256); + } + return toHex(arr); +} + +export class OtlpHttpsEventSink { + readonly name = "otel"; + private readonly endpoint: string; + private readonly maxBatchSize: number; + private readonly serviceName: string; + private buffer: RoutingEventLike[] = []; + private dropped = 0; + private consecutiveFailures = 0; + private flushedBatches = 0; + private timer: ReturnType | null = null; + private flushing = false; + + constructor(private readonly config: OtlpHttpsExporterConfig) { + this.endpoint = config.endpoint.replace(/\/+$/, "") + "/v1/traces"; + this.maxBatchSize = config.maxBatchSize ?? 64; + this.serviceName = config.serviceName ?? "omniroute"; + this.start(); + } + + /** O(1) enqueue; drops oldest when the buffer is full. Never performs I/O. */ + record(event: RoutingEventLike): void { + if (this.buffer.length >= this.maxBatchSize * 4) { + this.buffer.shift(); + this.dropped += 1; + } + this.buffer.push(event); + } + + getStats(): { + buffered: number; + dropped: number; + consecutiveFailures: number; + flushedBatches: number; + } { + return { + buffered: this.buffer.length, + dropped: this.dropped, + consecutiveFailures: this.consecutiveFailures, + flushedBatches: this.flushedBatches, + }; + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + void this.flush(); + } + + private start(): void { + const intervalMs = this.config.flushIntervalMs ?? 10_000; + this.timer = setInterval(() => void this.flush(), intervalMs); + // Do not keep the process alive just for telemetry. + this.timer.unref?.(); + } + + private async flush(): Promise { + if (this.flushing) return; + if (this.buffer.length === 0) return; + this.flushing = true; + const batch = this.buffer.splice(0, this.maxBatchSize); + try { + const res = await fetch(this.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildOtlpTracesPayload(batch, this.serviceName)), + signal: AbortSignal.timeout(3000), + }); + if (!res.ok) throw new Error(`OTLP collector returned ${res.status}`); + this.consecutiveFailures = 0; + this.flushedBatches += 1; + } catch { + // Telemetry delivery is best-effort. Re-buffer for a retry, but stop after + // MAX_CONSECUTIVE_FAILURES so a permanently-unavailable collector cannot + // grow the buffer without bound. The dropped counter reflects the loss. + this.consecutiveFailures += 1; + if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + this.dropped += batch.length; + } else { + this.buffer.unshift(...batch); + } + } finally { + this.flushing = false; + } + } +} + +/** Drop a batch (and count it) after this many consecutive collector failures. */ +const MAX_CONSECUTIVE_FAILURES = 5; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type RoutingEventLike = any; + +/** + * Build an OTLP/HTTP traces JSON payload with one span per routing event, + * mapped to GenAI semantic conventions. + */ +export function buildOtlpTracesPayload(events: RoutingEventLike[], serviceName: string): unknown { + const resourceSpans = [ + { + resource: { + attributes: [ + { key: "service.name", value: { stringValue: serviceName } }, + { key: "telemetry.sdk.name", value: { stringValue: "omniroute-routing" } }, + ], + }, + scopeSpans: [ + { + scope: { name: "omniroute.routing" }, + spans: events.map(toSpan), + }, + ], + }, + ]; + return { resourceSpans }; +} + +function attr( + key: string, + value: string | number +): { key: string; value: { stringValue?: string; intValue?: string; doubleValue?: number } } { + if (typeof value === "number") { + return Number.isInteger(value) + ? { key, value: { intValue: String(value) } } + : { key, value: { doubleValue: value } }; + } + return { key, value: { stringValue: String(value) } }; +} + +function toSpan(event: RoutingEventLike): OtelSpan { + const traceId = randomId(16); + const spanId = randomId(8); + const startNs = BigInt(event.ts) * 1_000_000n; + const endNs = startNs + BigInt(Math.max(0, event.latencyMs || 0)) * 1_000_000n; + const attributes = [ + attr("gen_ai.provider.name", event.provider), + attr("gen_ai.request.model", event.model), + attr("gen_ai.operation.name", "chat"), + attr("gen_ai.system", event.strategy || "direct"), + attr("gen_ai.usage.input_tokens", event.inputTokens ?? 0), + attr("gen_ai.usage.output_tokens", event.outputTokens ?? 0), + attr("gen_ai.completion.finish_reason", event.finishReason ?? "unknown"), + attr("gen_ai.request.temperature", 0), + attr("omniroute.routing.outcome", event.outcome), + attr("omniroute.routing.status", event.status ?? 0), + attr("omniroute.routing.ttft_ms", event.ttftMs ?? -1), + attr("omniroute.routing.itl_ms", event.itlMs ?? -1), + attr("omniroute.routing.retries", event.retries ?? 0), + attr("omniroute.routing.fallback_used", event.fallbackUsed ? 1 : 0), + attr("gen_ai.client.token.usage.input_tokens", event.inputTokens ?? 0), + attr("gen_ai.client.token.usage.output_tokens", event.outputTokens ?? 0), + ]; + if (event.connectionId) attributes.push(attr("omniroute.connection_id", event.connectionId)); + + return { + traceId, + spanId, + name: `chat ${event.provider}/${event.model}`, + kind: 3, // CLIENT + startTimeUnixNano: startNs.toString(), + endTimeUnixNano: endNs.toString(), + attributes, + }; +} diff --git a/open-sse/services/routing/quality.ts b/open-sse/services/routing/quality.ts new file mode 100644 index 0000000000..b4e06a9ed9 --- /dev/null +++ b/open-sse/services/routing/quality.ts @@ -0,0 +1,313 @@ +/** + * Provider/Model Quality Signal — feedback-driven adaptive routing (v2). + * + * v2 separates two distinct concepts that v1 conflated: + * + * - **Operational quality** — derived from the routing hot path (HTTP status, + * connection failures, 429s, malformed responses, stream interruptions, + * finish_reason anomalies, zero-output successes, latency/TTFT). A request + * returning HTTP 200 is NOT necessarily high quality; operational quality + * only says "the wire behaved." + * - **Semantic quality** — the actual value of the generated output + * (evaluator score, task success, tool-use correctness, factual accuracy). + * This is ONLY ever produced by an external evaluator via + * `setSemanticQuality()`. It is never manufactured from HTTP success. It is + * `null` until an evaluator provides a value. + * + * Confidence / sample awareness (v2): + * - `confidence = clamp01(samples / CONFIDENCE_FULL_SAMPLES)`. + * - The score returned to the scorer is blended toward the neutral midpoint + * (0.5): `score = NEUTRAL + confidence * (operational - NEUTRAL)`. + * - Consequences: a cold provider (0 samples) scores neutral 0.5 — it is not + * unfairly penalized, but it also cannot dominate a provider with thousands + * of solid observations. A provider with 7 lucky successes is pulled toward + * 0.5, so it never dominates purely from optimistic initialization. + * + * This complements the existing resilience stack (circuit breaker, connection + * cooldown, model lockout, health matrix): those handle *availability* (hard + * exclusion); this signal handles *soft adaptive preference*. + * + * Statistics are plain arithmetic (EWMA + small counters), O(1) per event, safe + * under the Node event loop's single thread — no lock-free/atomic trickery. + */ + +/** EWMA smoothing factor (alpha). Lower = slower adaptation. */ +const OPERATIONAL_ALPHA = 0.2; +/** Latency EWMA alpha — slower so transient spikes don't tank quality instantly. */ +const LATENCY_ALPHA = 0.1; +/** Samples at which confidence reaches 1.0 (full confidence). */ +const CONFIDENCE_FULL_SAMPLES = 50; +/** Neutral score used for cold/unknown providers (midpoint, neither boosted nor penalized). */ +const NEUTRAL_SCORE = 0.5; + +interface QualityState { + /** EWMA of the success indicator (1 = good, 0 = bad). */ + successEwma: number; + /** EWMA of latency in ms. */ + latencyEwma: number; + /** EWMA of TTFT in ms (streaming only). */ + ttftEwma: number | null; + /** Total events observed for this (provider, model). */ + samples: number; + /** Count of operational-anomaly events (malformed / empty / length / interrupted). */ + anomalies: number; + /** Rate-limit (429) count — tracked separately for observability. */ + rateLimited: number; + /** Semantic quality [0,1] from an external evaluator, if one has provided it. */ + semantic: number | null; + /** Confidence [0,1] of the semantic score as reported by the evaluator. */ + semanticConfidence: number | null; + lastTs: number; +} + +const states = new Map(); + +function keyOf(provider: string, model: string): string { + return `${provider}/${model}`; +} + +function getOrCreate(key: string): QualityState { + let state = states.get(key); + if (!state) { + state = { + successEwma: 1, + latencyEwma: 0, + ttftEwma: null, + samples: 0, + anomalies: 0, + rateLimited: 0, + semantic: null, + semanticConfidence: null, + lastTs: 0, + }; + states.set(key, state); + } + return state; +} + +function isOperationalAnomaly(event: { + outcome: string; + finishReason: string | null; + outputTokens: number | null | undefined; +}): boolean { + if (event.outcome === "malformed" || event.outcome === "stream_interrupted") return true; + // finish_reason=length → the model ran out of output budget (truncated answer). + if (event.outcome === "success" && event.finishReason === "length") return true; + // A "successful" 200 that produced zero output tokens is an empty/invalid output. + // NOTE: we deliberately do NOT treat a missing finish_reason as an anomaly — + // streaming passthrough frequently has no reconstructed finish_reason, so that + // signal would penalize every legitimately streamed request (pure noise). + if (event.outcome === "success" && event.outputTokens === 0) return true; + return false; +} + +function successIndicator(event: { outcome: string; status: number | null }): number { + if (event.outcome === "success") return 1; + // 429 is a transient signal, not a quality failure — treat as neutral-positive. + if (event.outcome === "rate_limited" || event.status === 429) return 0.5; + return 0; +} + +/** Record one operational routing event into the quality estimate. O(1). */ +export function recordQualityEvent(event: { + provider: string; + model: string; + outcome: string; + status: number | null; + latencyMs: number; + ttftMs?: number | null; + finishReason?: string | null; + outputTokens?: number | null; + ts?: number; +}): void { + const key = keyOf(event.provider || "unknown", event.model || "unknown"); + const state = getOrCreate(key); + + state.samples += 1; + if ( + isOperationalAnomaly({ + outcome: event.outcome, + finishReason: event.finishReason ?? null, + outputTokens: event.outputTokens ?? undefined, + }) + ) { + state.anomalies += 1; + } + if (event.outcome === "rate_limited" || event.status === 429) state.rateLimited += 1; + + const indicator = successIndicator({ outcome: event.outcome, status: event.status }); + // First sample seeds the EWMA directly (no lag toward a default). + state.successEwma = + state.samples === 1 + ? indicator + : state.successEwma + OPERATIONAL_ALPHA * (indicator - state.successEwma); + + const latency = Number.isFinite(event.latencyMs) && event.latencyMs >= 0 ? event.latencyMs : 0; + state.latencyEwma = + state.samples === 1 + ? latency + : state.latencyEwma + LATENCY_ALPHA * (latency - state.latencyEwma); + + const ttft = event.ttftMs; + if (typeof ttft === "number" && Number.isFinite(ttft) && ttft >= 0) { + state.ttftEwma = + state.ttftEwma == null ? ttft : state.ttftEwma + LATENCY_ALPHA * (ttft - state.ttftEwma); + } + + state.lastTs = event.ts ?? Date.now(); +} + +/** + * Evaluator seam: record a semantic quality score for a (provider, model). + * Semantic quality is ONLY ever produced by an evaluator (deterministic scorer, + * local LLM judge, HTTP/Future-AGI adapter, WASM). It is never manufactured from + * operational/HTP success. `confidence` should reflect the evaluator's certainty + * (e.g. number of eval cases backing the score). + */ +export function setSemanticQuality( + provider: string, + model: string, + score: number, + confidence: number +): void { + const state = getOrCreate(keyOf(provider || "unknown", model || "unknown")); + state.semantic = Math.max(0, Math.min(1, Number.isFinite(score) ? score : 0.5)); + state.semanticConfidence = Math.max(0, Math.min(1, Number.isFinite(confidence) ? confidence : 0)); +} + +export interface ProviderQuality { + provider: string; + model: string; + /** Operational score [0,1] (wire behavior) — confidence-adjusted, neutral 0.5 cold. */ + operational: number; + /** Semantic score [0,1] from an evaluator, or null when none has been provided. */ + semantic: number | null; + /** Confidence [0,1] of the operational score (sample-count based). */ + confidence: number; + /** Confidence [0,1] of the semantic score, when an evaluator reported one. */ + semanticConfidence: number | null; + samples: number; + anomalies: number; + rateLimited: number; + successEwma: number; + latencyEwmaMs: number; + ttftEwmaMs: number | null; + /** Milliseconds since the last observed event; null when never observed. */ + recencyMs: number | null; + lastTs: number; +} + +/** Raw operational score before the confidence blend (pure EWMA + penalties). */ +function rawOperationalScore(state: QualityState): number { + let score = state.successEwma; + + // Latency degradation: soft penalty capped at 0.2 so slow models are discounted, not zeroed. + const latencyPenalty = Math.min(0.2, state.latencyEwma / 60_000); + score -= latencyPenalty; + + // Anomaly penalty: capped so a few bad apples don't nuke a provider entirely. + const anomalyRate = state.anomalies / Math.max(1, state.samples); + score -= Math.min(0.25, anomalyRate * 0.5); + + return Math.max(0, Math.min(1, score)); +} + +function confidenceOf(samples: number): number { + return Math.max(0, Math.min(1, samples / CONFIDENCE_FULL_SAMPLES)); +} + +/** + * Operational quality for a (provider, model), confidence-adjusted and blended + * toward the neutral midpoint. See module docs for the cold-start guarantee. + */ +export function getProviderQuality(provider: string, model: string): ProviderQuality { + const state = states.get(keyOf(provider, model)); + const now = Date.now(); + if (!state || state.samples === 0) { + return { + provider, + model, + operational: NEUTRAL_SCORE, + semantic: null, + confidence: 0, + semanticConfidence: null, + samples: 0, + anomalies: 0, + rateLimited: 0, + successEwma: 1, + latencyEwmaMs: 0, + ttftEwmaMs: null, + recencyMs: null, + lastTs: 0, + }; + } + const confidence = confidenceOf(state.samples); + const raw = rawOperationalScore(state); + const operational = NEUTRAL_SCORE + confidence * (raw - NEUTRAL_SCORE); + return { + provider, + model, + operational, + semantic: state.semantic, + confidence, + semanticConfidence: state.semanticConfidence, + samples: state.samples, + anomalies: state.anomalies, + rateLimited: state.rateLimited, + successEwma: state.successEwma, + latencyEwmaMs: state.latencyEwma, + ttftEwmaMs: state.ttftEwma, + recencyMs: state.samples > 0 ? Math.max(0, now - state.lastTs) : null, + lastTs: state.lastTs, + }; +} + +/** + * Backward-compatible scalar used by the auto-combo scorer's `quality` factor. + * Returns the confidence-adjusted operational score (neutral 0.5 when cold). + */ +export function getQualityScore(provider: string, model: string): number { + return getProviderQuality(provider, model).operational; +} + +/** Full snapshot of the tracker for explainability / dashboard. */ +export function getQualitySnapshot(limit = 200): ProviderQuality[] { + const views: ProviderQuality[] = []; + for (const [key] of states) { + const slash = key.indexOf("/"); + const provider = slash >= 0 ? key.slice(0, slash) : key; + const model = slash >= 0 ? key.slice(slash + 1) : key; + views.push(getProviderQuality(provider, model)); + } + views.sort((a, b) => b.lastTs - a.lastTs); + return views.slice(0, limit); +} + +/** + * Classify a provider/model quality state for explainability / dashboard. + * This reflects the SOFT adaptive signal — it says nothing about hard exclusion + * (circuit open / quota / auth), which is owned by the resilience stack. + * + * - "healthy": high confidence + operational quality well above neutral + * - "degraded": operational quality at or below neutral (soft penalty active) + * - "warming": low confidence (few samples) — treated neutrally + * - "cold": never observed — neutral, cannot dominate + */ +export type QualityClassification = "healthy" | "degraded" | "warming" | "cold"; + +export function classifyQuality(q: ProviderQuality): QualityClassification { + if (q.samples === 0) return "cold"; + if (q.confidence < 0.5) return "warming"; + if (q.operational < 0.5) return "degraded"; + return "healthy"; +} + +/** Test/ops hook: reset all quality state. */ +export function resetQualityTracker(): void { + states.clear(); +} + +export const QUALITY_WELL_KNOWN = { + CONFIDENCE_FULL_SAMPLES, + NEUTRAL_SCORE, +} as const; diff --git a/open-sse/services/taskAwareRouter.ts b/open-sse/services/taskAwareRouter.ts index 8970a60218..e1ba402b3f 100644 --- a/open-sse/services/taskAwareRouter.ts +++ b/open-sse/services/taskAwareRouter.ts @@ -28,6 +28,14 @@ interface TaskPattern { userPatterns?: string[]; // in user message content } +/** + * Per-task-type replacement for the built-in detection patterns (same config surface as + * taskModelMap). A provided `patterns`/`userPatterns` array replaces the built-in list for + * that task type — no merge. Omitting a task type, or a field within it, falls back to + * TASK_PATTERNS. + */ +export type TaskPatternOverrides = Partial>>; + export interface TaskRoutingConfig { enabled: boolean; /** @@ -35,6 +43,8 @@ export interface TaskRoutingConfig { * Empty string = use whatever was requested (no override). */ taskModelMap: Record; + /** Operator-configurable detection patterns — see TaskPatternOverrides. */ + patternOverrides?: TaskPatternOverrides; detectionEnabled: boolean; stats: { detected: number; routed: number }; } @@ -274,6 +284,16 @@ export function getDefaultTaskModelMap(): Record { return { ...DEFAULT_TASK_MODEL_MAP }; } +/** Built-in detection patterns, before any operator patternOverrides — for the settings UI. */ +export function getDefaultTaskPatterns(): Record { + return Object.fromEntries( + Object.entries(TASK_PATTERNS).map(([taskType, { patterns, userPatterns }]) => [ + taskType, + { patterns: [...patterns], ...(userPatterns ? { userPatterns: [...userPatterns] } : {}) }, + ]) + ) as Record; +} + // ── Detection ──────────────────────────────────────────────────────────────── interface RequestMessage { @@ -338,8 +358,13 @@ export function detectTaskType(body: any): TaskType { "creative", ]; + const overrides = getConfig().patternOverrides; + for (const taskType of priorityOrder) { - const { patterns, userPatterns } = TASK_PATTERNS[taskType]; + const defaults = TASK_PATTERNS[taskType]; + const override = overrides?.[taskType]; + const patterns = override?.patterns ?? defaults.patterns; + const userPatterns = override?.userPatterns ?? defaults.userPatterns; // Check system prompt if (patterns.some((p) => systemText.includes(p.toLowerCase()))) { diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 6b8b018a04..2ca3c5df62 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -12,6 +12,7 @@ // tests) keep a stable surface. import { AsyncLocalStorage } from "node:async_hooks"; import { PROVIDERS } from "../config/constants.ts"; +import { getCodexAuthIdentityHeaders } from "../config/codexClient.ts"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; import { serializeRefresh } from "./refreshSerializer.ts"; import { @@ -254,6 +255,12 @@ export async function refreshAccessToken( headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", + // Credential face (auth.openai.com): the real Codex client sends only + // originator + User-Agent here — no version header (that gate exists + // only on the /backend-api/codex inference face). Refreshing with a + // bare/anonymous identity is a half-identity no real client emits. + // Mirrors sub2api v0.1.178 ApplyCodexCanonicalAuthIdentity. + ...(provider === "codex" ? getCodexAuthIdentityHeaders() : null), }, body: params, }) diff --git a/open-sse/services/usage/codex.ts b/open-sse/services/usage/codex.ts index 564cbdad9d..64b37c0183 100644 --- a/open-sse/services/usage/codex.ts +++ b/open-sse/services/usage/codex.ts @@ -9,6 +9,7 @@ */ import { buildCodexUsageQuotas } from "../codexUsageQuotas.ts"; +import { getCodexBackendIdentityHeaders } from "../../config/codexClient.ts"; import { getFieldValue } from "./scalars.ts"; // Codex (OpenAI) API config @@ -36,6 +37,10 @@ export async function getCodexUsage( Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", Accept: "application/json", + // Same UA/version identity chain as Codex inference (sub2api v0.1.178 + // unified-outbound-identity): usage probes must not show up upstream as + // an anonymous half-identity next to the converged inference traffic. + ...getCodexBackendIdentityHeaders(), }; if (accountId) { headers["chatgpt-account-id"] = accountId; diff --git a/open-sse/services/usage/kimi.ts b/open-sse/services/usage/kimi.ts index ca9f2d5630..d27c3c889b 100644 --- a/open-sse/services/usage/kimi.ts +++ b/open-sse/services/usage/kimi.ts @@ -9,12 +9,16 @@ */ import { safePercentage } from "@/shared/utils/formatting"; +import { + KIMI_CODE_ADDITIONAL_CREDITS_URL, + type KimiBillingStatus, +} from "@/shared/utils/kimiBilling"; import { buildKimiCodeIdentityHeaders, getKimiCodeCliUserAgent, } from "../../config/providers/registry/kimi/coding/runtime.ts"; import { toRecord, toNumber } from "./scalars.ts"; -import { type UsageQuota, parseResetTime } from "./quota.ts"; +import { createQuotaFromUsage, type UsageQuota, parseResetTime } from "./quota.ts"; type JsonRecord = Record; @@ -25,6 +29,145 @@ const KIMI_CONFIG = { apiVersion: "2023-06-01", }; +const KIMI_BOOSTER_FIXED_POINT_PER_CENT = 1_000_000; + +function toInteger(value: unknown): number | null { + const parsed = toNumber(value, Number.NaN); + return Number.isFinite(parsed) ? Math.trunc(parsed) : null; +} + +function fixedPointToCents(value: number): number { + const cents = value / KIMI_BOOSTER_FIXED_POINT_PER_CENT; + if (cents > 0 && cents < 1) return 1; + return Math.round(cents); +} + +function parseKimiMoney(value: unknown): { cents: number; currency: string } | null { + const money = toRecord(value); + const cents = toInteger(money.priceInCents); + const currency = money.currency; + if ( + cents === null || + cents < 0 || + typeof currency !== "string" || + !/^[A-Za-z]{3}$/.test(currency) + ) { + return null; + } + return { cents, currency: currency.toUpperCase() }; +} + +function parseKimiExtraUsageStatus(value: unknown): KimiBillingStatus["extraUsageStatus"] { + switch (value) { + case "STATUS_ACTIVE": + return "enabled"; + case "STATUS_DISABLED": + return "disabled"; + case "STATUS_FROZEN": + return "frozen"; + default: + return "unavailable"; + } +} + +function parseKimiBoosterWallet(value: unknown): KimiBillingStatus | null { + const wallet = toRecord(value); + const balance = toRecord(wallet.balance); + if (balance.type !== "BOOSTER") return null; + + const amount = toInteger(balance.amount); + const amountLeft = toInteger(balance.amountLeft); + const monthlyLimit = parseKimiMoney(wallet.monthlyChargeLimit); + const monthlyUsed = parseKimiMoney(wallet.monthlyUsed); + const autoRefillCharge = parseKimiMoney(wallet.autoRefillCharge); + const autoRefillThreshold = parseKimiMoney(wallet.autoRefillThreshold); + const extraUsageStatus = parseKimiExtraUsageStatus(wallet.status); + const hasWalletEvidence = + (amount !== null && amount > 0) || + amountLeft !== null || + monthlyLimit !== null || + monthlyUsed !== null || + extraUsageStatus !== "unavailable"; + if (!hasWalletEvidence) return null; + + const currency = + monthlyLimit?.currency ?? + monthlyUsed?.currency ?? + autoRefillCharge?.currency ?? + autoRefillThreshold?.currency ?? + "USD"; + + return { + currency, + // Proto JSON omits numeric zero values. Production therefore returns a + // BOOSTER balance record without amount/amountLeft when the preserved + // balance is exactly zero; treat that as an explicit zero, not unknown. + extraCreditsMinorUnits: + amountLeft === null || amountLeft < 0 ? 0 : fixedPointToCents(amountLeft), + monthlyUsedMinorUnits: monthlyUsed?.cents ?? 0, + monthlyLimitEnabled: wallet.monthlyChargeLimitEnabled === true, + monthlyLimitMinorUnits: monthlyLimit?.cents ?? 0, + extraUsageStatus, + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + }; +} + +function buildKimiBillingStatus(value: unknown): KimiBillingStatus { + return ( + parseKimiBoosterWallet(value) ?? { + currency: "USD", + extraUsageStatus: "unavailable", + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + } + ); +} + +function optionalNumber(value: unknown): number | null { + if (typeof value !== "number" && typeof value !== "string") return null; + const parsed = toNumber(value, Number.NaN); + return Number.isFinite(parsed) ? parsed : null; +} + +function createKimiCountQuota(value: unknown): UsageQuota | null { + const detail = toRecord(value); + const limit = optionalNumber(detail.limit ?? detail.Limit); + if (limit === null || limit <= 0) return null; + + const reportedUsed = optionalNumber(detail.used ?? detail.Used); + const reportedRemaining = optionalNumber(detail.remaining ?? detail.Remaining); + const used = reportedUsed ?? (reportedRemaining === null ? 0 : limit - reportedRemaining); + return createQuotaFromUsage(used, limit, detail.resetTime ?? detail.reset_at ?? detail.resetAt); +} + +type KimiWindowLabel = { key: string; displayName: string }; + +function normalizeKimiWindow(value: unknown, fallbackIndex: number): KimiWindowLabel { + const window = toRecord(value); + const duration = optionalNumber(window.duration); + const timeUnit = window.timeUnit; + + if (duration !== null && duration > 0) { + if (timeUnit === "TIME_UNIT_MINUTE" && duration % 60 === 0) { + const hours = duration / 60; + return { key: `${hours}h`, displayName: `Code · ${hours}h` }; + } + if (timeUnit === "TIME_UNIT_HOUR") { + return { key: `${duration}h`, displayName: `Code · ${duration}h` }; + } + if (timeUnit === "TIME_UNIT_DAY") { + return { key: `${duration}d`, displayName: `Code · ${duration}d` }; + } + if (timeUnit === "TIME_UNIT_WEEK") { + return { key: `${duration}w`, displayName: `Code · ${duration}w` }; + } + if (timeUnit === "TIME_UNIT_MINUTE") { + return { key: `${duration}m`, displayName: `Code · ${duration}m` }; + } + } + + return { key: `limit_${fallbackIndex}`, displayName: `Code · Limit ${fallbackIndex}` }; +} + /** * Map Kimi membership level to display name * LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto, @@ -100,52 +243,38 @@ export async function getKimiUsage( const quotas: Record = {}; const dataObj = toRecord(data); + const billing = buildKimiBillingStatus(dataObj.boosterWallet); - // Parse Kimi usage response format - // Format: { user: {...}, usage: { limit: "100", used: "92", remaining: "8", resetTime: "..." }, limits: [...] } - const usageObj = toRecord(dataObj.usage); - - // Check for Kimi's actual usage fields (strings, not numbers) - const usageLimit = toNumber(usageObj.limit || usageObj.Limit, 0); - const usageUsed = toNumber(usageObj.used || usageObj.Used, 0); - const usageRemaining = toNumber(usageObj.remaining || usageObj.Remaining, 0); - const usageResetTime = - usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt; - - if (usageLimit > 0) { - const percentRemaining = usageLimit > 0 ? (usageRemaining / usageLimit) * 100 : 0; - - quotas["Weekly"] = { - used: usageUsed, - total: usageLimit, - remaining: usageRemaining, - remainingPercentage: percentRemaining, - resetAt: parseResetTime(usageResetTime), - unlimited: false, - }; + // The managed Kimi Code API reports the Code 7-day quota in `usage`. + // The website's separate shared-membership total/Kimi split comes from a + // Web-session-only endpoint and cannot be read with a Coding OAuth token. + const weeklyQuota = createKimiCountQuota(dataObj.usage); + if (weeklyQuota) { + quotas.code_7d = { ...weeklyQuota, displayName: "Code · 7d" }; } - // Also parse limits array for rate limits + // Each limits[] item is an independent rolling window. Preserve all of + // them with deterministic window-derived keys instead of overwriting one + // generic `Ratelimit` row. const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : []; for (let i = 0; i < limitsArray.length; i++) { const limitItem = toRecord(limitsArray[i]); - const window = toRecord(limitItem.window); - const detail = toRecord(limitItem.detail); + const quota = createKimiCountQuota(limitItem.detail); + if (!quota) continue; - const limit = toNumber(detail.limit || detail.Limit, 0); - const remaining = toNumber(detail.remaining || detail.Remaining, 0); - const resetTime = detail.resetTime || detail.reset_at || detail.resetAt; - - if (limit > 0) { - quotas["Ratelimit"] = { - used: limit - remaining, - total: limit, - remaining, - remainingPercentage: limit > 0 ? (remaining / limit) * 100 : 0, - resetAt: parseResetTime(resetTime), - unlimited: false, - }; - } + const normalized = normalizeKimiWindow(limitItem.window, i + 1); + const baseKey = `code_${normalized.key}`; + let key = baseKey; + let suffix = 2; + while (key in quotas) key = `${baseKey}_${suffix++}`; + const reportedName = + typeof limitItem.name === "string" && limitItem.name.trim() ? limitItem.name.trim() : null; + const displayName = reportedName + ? /^code\b/i.test(reportedName) + ? reportedName + : `Code · ${reportedName}` + : normalized.displayName; + quotas[key] = { ...quota, displayName }; } // Check for quota windows (Claude-like format with utilization) as fallback @@ -189,6 +318,7 @@ export async function getKimiUsage( return { plan: planName || "Kimi Coding", quotas, + billing, }; } @@ -199,6 +329,7 @@ export async function getKimiUsage( return { plan: planName || "Kimi Coding", message: "Kimi Coding connected. Usage tracked per request.", + billing, }; } catch (error) { return { diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index 1ef35e4aa6..f22f38f9ed 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -7,6 +7,15 @@ import { } from "../utils/reasoningPlaceholder.ts"; import * as fs from "fs"; import * as path from "path"; + +// #10223: threshold for detecting corrupted request_id fields. Normal +// request IDs are <100 chars. DeepSeek's SSE encoder bug produces 200+ +// char values with response-ID fragments. The 100-char gap between normal +// (<100) and threshold (200) provides safety margin for providers that +// use moderately longer IDs. The transformer never reads request_id, so +// stripping it has no functional impact on the output. +const CORRUPTED_REQUEST_ID_THRESHOLD = 200; + /** * Responses API Transformer * Converts OpenAI Chat Completions SSE to Codex Responses API SSE format @@ -605,6 +614,21 @@ export function createResponsesApiTransformStream( continue; } + // #10223: strip request_id when it looks corrupted (suspiciously + // long — normal request IDs are <100 chars). Some providers + // (DeepSeek) have SSE encoder bugs that leak response-ID fragments + // into this field, producing 200+ char values. Well-behaved + // providers' request_id is preserved. + if ( + typeof parsed.request_id === "string" && + parsed.request_id.length >= CORRUPTED_REQUEST_ID_THRESHOLD + ) { + logger?.logInput( + `[ResponsesTransformer] stripped corrupted request_id (${parsed.request_id.length} chars)` + ); + delete parsed.request_id; + } + if (parsed.usage) { state.usage = normalizeResponsesUsage(state.usage, parsed.usage); } diff --git a/open-sse/translator/helpers/responsesApiHelper.ts b/open-sse/translator/helpers/responsesApiHelper.ts index 625daf174f..a6c0ea29d5 100644 --- a/open-sse/translator/helpers/responsesApiHelper.ts +++ b/open-sse/translator/helpers/responsesApiHelper.ts @@ -3,6 +3,7 @@ * Delegates to the canonical translator to avoid logic duplication. */ import { requiresReasoningReplay } from "../../services/reasoningCache.ts"; +import { requiresAuthenticReasoningContent } from "../../utils/reasoningContentInjector.ts"; import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts"; import { toRecord } from "../request/openai-responses/helpers.ts"; @@ -23,13 +24,15 @@ export function convertResponsesApiFormat( credentials && typeof credentials === "object" && !Array.isArray(credentials) ? (credentials as Record) : {}; - const translationCredentials = requiresReasoningReplay({ - provider: String(provider ?? ""), - model: String(model ?? ""), - allowLegacyFallback: false, - }) - ? { ...credentialRecord, _preserveReasoningContent: true } - : credentials; + const translationCredentials = + requiresAuthenticReasoningContent(provider, model) || + requiresReasoningReplay({ + provider: String(provider ?? ""), + model: String(model ?? ""), + allowLegacyFallback: false, + }) + ? { ...credentialRecord, _preserveReasoningContent: true } + : credentials; const converted = openaiResponsesToOpenAIRequest( requestedModel, body, diff --git a/open-sse/translator/helpers/toolCallShim.ts b/open-sse/translator/helpers/toolCallShim.ts index 0c546bb299..ef00713474 100644 --- a/open-sse/translator/helpers/toolCallShim.ts +++ b/open-sse/translator/helpers/toolCallShim.ts @@ -89,8 +89,18 @@ const TOOL_SHIMS: Record = { }, }; +function resolveToolCallShim(name: string | undefined | null): ShimFn | undefined { + if (typeof name !== "string" || !name) return undefined; + if (Object.prototype.hasOwnProperty.call(TOOL_SHIMS, name)) return TOOL_SHIMS[name]; + const lower = name.toLowerCase(); + for (const [key, fn] of Object.entries(TOOL_SHIMS)) { + if (key.toLowerCase() === lower) return fn; + } + return undefined; +} + export function hasToolCallShim(name: string | undefined | null): boolean { - return typeof name === "string" && Object.prototype.hasOwnProperty.call(TOOL_SHIMS, name); + return Boolean(resolveToolCallShim(name)); } /** @@ -100,7 +110,7 @@ export function hasToolCallShim(name: string | undefined | null): boolean { * the shim with `{}` as input (so required arrays still get injected). */ export function applyToolCallShimToBuffer(name: string, raw: string): string { - const shim = TOOL_SHIMS[name]; + const shim = resolveToolCallShim(name); if (!shim) return raw; let parsed: unknown; diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 979fce6e5f..501d2ffdbc 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -202,6 +202,88 @@ function requiresReasoningContentPresence(provider: unknown, model: unknown): bo return normalizedProvider === "xiaomi-mimo" || /(^|\/)mimo/i.test(normalizedModel); } +type OpenAIReplayOptions = { + canReplayReasoningOnly: boolean; + requiresExplicitReasoningReplay: boolean; + provider: string; + model: string; + reasoningCacheScope?: string | null; +}; + +function replayOpenAIReasoningMessage( + messages: Array>, + messageIndex: number, + options: OpenAIReplayOptions +): void { + const message = messages[messageIndex]; + if (!message || message.role !== "assistant") return; + + // Moonshot `partial` messages are output prefixes, not completed prior turns. + if (message.partial === true) { + if (message.reasoning_content === "") delete message.reasoning_content; + return; + } + + if ( + !hasNonEmptyReasoningContent(message) && + typeof message.reasoning === "string" && + message.reasoning.trim().length > 0 + ) { + message.reasoning_content = message.reasoning; + } + + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + const hasToolCalls = toolCalls.length > 0; + const shouldReplayReasoningOnly = + !hasToolCalls && options.canReplayReasoningOnly && !hasNonEmptyReasoningContent(message); + + if (!hasToolCalls && !shouldReplayReasoningOnly) { + if ( + message.reasoning_content === "" || + isInternalReasoningPlaceholder(message.reasoning_content) + ) { + delete message.reasoning_content; + } + return; + } + + if (hasNonEmptyReasoningContent(message)) { + if (!isInternalReasoningPlaceholder(message.reasoning_content)) return; + delete message.reasoning_content; + } + + const firstToolCall = + toolCalls[0] && typeof toolCalls[0] === "object" && !Array.isArray(toolCalls[0]) + ? (toolCalls[0] as Record) + : null; + const cacheKey = hasToolCalls + ? typeof firstToolCall?.id === "string" + ? firstToolCall.id + : "" + : buildAssistantMessageCacheKey(options.reasoningCacheScope, messages, messageIndex); + if (cacheKey) { + const cached = lookupReasoning(cacheKey); + if (cached) { + message.reasoning_content = cached; + recordReplay(); + return; + } + } + + if (options.requiresExplicitReasoningReplay) { + if (message.reasoning_content === "") delete message.reasoning_content; + return; + } + + if ((hasToolCalls || shouldReplayReasoningOnly) && !message.reasoning_content) { + if (requiresReasoningContentPresence(options.provider, options.model)) { + message.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; + } else { + delete message.reasoning_content; + } + } +} + /** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */ /** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */ /** @param options.preserveCacheControl - When true, preserve client-side cache_control markers (for Claude Code, etc.) */ @@ -321,6 +403,25 @@ export function translateRequest( result.messages = hoistLeadingSystemMessage(result.messages, provider); } + if ( + sourceFormat === FORMATS.OPENAI && + targetFormat === FORMATS.OPENAI_RESPONSES && + isReasoner && + Array.isArray(result.messages) + ) { + const messages = result.messages as Array>; + const replayOptions: OpenAIReplayOptions = { + canReplayReasoningOnly: isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel), + requiresExplicitReasoningReplay, + provider: normalizedProvider, + model: normalizedModel, + reasoningCacheScope: options?.reasoningCacheScope, + }; + for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) { + replayOpenAIReasoningMessage(messages, messageIndex, replayOptions); + } + } + // If same format, skip translation steps if (sourceFormat !== targetFormat) { // Check for direct translation path first (e.g., Claude → Gemini) @@ -619,59 +720,13 @@ export function translateRequest( } // ── OpenAI-format message ── - // Skip if client already provided real reasoning_content. The internal - // replay placeholder is NOT real reasoning: drop it and fall through to - // the cache lookup so it can be replaced with genuine cached reasoning. - // Forwarding it makes the model continue its chain of thought from that - // text (echo → empty stop), and the echo re-poisons cache + client - // history (#9573). - if (hasNonEmptyReasoningContent(msg)) { - if (!isInternalReasoningPlaceholder(msg.reasoning_content)) { - continue; - } - delete msg.reasoning_content; - } - - const cacheKey = hasToolCalls - ? msg.tool_calls[0]?.id - : buildAssistantMessageCacheKey( - options?.reasoningCacheScope, - result.messages, - messageIndex - ); - if (cacheKey) { - const cached = lookupReasoning(cacheKey); - if (cached) { - msg.reasoning_content = cached; - recordReplay(); - continue; - } - } - - // Native Moonshot K3/K2.7 accepts only the real prior reasoning. If it - // was not supplied and the cache missed, leave it absent so upstream can - // enforce its contract instead of corrupting history with a placeholder. - if (requiresExplicitReasoningReplay) { - if (msg.reasoning_content === "") delete msg.reasoning_content; - continue; - } - - // Cache miss fallback — previously injected a non-empty placeholder - // (NON_ANTHROPIC_THINKING_PLACEHOLDER) to dodge an alleged DeepSeek V4 400 - // on missing reasoning_content. The placeholder is the root cause of this - // bug: the model echoes it as its own reasoning and stops (empty turns), - // and the echo re-poisons the cache + client history (#9573). Empirically, - // deepseek-v4-flash accepts an ABSENT reasoning_content field (the 400 is - // specific to empty-string, and even that is endpoint-dependent). Omit - // the field instead; providers that genuinely enforce the contract - // (kimi-coding, moonshot reasoning replay) have their own paths above. - if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) { - if (requiresReasoningContentPresence(normalizedProvider, normalizedModel)) { - msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; - } else { - delete msg.reasoning_content; - } - } + replayOpenAIReasoningMessage(result.messages, messageIndex, { + canReplayReasoningOnly, + requiresExplicitReasoningReplay, + provider: normalizedProvider, + model: normalizedModel, + reasoningCacheScope: options?.reasoningCacheScope, + }); } } else if ( !isReasoner && diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index b45bf7730a..853e696dbb 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -15,6 +15,8 @@ import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts"; import { buildChangedToolNameMap, buildHistoricalToolResultContext, + mergeConsecutiveSameRoleContents, + type GeminiContent, } from "./openai-to-gemini/helpers.ts"; /** @@ -45,7 +47,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { : null; const result: { model: string; - contents: Array>; + contents: GeminiContent[]; generationConfig: Record; safetySettings: unknown; systemInstruction?: { role: string; parts: Array<{ text: string }> }; @@ -314,6 +316,11 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { result._toolNameMap = changedToolNameMap; } + // Gemini strictly rejects requests containing consecutive messages with the same role + // (400 INVALID_ARGUMENT: "Request contains consecutive messages with the same role"). + // Normalize adjacent same-role messages by concatenating their parts. + result.contents = mergeConsecutiveSameRoleContents(result.contents); + return result; } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index df65ab5f3c..b886650252 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -8,6 +8,11 @@ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; import { FORMATS } from "../formats.ts"; import { register } from "../registry.ts"; import { normalizeResponsesInputForChat } from "../../utils/responsesInputNormalization.ts"; +import { + createReasoningTransportIncompatibleError, + hasOpaqueReasoningState, + extractReplayableResponsesReasoningText, +} from "../../services/reasoningInputPolicy.ts"; import { getRegisteredProviders, requiresPlainStringContent, @@ -73,14 +78,6 @@ function toolOutputContentToString(output: unknown): string { return parts.join("\n"); } -function getReasoningSummaryText(item: JsonRecord): string { - if (!Array.isArray(item.summary)) return ""; - return item.summary - .map((part) => toString(toRecord(part).text)) - .filter((text) => text.length > 0) - .join("\n\n"); -} - function appendReasoningContent(current: unknown, next: string): string { const existing = typeof current === "string" ? current : ""; return existing ? `${existing}\n\n${next}` : next; @@ -456,10 +453,13 @@ export function openaiResponsesToOpenAIRequest( } if (itemType === "reasoning") { - // Responses reasoning summaries are normally display metadata. Preserve them only - // when the routed upstream explicitly requires prior reasoning to continue a turn. + // Only genuine plaintext reasoning can cross into Chat reasoning_content. + // Opaque encrypted state and its display summary have no Chat replay form. + if (preserveReasoningContent && hasOpaqueReasoningState(item)) { + throw createReasoningTransportIncompatibleError(); + } if (preserveReasoningContent) { - const reasoning = getReasoningSummaryText(item); + const reasoning = extractReplayableResponsesReasoningText(item); if (reasoning) { if (currentAssistantMsg) { currentAssistantMsg.reasoning_content = appendReasoningContent( diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts index f91b66f918..bee5efab1a 100644 --- a/open-sse/translator/request/openai-responses/toResponses.ts +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -4,6 +4,8 @@ * Extracted verbatim from openai-responses.ts. Registration stays in the host. */ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; +import { isInternalReasoningPlaceholder } from "../../../utils/reasoningPlaceholder.ts"; +import { getReadableReasoningValue } from "../../../utils/reasoningFields.ts"; import { generateToolCallId } from "../../helpers/toolCallHelper.ts"; import { JsonRecord, @@ -192,12 +194,18 @@ export function openaiToOpenAIResponsesRequest( // Convert assistant messages if (role === "assistant") { - // Skip reasoning_content — OpenAI Responses API requires server-generated - // rs_* IDs for reasoning items. Synthesizing client-side IDs (e.g. reasoning_N) - // causes 400 errors from Responses-compatible upstreams. (#224) - - // Skip thinking blocks in array content — same rs_* ID constraint applies + const reasoning = getReadableReasoningValue(msg).trim(); + if (reasoning && !isInternalReasoningPlaceholder(reasoning)) { + // Compatibility is decided before protocol translation; this adapter + // only encodes the surviving portable plaintext state. + input.push({ + type: "reasoning", + content: [{ type: "reasoning_text", text: reasoning }], + }); + } + // Thinking blocks remain display-only here. They do not prove that the + // selected target accepts their provider-specific replay representation. // Build assistant output content const outputContent: unknown[] = []; if (typeof msg.content === "string" && msg.content) { diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index ff07029e61..8a5c115c2a 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -7,10 +7,17 @@ import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; import { safeParseJSON } from "../helpers/jsonUtil.ts"; import { applyKimiCodingThinking } from "../helpers/claudeHelper.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; -import { getDefaultThinkingBudget, isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; +import { + getDefaultThinkingBudget, + isAdaptiveThinkingOnly, +} from "../../../src/shared/constants/modelSpecs.ts"; import { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts"; import { enforceToolResultAdjacency } from "./openai-to-claude/toolResultAdjacency.ts"; import { sanitizeToolResultId } from "./openai-to-claude/sanitizeToolResultId.ts"; +import { + openAiImagePartToClaudeBlock, + normalizeToolResultImages, +} from "./openai-to-claude/imageBlocks.ts"; // Reasoning-effort levels Anthropic accepts on `output_config.effort`. Used to steer // adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget. @@ -534,9 +541,10 @@ function getContentBlocksFromMessage( const sanitizedToolUseId = sanitizeToolResultId(msg.tool_call_id); // #7705 if (!sanitizedToolUseId) return blocks; // T02: Strip empty text blocks from nested tool_result content to avoid Anthropic 400 - const toolContent = Array.isArray(msg.content) - ? stripEmptyTextBlocks(msg.content) - : msg.content; + // #9692: rewrite OpenAI image_url parts to Claude image blocks (same as user turns) + const toolContent = normalizeToolResultImages( + Array.isArray(msg.content) ? stripEmptyTextBlocks(msg.content) : msg.content + ); blocks.push({ type: "tool_result", tool_use_id: sanitizedToolUseId, @@ -555,43 +563,19 @@ function getContentBlocksFromMessage( // Skip tool_result with no tool_use_id (would be useless and may cause errors) if (!part.tool_use_id) continue; // T02: strip empty text blocks from nested content before passing to Anthropic - const resultContent = Array.isArray(part.content) - ? stripEmptyTextBlocks(part.content) - : part.content; + // #9692: convert OpenAI image_url nested in tool_result the same way + const resultContent = normalizeToolResultImages( + Array.isArray(part.content) ? stripEmptyTextBlocks(part.content) : part.content + ); blocks.push({ type: "tool_result", tool_use_id: sanitizeToolId(part.tool_use_id), // #7705 content: resultContent, ...(part.is_error && { is_error: part.is_error }), }); - } else if (part.type === "image_url") { - const url = part.image_url.url; - const match = url.match(/^data:([^;]+);base64,(.+)$/); - if (match) { - blocks.push({ - type: "image", - source: { type: "base64", media_type: match[1], data: match[2] }, - }); - } else if (typeof url === "string" && url.trim()) { - blocks.push({ - type: "image", - source: { type: "url", url }, - }); - } - } else if (part.type === "image" && part.source) { - blocks.push({ type: "image", source: part.source }); - } else if (part.type === "image" && typeof part.image === "string") { - // AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330) - const url = part.image; - const match = url.match(/^data:([^;]+);base64,(.+)$/); - if (match) { - blocks.push({ - type: "image", - source: { type: "base64", media_type: match[1], data: match[2] }, - }); - } else if (url.trim()) { - blocks.push({ type: "image", source: { type: "url", url } }); - } + } else if (part.type === "image_url" || part.type === "image") { + const imageBlock = openAiImagePartToClaudeBlock(part); + if (imageBlock) blocks.push(imageBlock); } else if (part.type === "file" && (part.file?.file_data || part.file?.data)) { // OpenAI Chat Completions file block: // {type:"file", file:{filename, file_data:"data:;base64,..."}}. diff --git a/open-sse/translator/request/openai-to-claude/imageBlocks.ts b/open-sse/translator/request/openai-to-claude/imageBlocks.ts new file mode 100644 index 0000000000..3157c500fa --- /dev/null +++ b/open-sse/translator/request/openai-to-claude/imageBlocks.ts @@ -0,0 +1,77 @@ +/** + * Convert OpenAI-style image parts (including those nested in tool results) + * into Claude Messages `image` blocks. User-message `image_url` already did + * this; `role: "tool"` and nested `tool_result` content previously forwarded + * the OpenAI shape unchanged, which Anthropic rejects with HTTP 400 (#9692). + */ + +const DATA_URL_RE = /^data:([^;]+);base64,(.+)$/; + +type ClaudeImageBlock = { + type: "image"; + source: { type: "base64"; media_type: string; data: string } | { type: "url"; url: string }; +}; + +export function extractOpenAiImageUrl(imageUrl: unknown): string { + if (typeof imageUrl === "string") return imageUrl; + if (imageUrl && typeof imageUrl === "object" && !Array.isArray(imageUrl)) { + const url = (imageUrl as { url?: unknown }).url; + if (typeof url === "string") return url; + } + return ""; +} + +export function urlToClaudeImageBlock(url: string): ClaudeImageBlock | null { + if (typeof url !== "string") return null; + const trimmed = url.trim(); + if (!trimmed) return null; + const match = trimmed.match(DATA_URL_RE); + if (match) { + return { + type: "image", + source: { type: "base64", media_type: match[1], data: match[2] }, + }; + } + return { type: "image", source: { type: "url", url: trimmed } }; +} + +/** + * Map one OpenAI / AI-SDK image-shaped part to a Claude image block. + * Returns null when the part is not an image (caller should keep it as-is). + */ +export function openAiImagePartToClaudeBlock( + part: Record +): ClaudeImageBlock | null { + const type = part.type; + if (type === "image_url") { + return urlToClaudeImageBlock(extractOpenAiImageUrl(part.image_url)); + } + if (type === "image") { + if (part.source && typeof part.source === "object" && !Array.isArray(part.source)) { + return { type: "image", source: part.source as ClaudeImageBlock["source"] }; + } + if (typeof part.image === "string") { + return urlToClaudeImageBlock(part.image); + } + } + return null; +} + +/** + * Walk a tool_result content value and rewrite OpenAI `image_url` (and AI-SDK + * `image`) parts to Claude `image` blocks. Nested `tool_result` arrays recurse. + * Non-array content (plain strings) is left unchanged. + */ +export function normalizeToolResultImages(content: unknown): unknown { + if (!Array.isArray(content)) return content; + return content.map((block) => { + if (!block || typeof block !== "object" || Array.isArray(block)) return block; + const rec = block as Record; + const image = openAiImagePartToClaudeBlock(rec); + if (image) return image; + if (rec.type === "tool_result" && Array.isArray(rec.content)) { + return { ...rec, content: normalizeToolResultImages(rec.content) }; + } + return rec; + }); +} diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index cb1cedd713..92399743ff 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -39,8 +39,13 @@ import { escapeHistoricalContextAttribute, escapeHistoricalContextContent, buildHistoricalToolResultContext, + type GeminiPart, + type GeminiContent, + mergeConsecutiveSameRoleContents, } from "./openai-to-gemini/helpers.ts"; +export { mergeConsecutiveSameRoleContents, type GeminiContent, type GeminiPart }; + // Observed Antigravity wrapper output cap, not an underlying model capability. // Keep this bridge-local: Antigravity currently caps visible output around 16K. // See: https://github.com/keisksw/antigravity-output-analysis @@ -56,9 +61,6 @@ const GEMINI_BUILTIN_TOOL_NAMES = new Set([ "googleSearch", ]); -type GeminiPart = Record; -type GeminiContent = { role: string; parts: GeminiPart[] }; - type GeminiFunctionDeclaration = { name: string; description: string; @@ -158,29 +160,6 @@ type GeminiToolNameOptions = { supportsSignatureBypass?: boolean; }; -// Gemini-family APIs (incl. Antigravity / Vertex) reject a `contents[]` array that -// has two adjacent entries with the same role: -// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role". -// Client history that carries consecutive user turns — or a tool-result turn (mapped -// to role:"user") immediately followed by a plain user turn — would otherwise leak -// that invalid alternation through. Merge adjacent same-role entries by concatenating -// their parts, the same normalization the Kiro and Claude request paths already apply -// (9router#2191). -export function mergeConsecutiveSameRoleContents(contents: GeminiContent[]): GeminiContent[] { - const merged: GeminiContent[] = []; - for (const entry of contents) { - const last = merged[merged.length - 1]; - if (last && last.role === entry.role) { - last.parts.push(...entry.parts); - } else { - // Shallow-copy the entry and its `parts` array so a later same-role merge - // (`last.parts.push(...)`) never mutates the caller's input objects. - merged.push({ ...entry, parts: [...entry.parts] }); - } - } - return merged; -} - // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase( model: string, diff --git a/open-sse/translator/request/openai-to-gemini/helpers.ts b/open-sse/translator/request/openai-to-gemini/helpers.ts index 810620d8f9..092a857a2c 100644 --- a/open-sse/translator/request/openai-to-gemini/helpers.ts +++ b/open-sse/translator/request/openai-to-gemini/helpers.ts @@ -152,3 +152,29 @@ export function buildHistoricalToolResultContext(name: string, response: unknown "", ].join("\n"); } + +export type GeminiPart = Record; +export type GeminiContent = { role: string; parts: GeminiPart[] }; + +// Gemini-family APIs (incl. Antigravity / Vertex) reject a `contents[]` array that +// has two adjacent entries with the same role: +// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role". +// Client history that carries consecutive user turns — or a tool-result turn (mapped +// to role:"user") immediately followed by a plain user turn — would otherwise leak +// that invalid alternation through. Merge adjacent same-role entries by concatenating +// their parts, the same normalization the Kiro and Claude request paths already apply +// (9router#2191). +export function mergeConsecutiveSameRoleContents(contents: GeminiContent[]): GeminiContent[] { + const merged: GeminiContent[] = []; + for (const entry of contents) { + const last = merged[merged.length - 1]; + if (last && last.role === entry.role) { + last.parts.push(...entry.parts); + } else { + // Shallow-copy the entry and its `parts` array so a later same-role merge + // (`last.parts.push(...)`) never mutates the caller's input objects. + merged.push({ ...entry, parts: [...entry.parts] }); + } + } + return merged; +} diff --git a/open-sse/translator/response/claude-to-openai.ts b/open-sse/translator/response/claude-to-openai.ts index 026a3e1f3e..2d20661e7b 100644 --- a/open-sse/translator/response/claude-to-openai.ts +++ b/open-sse/translator/response/claude-to-openai.ts @@ -44,6 +44,40 @@ export function claudeToOpenAIResponse(chunk, state) { state.messageId = chunk.message?.id || `msg_${Date.now()}`; state.model = chunk.message?.model; state.toolCallIndex = 0; + const startUsage = chunk.message?.usage; + if (startUsage && typeof startUsage === "object") { + const inputTokens = + typeof startUsage.input_tokens === "number" + ? startUsage.input_tokens + : typeof startUsage.prompt_tokens === "number" + ? startUsage.prompt_tokens + : 0; + const outputTokens = + typeof startUsage.output_tokens === "number" + ? startUsage.output_tokens + : typeof startUsage.completion_tokens === "number" + ? startUsage.completion_tokens + : 0; + const cacheRead = + typeof startUsage.cache_read_input_tokens === "number" + ? startUsage.cache_read_input_tokens + : 0; + const cacheCreation = + typeof startUsage.cache_creation_input_tokens === "number" + ? startUsage.cache_creation_input_tokens + : 0; + if (inputTokens > 0 || outputTokens > 0 || cacheRead > 0 || cacheCreation > 0) { + const billableInputTokens = inputTokens + cacheRead; + state.usage = { + prompt_tokens: billableInputTokens, + completion_tokens: outputTokens, + input_tokens: billableInputTokens, + output_tokens: outputTokens, + }; + if (cacheRead > 0) state.usage.cache_read_input_tokens = cacheRead; + if (cacheCreation > 0) state.usage.cache_creation_input_tokens = cacheCreation; + } + } results.push(createChunk(state, { role: "assistant" })); break; } @@ -298,6 +332,8 @@ export function claudeToOpenAIResponse(chunk, state) { if (!state.finishReasonSent) { const finishReason = state.finishReason || (state.toolCalls?.size > 0 ? "tool_calls" : "stop"); + const cachedTokens = state.usage?.cache_read_input_tokens || 0; + const cacheCreationTokens = state.usage?.cache_creation_input_tokens || 0; const usageObj = state.usage && typeof state.usage === "object" ? { @@ -313,6 +349,16 @@ export function claudeToOpenAIResponse(chunk, state) { }, } : {}), + ...(cachedTokens > 0 || cacheCreationTokens > 0 + ? { + prompt_tokens_details: { + ...(cachedTokens > 0 ? { cached_tokens: cachedTokens } : {}), + ...(cacheCreationTokens > 0 + ? { cache_creation_tokens: cacheCreationTokens } + : {}), + }, + } + : {}), }, } : {}; diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 09ff1c8d7c..0c59fac4fb 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -12,6 +12,7 @@ import { isInternalReasoningPlaceholder, stripInternalReasoningPlaceholder, } from "../../utils/reasoningPlaceholder.ts"; +import { extractReplayableResponsesReasoningText } from "../../services/reasoningInputPolicy.ts"; import { normalizeToolName, stripEmptyOptionalToolArgs, @@ -542,7 +543,8 @@ function emitToolCall(state, emit, tc) { const toolName = state.funcNames[tcIdx] || funcName || ""; const lowerName = toolName.toLowerCase(); const isCustomTool = - ((lowerName === "apply_patch" || lowerName === "applypatch") && !state.toolSchemas?.has?.(toolName)) || + ((lowerName === "apply_patch" || lowerName === "applypatch") && + !state.toolSchemas?.has?.(toolName)) || state.customToolNames?.has?.(toolName) === true; if (!state.funcCallIds[tcIdx] && newCallId) state.funcCallIds[tcIdx] = newCallId; @@ -614,7 +616,8 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) { // same classification independently for their respective add/close call sites). const lowerName = toolName.toLowerCase(); const isCustomTool = - ((lowerName === "apply_patch" || lowerName === "applypatch") && !state.toolSchemas?.has?.(toolName)) || + ((lowerName === "apply_patch" || lowerName === "applypatch") && + !state.toolSchemas?.has?.(toolName)) || state.customToolNames?.has?.(toolName) === true; let funcItem; @@ -1042,6 +1045,17 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { return null; } + if (eventType === "response.output_item.done" && data.item?.type === "reasoning") { + const replayableReasoning = extractReplayableResponsesReasoningText(data.item); + if (replayableReasoning) { + const accumulated = + typeof state.accumulatedReasoning === "string" ? state.accumulatedReasoning : ""; + state.accumulatedReasoning = accumulated + ? `${accumulated}\n\n${replayableReasoning}` + : replayableReasoning; + } + } + // Function call done — emit args chunk from item.arguments when no deltas were received, // then advance the tool-call index. This handles Codex Responses API payloads that // carry the complete arguments only in output_item.done (no preceding delta events). @@ -1055,6 +1069,28 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { const shouldNormalizeArguments = toolName === "Agent"; state.currentToolCallNeedsNormalization = shouldNormalizeArguments; + if (toolName && state.toolCalls instanceof Map) { + const completedArguments = + typeof item.arguments === "string" && item.arguments.length > 0 ? item.arguments : buffered; + const normalizedArguments = stripEmptyOptionalToolArgs( + completedArguments, + toolName, + toolSchema + ); + state.toolCalls.set(currentIndex, { + id: callId, + index: currentIndex, + type: "function", + function: { + name: toolName, + arguments: + typeof normalizedArguments === "string" + ? normalizedArguments + : JSON.stringify(normalizedArguments ?? {}), + }, + }); + } + // Track this call_id so response.completed doesn't synthesize a duplicate if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set(); if (callId) state.toolCallIdsSeen.add(callId); @@ -1314,12 +1350,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { return buildResponsesReasoningDeltaChunk(state, deltaText); } - // #5786 — reasoning summary exposed ONLY as a terminal snapshot on - // `response.output_item.done` (no preceding reasoning_summary_text.delta events — e.g. - // Codex reasoning models that surface the summary once at item close). Without this the - // reasoning channel is silently dropped and never reaches the client's thinking panel. - // Only synthesize when NO reasoning delta was already streamed for this item, so normal - // delta streams are never duplicated. + // Some providers expose completed reasoning only on `response.output_item.done`. + // Synthesize one Chat reasoning delta only when no delta was already emitted. if (eventType === "response.output_item.done" && data.item?.type === "reasoning") { const item = data.item; const itemId = item.id != null ? String(item.id) : ""; @@ -1334,6 +1366,11 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { !(state.reasoningItemsWithDelta instanceof Set && state.reasoningItemsWithDelta.size > 0); if (emittedForItem || emittedWithoutItemId) return null; + const replayableReasoning = extractReplayableResponsesReasoningText(item); + if (replayableReasoning) { + return buildResponsesReasoningDeltaChunk(state, replayableReasoning); + } + // #7176/#7243: only synthesize from real upstream plaintext — never mutate // `item` and never fabricate placeholder text for encrypted-only reasoning. const summaryText = getVisibleResponsesReasoningSummaryText(item); diff --git a/open-sse/utils/directResponseStartTimeout.ts b/open-sse/utils/directResponseStartTimeout.ts new file mode 100644 index 0000000000..90e7b6a04a --- /dev/null +++ b/open-sse/utils/directResponseStartTimeout.ts @@ -0,0 +1,77 @@ +type DirectFetchOptions = RequestInit & { dispatcher?: unknown }; +type DirectFetch = ( + input: RequestInfo | URL, + options: DirectFetchOptions +) => Promise; + +const DEFAULT_DIRECT_HEADERS_TIMEOUT_MS = 30_000; +const DIRECT_RESPONSE_START_TIMEOUT_CODE = "DIRECT_RESPONSE_START_TIMEOUT"; + +export function resolveDirectHeadersTimeoutMs( + env: Record = process.env +): number { + const raw = env.OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS; + if (raw == null || raw.trim() === "") return DEFAULT_DIRECT_HEADERS_TIMEOUT_MS; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0; +} + +function createDirectResponseStartTimeout(timeoutMs: number): Error & { code: string } { + const err = new Error( + `Direct response did not start within ${timeoutMs}ms — retrying on a fresh socket` + ) as Error & { code: string }; + err.name = "TimeoutError"; + err.code = DIRECT_RESPONSE_START_TIMEOUT_CODE; + return err; +} + +export function isDirectResponseStartTimeout(err: unknown): boolean { + return ( + !!err && + typeof err === "object" && + "code" in err && + err.code === DIRECT_RESPONSE_START_TIMEOUT_CODE + ); +} + +function mergeAbortSignals( + primary: AbortSignal | null | undefined, + secondary: AbortSignal +): AbortSignal { + if (!primary) return secondary; + if (primary.aborted) return primary; + const controller = new AbortController(); + const onPrimaryAbort = () => controller.abort(primary.reason); + const onSecondaryAbort = () => controller.abort(secondary.reason); + const cleanup = () => { + primary.removeEventListener("abort", onPrimaryAbort); + secondary.removeEventListener("abort", onSecondaryAbort); + }; + primary.addEventListener("abort", onPrimaryAbort, { once: true }); + secondary.addEventListener("abort", onSecondaryAbort, { once: true }); + controller.signal.addEventListener("abort", cleanup, { once: true }); + return controller.signal; +} + +export async function directFetchWithBoundedResponseStart( + input: RequestInfo | URL, + options: DirectFetchOptions, + fetchImpl: DirectFetch, + timeoutMs: number +): Promise { + if (!timeoutMs || timeoutMs <= 0) return fetchImpl(input, options); + const attemptController = new AbortController(); + const timer = setTimeout( + () => attemptController.abort(createDirectResponseStartTimeout(timeoutMs)), + timeoutMs + ); + timer.unref?.(); + try { + return await fetchImpl(input, { + ...options, + signal: mergeAbortSignals(options.signal, attemptController.signal), + }); + } finally { + clearTimeout(timer); + } +} diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts index aa7fb63594..0b7cdaab36 100644 --- a/open-sse/utils/earlyStreamKeepalive.ts +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -31,7 +31,6 @@ * to 200, so the HTTP status can no longer change). */ -import { ResponsesOutputIndexStack } from "./responsesOutputIndexStack.ts"; import { recordEarlyKeepaliveBytes } from "./earlyKeepaliveByteBuffer.ts"; const ENCODER = new TextEncoder(); @@ -52,91 +51,6 @@ export const OPENAI_STARTUP_FRAME = OPENAI_KEEPALIVE_FRAME; // token the comment frame lets the client abort and retry the stream. Anthropic's own // API emits `event: ping` for exactly this reason; the /v1/messages route mirrors it. export const ANTHROPIC_PING_FRAME = ENCODER.encode('event: ping\ndata: {"type":"ping"}\n\n'); -// Responses API keepalive: a self-contained, self-closed synthetic reasoning -// item (added -> summary_part.added -> text.delta -> summary_part.done -> -// output_item.done). Unlike open-sse/utils/stream.ts's own -// emitSyntheticResponsesReasoningSummary — which only supplements a REAL -// upstream item that the real provider stream will close on its own — this -// placeholder item has no real counterpart: the upstream response, once it -// arrives, starts its own independent response.created lifecycle from -// scratch and will never close this one. It must therefore send its own -// response.output_item.done here, not just reasoning_summary_part.done -// (that only closes the nested summary part, not the output item itself). -// Without it, a strict client tracking open items by output_index (as the -// Responses API spec requires) sees this item still open at index 0 and -// throws a collision the moment the real response's own output_item.added -// reuses that same index — reproduced live 2026-08-13, OpenClaw issue -// https://github.com/openclaw/openclaw/issues/123342. -// -// The output_index is allocated from ResponsesOutputIndexStack instead of a -// hardcoded literal so this stays structurally correct: forgetting the -// close() call throws at module load (assertAllClosed() below), not -// silently at some future real request. -const RESPONSES_STARTUP_ITEM_ID = "rs_keepalive"; -// Brand-neutral placeholder — clients persist this as visible reasoning. -const STARTUP_THINKING_TEXT = "✨"; -const startupIndexStack = new ResponsesOutputIndexStack(); -const RESPONSES_STARTUP_OUTPUT_INDEX = startupIndexStack.open(); -const startupEvents = [ - { - event: "response.output_item.added", - data: { - type: "response.output_item.added", - output_index: RESPONSES_STARTUP_OUTPUT_INDEX, - item: { id: RESPONSES_STARTUP_ITEM_ID, type: "reasoning", summary: [] }, - }, - }, - { - event: "response.reasoning_summary_part.added", - data: { - type: "response.reasoning_summary_part.added", - item_id: RESPONSES_STARTUP_ITEM_ID, - output_index: RESPONSES_STARTUP_OUTPUT_INDEX, - summary_index: 0, - part: { type: "summary_text", text: "" }, - }, - }, - { - event: "response.reasoning_summary_text.delta", - data: { - type: "response.reasoning_summary_text.delta", - item_id: RESPONSES_STARTUP_ITEM_ID, - output_index: RESPONSES_STARTUP_OUTPUT_INDEX, - summary_index: 0, - delta: STARTUP_THINKING_TEXT, - }, - }, - { - event: "response.reasoning_summary_part.done", - data: { - type: "response.reasoning_summary_part.done", - item_id: RESPONSES_STARTUP_ITEM_ID, - output_index: RESPONSES_STARTUP_OUTPUT_INDEX, - summary_index: 0, - part: { type: "summary_text", text: STARTUP_THINKING_TEXT }, - }, - }, -]; -// close() runs before the output_item.done event is built (not just before -// it's appended) so assertAllClosed() below is a real check, not scaffolding -// that always trivially passes. -startupIndexStack.close(RESPONSES_STARTUP_OUTPUT_INDEX); -startupEvents.push({ - event: "response.output_item.done", - data: { - type: "response.output_item.done", - output_index: RESPONSES_STARTUP_OUTPUT_INDEX, - item: { - id: RESPONSES_STARTUP_ITEM_ID, - type: "reasoning", - summary: [{ type: "summary_text", text: STARTUP_THINKING_TEXT }], - }, - }, -}); -startupIndexStack.assertAllClosed(); -export const RESPONSES_STARTUP_THINKING_FRAME = ENCODER.encode( - startupEvents.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`).join("") -); // Anthropic Messages API default — Anthropic's own spec really does use a named // `event: error` SSE frame, so this is correct there. It is WRONG for the OpenAI- // format routes below: Chat Completions and Responses streaming never use the SSE @@ -192,11 +106,15 @@ export type EarlyStreamKeepaliveOptions = { /** * Frame emitted ONCE, immediately, as the very first byte of the slow path — * before the recurring `keepaliveFrame` ticks start. Defaults to - * `keepaliveFrame` when omitted (today's behavior, unchanged). Pass a - * content-bearing frame (e.g. `OPENAI_STARTUP_THINKING_FRAME`) so the client - * sees visible progress instead of an empty/no-op keepalive on the first byte. + * `keepaliveFrame` when omitted (today's behavior, unchanged). */ startupFrame?: Uint8Array; + /** + * Optional parser-visible frame emitted at a slower cadence than the transport + * heartbeat. A due application frame replaces that interval's keepalive frame, + * so both cadences share one timer and never burst after an event-loop stall. + */ + applicationKeepalive?: { frame: Uint8Array; intervalMs: number }; /** Extra headers to include in the keepalive response (e.g. X-Correlation-Id). */ extraHeaders?: Record; /** @@ -241,6 +159,13 @@ export async function withEarlyStreamKeepalive( const signal = options.signal ?? null; const keepaliveFrame = options.keepaliveFrame ?? KEEPALIVE_FRAME; const startupFrame = options.startupFrame ?? keepaliveFrame; + const applicationKeepalive = + options.applicationKeepalive && options.applicationKeepalive.intervalMs > 0 + ? { + frame: options.applicationKeepalive.frame, + intervalMs: Math.max(intervalMs, options.applicationKeepalive.intervalMs), + } + : null; const extraHeaders = options.extraHeaders ?? {}; const errorFrame = options.errorFrame ?? ERROR_FRAME; // Single source of truth for whether THIS route's error framing uses a named SSE @@ -291,22 +216,30 @@ export async function withEarlyStreamKeepalive( const stream = new ReadableStream({ async start(controller) { let stopped = false; + let nextApplicationKeepaliveAt = applicationKeepalive + ? performance.now() + applicationKeepalive.intervalMs + : Number.POSITIVE_INFINITY; const interval = setInterval(() => { if (stopped) return; try { - controller.enqueue(keepaliveFrame); - recordClientBytes(keepaliveFrame); + const now = performance.now(); + let frame = keepaliveFrame; + if (applicationKeepalive && now >= nextApplicationKeepaliveAt) { + frame = applicationKeepalive.frame; + nextApplicationKeepaliveAt = now + applicationKeepalive.intervalMs; + } + controller.enqueue(frame); + recordClientBytes(frame); } catch { stopped = true; clearInterval(interval); } }, intervalMs); - if (interval && typeof interval === "object" && "unref" in interval) { + if (typeof interval === "object" && interval !== null && "unref" in interval) { interval.unref?.(); } // First frame immediately on commit so the client sees a byte right away. - // Use `startupFrame` (e.g. OPENAI_STARTUP_THINKING_FRAME / ANTHROPIC_PING_FRAME) - // — an SSE comment here would be ignored by Anthropic clients' watchdog on a + // An SSE comment here would be ignored by Anthropic clients' watchdog on a // sub-interval gap, defeating the keepalive for exactly the case it targets. try { controller.enqueue(startupFrame); diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 56920ccef5..e8e3ea26f8 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -19,6 +19,11 @@ import { isControlPlaneProxyDirectFallbackEnabled, isFeatureFlagEnabled, } from "@/shared/utils/featureFlags"; +import { + directFetchWithBoundedResponseStart, + isDirectResponseStartTimeout, + resolveDirectHeadersTimeoutMs, +} from "./directResponseStartTimeout.ts"; // #9100: relay egress (Vercel / Deno / Cloudflare edge functions) used to go // through bare `originalFetch` — NO connection pooling, NO timeout, NO retry. @@ -154,7 +159,6 @@ type TlsFingerprintStore = { provider?: string | null; sessionScope?: string; }; - /** * #5217 (Gap-secondary): a mutable sink that records the proxy actually applied * by `runWithProxyContext` for the in-flight request. Executors that pin their @@ -802,15 +806,7 @@ async function patchedFetch( (deps.nativeFetch as FetchWithDispatcher | undefined) ?? originalFetchWithDispatcher; return _nativeFetch(input, options); } - // Direct connection (no proxy) — use undici with custom dispatcher for timeout control. - // Falls back to original native fetch if dispatcher initialization fails (#1054). - // Retries once on transient dispatcher errors before falling back (fix: proxyfetch-undici-retry). - // - // Non-replayable body guard: if the body is stream-like (ReadableStream/Blob) - // or the input is a Request that carries a body, the first dispatcher attempt - // owns that body. Retrying or falling back to native fetch would replay a - // consumed/locked body and can mask the original transport error with - // "Response body object should not be disturbed or locked". + // Direct undici path: bound response-start, fresh-socket retry, and body guard. const hasNonReplayableBody = requestHasNonReplayableBody(input, options); const maxAttempts = hasNonReplayableBody ? 1 : 2; const _undiciDirect = @@ -818,32 +814,44 @@ async function patchedFetch( const _nativeFallback = (deps.nativeFetch as FetchWithDispatcher | undefined) ?? originalFetchWithDispatcher; let lastDispatcherError: unknown = null; + const directHeadersTimeoutMs = resolveDirectHeadersTimeoutMs(); + let targetHostForLogs = ""; + try { + targetHostForLogs = new URL(targetUrl).host; + } catch { + // ignore — logging is best-effort + } for (let attempt = 0; attempt < maxAttempts; attempt++) { try { - return await _undiciDirect(input, { - ...options, - // #4252: first attempt uses the pooled keep-alive dispatcher; a retry - // (after a transient socket error) uses the no-keep-alive dispatcher so - // it opens a FRESH socket instead of grabbing another stale pooled one - // — the burst pattern was the retry re-hitting a dead pooled socket and - // then falling through to native fetch (which also pools) → 502. - dispatcher: attempt === 0 ? getDefaultDispatcher() : getRetryDispatcher(), - }); + return await directFetchWithBoundedResponseStart( + input, + { + ...options, + dispatcher: attempt === 0 ? getDefaultDispatcher() : getRetryDispatcher(), + }, + _undiciDirect, + directHeadersTimeoutMs + ); } catch (dispatcherError) { + if (isDirectResponseStartTimeout(dispatcherError)) { + if (attempt === 0 && maxAttempts > 1) { + console.warn( + `[ProxyFetch] Direct response-start timeout (${directHeadersTimeoutMs}ms) on pooled dispatcher — retrying on fresh no-keep-alive dispatcher: ${targetHostForLogs}` + ); + lastDispatcherError = dispatcherError; + continue; + } + throw dispatcherError; + } const msg = dispatcherError instanceof Error ? dispatcherError.message : String(dispatcherError); - // CAUTION: Do NOT fallback to native fetch if the error is a version mismatch (invalid onRequestStart) - // because the native fetch will definitely fail with the undici v8 dispatcher. if (msg.includes("onRequestStart")) { console.error( `[ProxyFetch] Fatal version mismatch: Dispatcher (v8) vs Fetch (v6/native). Hardware upgrade or SOCKS5 config isolation required. Error: ${msg}` ); throw dispatcherError; } - // Only retry/fallback for connection/dispatcher errors, not HTTP errors. - // Prefer the .code property when available (more stable across undici - // versions than message-string matching); fall back to substring match - // for errors that lack a structured code. + // Retry/fallback only for connection errors, never HTTP errors. tagProxyUnreachable(dispatcherError); const errCode = (dispatcherError as { code?: unknown })?.code; if ( @@ -854,10 +862,7 @@ async function patchedFetch( msg.includes("UND_ERR") ) { if (attempt === 0 && maxAttempts > 1) { - // First failure — retry once after a short backoff before giving up. - // Delay is OMNIROUTE_RETRY_BACKOFF_MS (default 10ms): a fixed backoff - // beats random jitter here because the retry opens a fresh socket, so - // jitter was pure added latency with no herd benefit. + // Retry after a short fixed backoff on a fresh socket. lastDispatcherError = dispatcherError; await new Promise((r) => setTimeout(r, RETRY_BACKOFF_MS)); continue; @@ -873,7 +878,7 @@ async function patchedFetch( throw tagProxyUnreachable(dispatcherError); } - // All attempts exhausted — try proxy fallback before native fetch + // Exhausted attempts: try proxy fallback before native fetch. if ( !tlsDirectFallback && source === "direct" && @@ -899,20 +904,14 @@ async function patchedFetch( } } } - // Preserve original phrase intact for monitoring: "Undici dispatcher failed, falling back to native fetch" - // #4252: append the flattened err.cause (code/syscall/errno/address) — the bare - // "fetch failed" message hides what actually broke, making bursts undiagnosable. + // Preserve the original monitoring phrase and append the transport cause. console.warn( `[ProxyFetch] Undici dispatcher failed, falling back to native fetch (after retry): ${describeFetchCause(dispatcherError)}` ); try { return await _nativeFallback(input, options); } catch (nativeError) { - // #4252: both the undici dispatcher AND native fetch failed. Surface BOTH - // causes (server log) and tag the propagated error so the combo executor sees - // a diagnosable failure IMMEDIATELY instead of a bare "fetch failed" — the - // latter left jobs sitting until the 30s semaphore queue timeout, which then - // tripped the circuit breaker. + // Surface both dispatcher and native causes immediately. const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[${describeFetchCause(nativeError)}]`; console.warn(`[ProxyFetch] native fetch fallback ALSO failed: ${detail}`); if (nativeError instanceof Error) { diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index 6d1e005b1d..ad915e4ce2 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -180,6 +180,12 @@ const EMBEDDED_DEFAULTS = { 13, 88, 13, 91, 68, 89, 65, 21, 72, 26, 21, 76, 0, 65, 93, 2, 26, 23, 28, 87, 14, 87, 8, 95, 12, 17, 70, 6, 24, 66, 17, 1, 10, 95, 81, 28, ], + // Microsoft 365 Copilot web (m365.cloud.microsoft) — public SPA client id + // observed in browser tokens and M365-Copilot2API. Not a per-user secret. + m365_oauth_client_id: [ + 12, 93, 15, 11, 74, 12, 16, 77, 72, 72, 73, 20, 82, 65, 93, 81, 72, 65, 28, 13, 93, 88, 93, 95, + 92, 70, 16, 81, 31, 66, 17, 4, 88, 88, 5, 28, + ], // Microsoft Edge Read Aloud (EdgeTTS) — public "trusted client token" used to // derive the Sec-MS-GEC anti-abuse header. Hardcoded in every known Edge // browser build and every open-source edge-tts reimplementation (e.g. diff --git a/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts index 375057cf77..a1d69ebf6d 100644 --- a/open-sse/utils/reasoningContentInjector.ts +++ b/open-sse/utils/reasoningContentInjector.ts @@ -13,8 +13,6 @@ * that proxy to thinking-mode models. */ -import { requiresReasoningReplay } from "../services/reasoningCache.ts"; - const PLACEHOLDER = " "; type JsonRecord = Record; @@ -31,6 +29,26 @@ const THINKING_MODEL_PATTERNS: RegExp[] = [ /\bminimax\b/i, /\bmimo\b/i, // xiaomi-tokenplan mimo family (e.g. xiaomi-tokenplan/mimo-v2.5-pro) ]; +const K3_AUTHENTIC_REASONING_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i; +const NATIVE_K27_AUTHENTIC_REASONING_PATTERN = /(?:^|\/)kimi-k2\.7-code(?:$|-)/i; + +/** + * K3 requires authentic reasoning regardless of which provider serves it. + * Native Moonshot K2.7 retains the same preserved-thinking contract. Empty + * protocol markers remain valid only after client content and replay miss. + */ +export function requiresAuthenticReasoningContent(provider: unknown, model: unknown): boolean { + const normalizedModel = String(model ?? "").trim(); + if (K3_AUTHENTIC_REASONING_PATTERN.test(normalizedModel)) return true; + + const normalizedProvider = String(provider ?? "") + .trim() + .toLowerCase(); + return ( + (normalizedProvider === "moonshot" || normalizedProvider === "kimi") && + NATIVE_K27_AUTHENTIC_REASONING_PATTERN.test(normalizedModel) + ); +} export function isThinkingMessageModel(model: string | undefined | null): boolean { if (!model || typeof model !== "string") return false; @@ -46,11 +64,7 @@ export function shouldInjectReasoningContentPlaceholder( .toLowerCase(); return ( (normalizedProvider === "moonshot" || normalizedProvider === "kimi") && - !requiresReasoningReplay({ - provider: normalizedProvider, - model: String(model ?? ""), - allowLegacyFallback: false, - }) && + !requiresAuthenticReasoningContent(normalizedProvider, model) && isThinkingMessageModel(model) ); } diff --git a/open-sse/utils/responsesOutputIndexStack.ts b/open-sse/utils/responsesOutputIndexStack.ts deleted file mode 100644 index 5eab5b4ca8..0000000000 --- a/open-sse/utils/responsesOutputIndexStack.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @file responsesOutputIndexStack.ts - * @description Structural guard against the Responses-API output_index - * collision bug class (OpenClaw issue #123342): a hand-tracked output_index - * that an emitter forgets to close before the same number gets reused. - * - * Responses-API output items open and close one at a time within any single - * emitter — there is never a real need to hold two indices open - * simultaneously from one emitter's own bookkeeping. Modeling allocation as - * a stack makes "forgot to close" a structural impossibility instead of a - * silent bug: open() always returns the next sequential index, close() - * requires the caller to name the index being closed and throws if it does - * not match the top of the stack, and assertAllClosed() — called once the - * caller has finished building its frame/events — throws if anything is - * still open. For a module-level constant frame (like the early keepalive - * placeholder), that last check runs at import time: a regression here fails - * the build/boot instead of shipping a malformed stream to production. - */ - -export class ResponsesOutputIndexStack { - private readonly openIndices: number[] = []; - private nextIndex = 0; - - open(): number { - const index = this.nextIndex; - this.nextIndex += 1; - this.openIndices.push(index); - return index; - } - - close(index: number): void { - const top = this.openIndices.at(-1); - if (top !== index) { - throw new Error( - `ResponsesOutputIndexStack: closing output_index ${index} but the open top was ${String(top)}` - ); - } - this.openIndices.pop(); - } - - assertAllClosed(): void { - if (this.openIndices.length > 0) { - throw new Error( - `ResponsesOutputIndexStack: output_index(es) still open with no close(): ${this.openIndices.join(", ")}` - ); - } - } -} diff --git a/open-sse/utils/responsesStreamHelpers.ts b/open-sse/utils/responsesStreamHelpers.ts index ce40999eb8..a2cba80fc1 100644 --- a/open-sse/utils/responsesStreamHelpers.ts +++ b/open-sse/utils/responsesStreamHelpers.ts @@ -121,6 +121,29 @@ export function pushUniqueResponsesOutputItems(target: unknown[], items: readonl } } +/** + * #10156 — strip items matched by `isCommentaryItem` (the same predicate used + * to drop live commentary-phase SSE frames, #6199) from a `response.completed` + * output array before it is forwarded or buffered for backfill. Upstreams may + * echo an already-dropped commentary item back inside a non-empty terminal + * `output` array; without this, the live stream and the terminal snapshot + * silently disagree about what the client actually saw. + */ +export function filterResponsesCommentaryFromItems( + items: readonly unknown[], + isCommentaryItem: (item: unknown) => boolean +): { items: unknown[]; changed: boolean } { + let changed = false; + const filtered = items.filter((item) => { + if (isCommentaryItem(item)) { + changed = true; + return false; + } + return true; + }); + return { items: filtered, changed }; +} + export function backfillResponsesCompletedOutput( parsed: unknown, collectedItems: readonly unknown[] diff --git a/open-sse/utils/sseHeartbeat.ts b/open-sse/utils/sseHeartbeat.ts index bdb355b96e..62574bd2e2 100644 --- a/open-sse/utils/sseHeartbeat.ts +++ b/open-sse/utils/sseHeartbeat.ts @@ -5,8 +5,16 @@ * @changes * - [2026-07-28] [Cursor Grok 4.5] - Brand-neutral default OpenAI keepalive id/model */ +const HEARTBEAT_ENCODER = new TextEncoder(); +const OPENAI_RESPONSES_IN_PROGRESS_PAYLOAD = 'data: {"type":"response.in_progress"}\n\n'; + export const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 15_000; +/** Shared Responses API heartbeat frame for early and mid-stream keepalives. */ +export const OPENAI_RESPONSES_IN_PROGRESS_FRAME = HEARTBEAT_ENCODER.encode( + OPENAI_RESPONSES_IN_PROGRESS_PAYLOAD +); + export const HEARTBEAT_SHAPES = { COMMENT: "comment", ANTHROPIC_PING: "anthropic-ping", @@ -41,7 +49,7 @@ function buildHeartbeatPayload( case HEARTBEAT_SHAPES.ANTHROPIC_PING: return 'event: ping\ndata: {"type":"ping"}\n\n'; case HEARTBEAT_SHAPES.OPENAI_RESPONSES_IN_PROGRESS: - return 'data: {"type":"response.in_progress"}\n\n'; + return OPENAI_RESPONSES_IN_PROGRESS_PAYLOAD; case HEARTBEAT_SHAPES.OPENAI_CHUNK: { const payload = { id: opts.chunkId ?? "chatcmpl-keepalive", @@ -66,8 +74,6 @@ type SseHeartbeatTransformOptions = { chunkModel?: string; }; -const HEARTBEAT_ENCODER = new TextEncoder(); - /** * Whether OmniRoute may emit SSE `:` comment lines (e.g. the `: keepalive` heartbeat). * Some strict OpenAI-compatible clients parse every SSE line as JSON and crash on `:` comments. diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index e6547bb3e5..4eab8ea7fc 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -36,6 +36,7 @@ import { import { STREAM_IDLE_TIMEOUT_MS, FETCH_BODY_TIMEOUT_MS, HTTP_STATUS } from "../config/constants.ts"; import { OMIT_STREAMING_CHUNK_MARKER, + isResponsesCommentaryMessageItem, sanitizeStreamingChunk, } from "../handlers/responseSanitizer.ts"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; @@ -59,6 +60,7 @@ import { } from "../services/sessionManager.ts"; import { backfillResponsesCompletedOutput, + filterResponsesCommentaryFromItems, normalizeResponsesCompletedUsage as normalizeUsage, normalizeResponsesSseIds, pushUniqueResponsesOutputItems, @@ -81,6 +83,7 @@ import { import { restoreClaudeToolName } from "../services/claudeCodeToolRemapper.ts"; import { normalizeFinalOpenAIStreamChunk } from "./openAIStreamChunk.ts"; import { collectClaudeDelta } from "./streamClaudeDelta.ts"; +import { createStreamTiming, type StreamTiming } from "./streamTiming.ts"; /** * Race a response body read against a timeout. @@ -129,7 +132,15 @@ type StreamCompletePayload = { clientPayload?: unknown; error?: string | null; errorCode?: string | null; + /** + * Time-to-first-forwarded-SSE-chunk in ms, or null when nothing was forwarded. + * NOT token-level TTFT — see open-sse/utils/streamTiming.ts for what is measured. + */ ttft?: number | null; + /** Mean inter-chunk gap in ms (chunk-latency proxy for ITL), or null. */ + itlMs?: number | null; + /** True when the stream was interrupted (timeout/abort/error) before a clean finish. */ + interrupted?: boolean; }; type StreamOptions = { @@ -577,7 +588,10 @@ function getOpenAIIntermediateChunks(value: unknown): unknown[] { return Array.isArray(candidate) ? candidate : []; } -export function restoreClaudePassthroughToolUseName(parsed: JsonRecord, toolNameMap: unknown): boolean { +export function restoreClaudePassthroughToolUseName( + parsed: JsonRecord, + toolNameMap: unknown +): boolean { const block = parsed.content_block && typeof parsed.content_block === "object" ? (parsed.content_block as JsonRecord) @@ -660,6 +674,16 @@ export function createSSEStream(options: StreamOptions = {}) { performance.clearMarks("omni-request-body-size"); } + // Canonical streaming timing (TTFT / ITL / interruption). One instance per + // stream, marked from the transform below. ttft() = first-forwarded-SSE-chunk + // latency (NOT token-level) — see streamTiming.ts. + const timing: StreamTiming = createStreamTiming(); + /** Forward a pre-encoded SSE chunk, marking TTFT/ITL on the way. */ + const forward = (controller: TransformStreamDefaultController, bytes: Uint8Array) => { + timing.markForward(); + controller.enqueue(bytes); + }; + // Drop internal commentary-phase Responses output before forwarding (#6199). // Explicit option wins; otherwise read the feature flag (default on) — resolved once per stream. const shouldDropResponsesCommentary = @@ -948,7 +972,7 @@ export function createSSEStream(options: StreamOptions = {}) { clientPayloadCollector.push(event); const output = formatSSE(event, FORMATS.CLAUDE); reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + forward(controller, encoder.encode(output)); } }; @@ -973,7 +997,8 @@ export function createSSEStream(options: StreamOptions = {}) { const errOutput = formatSSE(errorEvent, FORMATS.CLAUDE); reqLogger?.appendConvertedChunk?.(errOutput); clientPayloadCollector.push(errorEvent); - controller.enqueue(encoder.encode(errOutput)); + forward(controller, encoder.encode(errOutput)); + timing.markInterrupted(); let failureHandled = false; if (onFailure) { try { @@ -1034,7 +1059,7 @@ export function createSSEStream(options: StreamOptions = {}) { clientPayloadCollector.push(itemSanitized); reqLogger?.appendConvertedChunk?.(output); forwardedValuableChunk = true; - controller.enqueue(encoder.encode(output)); + forward(controller, encoder.encode(output)); }; const emitFinalSseMetadata = async ( @@ -1059,7 +1084,7 @@ export function createSSEStream(options: StreamOptions = {}) { }); if (!comment) return; reqLogger?.appendConvertedChunk?.(comment); - controller.enqueue(encoder.encode(comment)); + forward(controller, encoder.encode(comment)); }; const getResponsesReasoningKey = (payload: Record): string | null => { @@ -1146,7 +1171,7 @@ export function createSSEStream(options: StreamOptions = {}) { clientPayloadCollector.push(syntheticEvent.body); const output = `event: ${syntheticEvent.event}\ndata: ${JSON.stringify(syntheticEvent.body)}\n\n`; reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + forward(controller, encoder.encode(output)); } }; @@ -1164,6 +1189,7 @@ export function createSSEStream(options: StreamOptions = {}) { let failureHandled = false; if (onFailure) { try { + timing.markInterrupted(); failureHandled = onFailure({ status: HTTP_STATUS.GATEWAY_TIMEOUT, @@ -1195,6 +1221,7 @@ export function createSSEStream(options: StreamOptions = {}) { transform(chunk, controller) { if (streamTimedOut) return; const now = Date.now(); + timing.markByte(); lastChunkTime = now; const text = decoder.decode(chunk, { stream: true }); buffer += text; @@ -1253,7 +1280,7 @@ export function createSSEStream(options: StreamOptions = {}) { const pendingOutput = passthroughEventPrefix.flush(); if (pendingOutput) { reqLogger?.appendConvertedChunk?.(pendingOutput); - controller.enqueue(encoder.encode(pendingOutput)); + forward(controller, encoder.encode(pendingOutput)); } clearPendingPassthroughEvent(); continue; @@ -1420,7 +1447,7 @@ export function createSSEStream(options: StreamOptions = {}) { clientPayloadCollector.push(event); } reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + forward(controller, encoder.encode(output)); injectedUsage = true; } else { output = `data: ${JSON.stringify(parsed)}\n\n`; @@ -1540,11 +1567,26 @@ export function createSSEStream(options: StreamOptions = {}) { } } } + let responsesCommentaryStrippedFromCompleted = false; if ( parsed.type === "response.completed" && Array.isArray(parsed.response?.output) && parsed.response.output.length > 0 ) { + // #10156 — an upstream may echo a `phase:"commentary"` item back + // inside a non-empty terminal `output` array even though its live + // SSE frames were already dropped above. Keep both representations + // consistent by applying the same drop here. + if (shouldDropResponsesCommentary) { + const { items, changed } = filterResponsesCommentaryFromItems( + parsed.response.output, + isResponsesCommentaryMessageItem + ); + if (changed) { + parsed.response.output = items; + responsesCommentaryStrippedFromCompleted = true; + } + } pushUniqueResponsesOutputItems( passthroughResponsesOutputItems, parsed.response.output @@ -1588,9 +1630,19 @@ export function createSSEStream(options: StreamOptions = {}) { ]) as typeof parsed; } const stripped = stripResponsesLifecycleEcho(parsed); + // Belt-and-suspenders for #10156: filter the backfill buffer itself + // before it can seed an empty `response.completed.response.output`, + // in case a future code path pushes a commentary item into it + // without going through the response.completed branch above. + const backfillCandidates = shouldDropResponsesCommentary + ? filterResponsesCommentaryFromItems( + passthroughResponsesOutputItems, + isResponsesCommentaryMessageItem + ).items + : passthroughResponsesOutputItems; const backfilled = backfillResponsesCompletedOutput( parsed, - passthroughResponsesOutputItems + backfillCandidates ); const usageNormalized = normalizeUsage(parsed); if ( @@ -1598,7 +1650,8 @@ export function createSSEStream(options: StreamOptions = {}) { backfilled || textualToolCallBackfilled || responsesIdsNormalized || - usageNormalized + usageNormalized || + responsesCommentaryStrippedFromCompleted ) { output = `data: ${JSON.stringify(parsed)}\n\n`; injectedUsage = true; @@ -1709,7 +1762,7 @@ export function createSSEStream(options: StreamOptions = {}) { clientPayload = parsed; clientPayloadCollector.push(clientPayload); reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + forward(controller, encoder.encode(output)); continue; } @@ -1785,7 +1838,7 @@ export function createSSEStream(options: StreamOptions = {}) { totalContentLength += delta.reasoning_content.length; clientPayloadCollector.push(reasoningChunk); reqLogger?.appendConvertedChunk?.(rOutput); - controller.enqueue(encoder.encode(rOutput)); + forward(controller, encoder.encode(rOutput)); delete delta.reasoning_content; splitMixedReasoningContent = true; } @@ -1964,7 +2017,7 @@ export function createSSEStream(options: StreamOptions = {}) { } reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + forward(controller, encoder.encode(output)); if (failurePayload) { let failureHandled = false; if (onFailure) { @@ -2004,7 +2057,7 @@ export function createSSEStream(options: StreamOptions = {}) { if (parsed.error) { const output = formatTranslatedStreamError(parsed, sourceFormat); reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + forward(controller, encoder.encode(output)); upstreamErrorForwarded = true; doneSent = true; continue; @@ -2223,7 +2276,7 @@ export function createSSEStream(options: StreamOptions = {}) { passthroughEventPrefix, emitConvertedOutput: (output: string) => { reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + forward(controller, encoder.encode(output)); }, pushProviderPayload: (payload: unknown) => providerPayloadCollector.push(payload), pushClientPayload: (payload: unknown) => clientPayloadCollector.push(payload), @@ -2336,7 +2389,7 @@ export function createSSEStream(options: StreamOptions = {}) { output = output.endsWith("\n") ? `${output}\n` : `${output}\n\n`; } reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + forward(controller, encoder.encode(output)); } if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) { @@ -2380,7 +2433,7 @@ export function createSSEStream(options: StreamOptions = {}) { flushOutput = `data: ${JSON.stringify(syntheticChunk)}\n\n`; } reqLogger?.appendConvertedChunk?.(flushOutput); - controller.enqueue(encoder.encode(flushOutput)); + forward(controller, encoder.encode(flushOutput)); passthroughAccumulatedContent = appendBoundedText( passthroughAccumulatedContent, passthroughBufferedTextualToolCallContent @@ -2397,7 +2450,7 @@ export function createSSEStream(options: StreamOptions = {}) { totalContentLength += thinkFlush.addedLength; clientPayloadCollector.push(thinkFlush.syntheticChunk); reqLogger?.appendConvertedChunk?.(thinkFlush.flushOutput); - controller.enqueue(encoder.encode(thinkFlush.flushOutput)); + forward(controller, encoder.encode(thinkFlush.flushOutput)); } // Estimate usage if provider didn't return valid usage @@ -2431,7 +2484,7 @@ export function createSSEStream(options: StreamOptions = {}) { ); const finishOutput = `data: ${JSON.stringify(syntheticFinishChunk)}\n\n`; reqLogger?.appendConvertedChunk?.(finishOutput); - controller.enqueue(encoder.encode(finishOutput)); + forward(controller, encoder.encode(finishOutput)); clientPayloadCollector.push(syntheticFinishChunk); } await emitFinalSseMetadata(controller, usage); @@ -2440,7 +2493,7 @@ export function createSSEStream(options: StreamOptions = {}) { clientPayloadCollector.push({ done: true }); const doneOutput = "data: [DONE]\n\n"; reqLogger?.appendConvertedChunk?.(doneOutput); - controller.enqueue(encoder.encode(doneOutput)); + forward(controller, encoder.encode(doneOutput)); } } // Notify caller for call log persistence (include full response body with accumulated content) @@ -2514,6 +2567,9 @@ export function createSSEStream(options: StreamOptions = {}) { status: 200, usage, responseBody, + ttft: timing.ttftMs(), + itlMs: timing.avgItlMs(), + interrupted: timing.interrupted, // #9315 switched the summary to the accumulated responseBody to avoid // stale/truncated event data — but responseBody here is synthesized in // chat-completion shape, which loses the Responses API `response` object. @@ -2616,6 +2672,7 @@ export function createSSEStream(options: StreamOptions = {}) { let failureHandled = false; if (onFailure) { try { + timing.markInterrupted(); failureHandled = onFailure({ status: err.status, @@ -2635,6 +2692,9 @@ export function createSSEStream(options: StreamOptions = {}) { status: err.status, usage: state?.usage, responseBody: errorBody, + ttft: timing.ttftMs(), + itlMs: timing.avgItlMs(), + interrupted: timing.interrupted, error: err.message, errorCode: err.code, providerPayload: providerPayloadCollector.build( @@ -2731,7 +2791,7 @@ export function createSSEStream(options: StreamOptions = {}) { clientPayloadCollector.push({ done: true }); const doneOutput = "data: [DONE]\n\n"; reqLogger?.appendConvertedChunk?.(doneOutput); - controller.enqueue(encoder.encode(doneOutput)); + forward(controller, encoder.encode(doneOutput)); } } diff --git a/open-sse/utils/streamTiming.ts b/open-sse/utils/streamTiming.ts new file mode 100644 index 0000000000..9f26f5d5b3 --- /dev/null +++ b/open-sse/utils/streamTiming.ts @@ -0,0 +1,83 @@ +/** + * Canonical streaming timing instrumentation (TTFT / ITL / interruption). + * + * One reusable seam for measuring the streaming path. It is created once per + * stream and marked from the SSE transform: + * + * markByte() — first upstream chunk received (bytes arrived from provider) + * markForward() — first chunk forwarded to the client (first SSE chunk enqueued) + * + * `ttft()` is therefore **first-forwarded-SSE-chunk latency**, NOT token-level + * TTFT. We document that distinction explicitly: a single SSE chunk can carry + * zero, one, or many tokens, and chunk boundaries do not map to token + * boundaries. If a future implementation can measure actual token timing it + * should extend this seam, not bypass it. + * + * ITL (inter-token latency) is approximated by the mean gap between forwarded + * SSE chunks (bounded sample window). It is a chunk-latency proxy, again not + * true token timing — callers must label it as such. + * + * The object is cheap to construct, plain mutable state, and safe under the + * event loop's single thread (each stream owns its own instance). + */ +export interface StreamTiming { + startedAt: number; + firstByteAt: number | null; + firstForwardAt: number | null; + lastForwardAt: number | null; + /** Mean gap between forwarded chunks (ms), bounded window. */ + interChunkGaps: number[]; + forwardedChunks: number; + interrupted: boolean; + markByte(): void; + markForward(): void; + markInterrupted(): void; + /** First-forwarded-SSE-chunk latency in ms, or null if nothing was forwarded. */ + ttftMs(): number | null; + /** Mean inter-chunk gap in ms, or null when fewer than 2 chunks were forwarded. */ + avgItlMs(): number | null; + /** Time from stream start to completion (ms). */ + totalMs(): number; +} + +/** Max number of inter-chunk samples kept (bounds memory). */ +const MAX_INTER_CHUNK_GAPS = 32; + +export function createStreamTiming(): StreamTiming { + const timing: StreamTiming = { + startedAt: Date.now(), + firstByteAt: null, + firstForwardAt: null, + lastForwardAt: null, + interChunkGaps: [], + forwardedChunks: 0, + interrupted: false, + markByte() { + if (this.firstByteAt === null) this.firstByteAt = Date.now(); + }, + markForward() { + const now = Date.now(); + if (this.firstForwardAt === null) this.firstForwardAt = now; + if (this.lastForwardAt !== null && this.interChunkGaps.length < MAX_INTER_CHUNK_GAPS) { + this.interChunkGaps.push(now - this.lastForwardAt); + } + this.lastForwardAt = now; + this.forwardedChunks += 1; + }, + markInterrupted() { + this.interrupted = true; + }, + ttftMs() { + return this.firstForwardAt === null ? null : this.firstForwardAt - this.startedAt; + }, + avgItlMs() { + if (this.interChunkGaps.length === 0) return null; + const sum = this.interChunkGaps.reduce((a, b) => a + b, 0); + return sum / this.interChunkGaps.length; + }, + totalMs() { + return Date.now() - this.startedAt; + }, + }; + return timing; +} diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index c89527518e..24fe802fda 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -567,6 +567,14 @@ export function sanitizeUsagePayloadForRequest( return replaceUsage(payload.message, "usage", FORMATS.CLAUDE); } if (payload.type === "message_delta" && payload.usage) { + // message_delta is output-only by spec. #10705 0-input repair would + // overwrite a valid message_start input count with an estimate. + const delta = payload.usage; + const deltaInput = + tokenNumber(delta.input_tokens) + + tokenNumber(delta.cache_read_input_tokens) + + tokenNumber(delta.cache_creation_input_tokens); + if (deltaInput === 0) return false; return replaceUsage(payload, "usage", FORMATS.CLAUDE); } if (payload.response?.usage) { diff --git a/package-lock.json b/package-lock.json index 2741e67b37..939fdf0071 100644 --- a/package-lock.json +++ b/package-lock.json @@ -155,8 +155,7 @@ "node": ">=22.22.2 <23 || >=24.0.0 <27" }, "optionalDependencies": { - "@atjsh/llmlingua-2": "2.0.3", - "@tensorflow/tfjs": "4.22.0", + "@atjsh/llmlingua-2": "2.0.5", "better-sqlite3": "^13.0.2", "js-tiktoken": "^1.0.20", "keytar": "^7.9.0", @@ -556,17 +555,16 @@ } }, "node_modules/@atjsh/llmlingua-2": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@atjsh/llmlingua-2/-/llmlingua-2-2.0.3.tgz", - "integrity": "sha512-UJJFMbzYldkZ4qX5CrSZtmytOnXf6aXhmr1sBhbpVMHdmQG+7GCnrx5rIwPSOmozXD9KiPv5nnV6pvzxdtHdYQ==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@atjsh/llmlingua-2/-/llmlingua-2-2.0.5.tgz", + "integrity": "sha512-cXdGUJgx0e2Sui5gYC8kapOhw1HAxwzh9IuYPdqyB+VlP6SL9imIfyB7I4GTCl/iG+BUxaOqSrLqWsWYvDZuVQ==", "license": "MIT", "optional": true, "dependencies": { "es-toolkit": "^1.38.0" }, "peerDependencies": { - "@huggingface/transformers": "*", - "@tensorflow/tfjs": "*", + "@huggingface/transformers": "^3.5.2 || ^4.0.0", "js-tiktoken": "*" } }, @@ -12241,241 +12239,6 @@ "tailwindcss": "4.3.3" } }, - "node_modules/@tensorflow/tfjs": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-4.22.0.tgz", - "integrity": "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@tensorflow/tfjs-backend-cpu": "4.22.0", - "@tensorflow/tfjs-backend-webgl": "4.22.0", - "@tensorflow/tfjs-converter": "4.22.0", - "@tensorflow/tfjs-core": "4.22.0", - "@tensorflow/tfjs-data": "4.22.0", - "@tensorflow/tfjs-layers": "4.22.0", - "argparse": "^1.0.10", - "chalk": "^4.1.0", - "core-js": "3.29.1", - "regenerator-runtime": "^0.13.5", - "yargs": "^16.0.3" - }, - "bin": { - "tfjs-custom-module": "dist/tools/custom_module/cli.js" - } - }, - "node_modules/@tensorflow/tfjs-backend-cpu": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz", - "integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@types/seedrandom": "^2.4.28", - "seedrandom": "^3.0.5" - }, - "engines": { - "yarn": ">= 1.3.2" - }, - "peerDependencies": { - "@tensorflow/tfjs-core": "4.22.0" - } - }, - "node_modules/@tensorflow/tfjs-backend-webgl": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz", - "integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@tensorflow/tfjs-backend-cpu": "4.22.0", - "@types/offscreencanvas": "~2019.3.0", - "@types/seedrandom": "^2.4.28", - "seedrandom": "^3.0.5" - }, - "engines": { - "yarn": ">= 1.3.2" - }, - "peerDependencies": { - "@tensorflow/tfjs-core": "4.22.0" - } - }, - "node_modules/@tensorflow/tfjs-converter": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz", - "integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==", - "license": "Apache-2.0", - "optional": true, - "peerDependencies": { - "@tensorflow/tfjs-core": "4.22.0" - } - }, - "node_modules/@tensorflow/tfjs-core": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz", - "integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@types/long": "^4.0.1", - "@types/offscreencanvas": "~2019.7.0", - "@types/seedrandom": "^2.4.28", - "@webgpu/types": "0.1.38", - "long": "4.0.0", - "node-fetch": "~2.6.1", - "seedrandom": "^3.0.5" - }, - "engines": { - "yarn": ">= 1.3.2" - } - }, - "node_modules/@tensorflow/tfjs-core/node_modules/@types/offscreencanvas": { - "version": "2019.7.3", - "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", - "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", - "license": "MIT", - "optional": true - }, - "node_modules/@tensorflow/tfjs-data": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-data/-/tfjs-data-4.22.0.tgz", - "integrity": "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@types/node-fetch": "^2.1.2", - "node-fetch": "~2.6.1", - "string_decoder": "^1.3.0" - }, - "peerDependencies": { - "@tensorflow/tfjs-core": "4.22.0", - "seedrandom": "^3.0.5" - } - }, - "node_modules/@tensorflow/tfjs-layers": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-layers/-/tfjs-layers-4.22.0.tgz", - "integrity": "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA==", - "license": "Apache-2.0 AND MIT", - "optional": true, - "peerDependencies": { - "@tensorflow/tfjs-core": "4.22.0" - } - }, - "node_modules/@tensorflow/tfjs/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "optional": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@tensorflow/tfjs/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/@tensorflow/tfjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true - }, - "node_modules/@tensorflow/tfjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@tensorflow/tfjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@tensorflow/tfjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@tensorflow/tfjs/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@tensorflow/tfjs/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "license": "MIT", - "optional": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@tensorflow/tfjs/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=10" - } - }, "node_modules/@testing-library/jest-dom": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", @@ -13022,13 +12785,6 @@ "@types/node": "*" } }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT", - "optional": true - }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -13059,24 +12815,6 @@ "undici-types": "~8.3.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/offscreencanvas": { - "version": "2019.3.0", - "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz", - "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==", - "license": "MIT", - "optional": true - }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -13153,13 +12891,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/seedrandom": { - "version": "2.4.34", - "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.34.tgz", - "integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==", - "license": "MIT", - "optional": true - }, "node_modules/@types/tough-cookie": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz", @@ -13978,13 +13709,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@webgpu/types": { - "version": "0.1.38", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz", - "integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==", - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/@xmldom/xmldom": { "version": "0.9.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", @@ -17208,18 +16932,6 @@ "node": ">=6.6.0" } }, - "node_modules/core-js": { - "version": "3.29.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.29.1.tgz", - "integrity": "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -26774,13 +26486,6 @@ "node": ">=0.1.90" } }, - "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0", - "optional": true - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -29266,52 +28971,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==", - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -33071,13 +32730,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "optional": true - }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -33916,7 +33568,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/selfsigned": { @@ -34761,13 +34413,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/sql.js": { "version": "1.14.2", "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz", diff --git a/package.json b/package.json index 64d600aa04..5dcd258e8b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", "version": "3.8.50", - "description": "Unified AI router with 341 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "description": "Unified AI router with 346 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", @@ -89,6 +89,7 @@ "gen:provider-reference": "bun scripts/docs/gen-provider-reference.ts", "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", "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", @@ -341,8 +342,7 @@ "onnxruntime-node": "~1.24.3" }, "optionalDependencies": { - "@atjsh/llmlingua-2": "2.0.3", - "@tensorflow/tfjs": "4.22.0", + "@atjsh/llmlingua-2": "2.0.5", "better-sqlite3": "^13.0.2", "js-tiktoken": "^1.0.20", "keytar": "^7.9.0", diff --git a/public/openapi.yaml b/public/openapi.yaml index ca00d23623..caa5aac6ea 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -5270,12 +5270,18 @@ components: BearerAuth: type: http scheme: bearer - description: API key obtained from the OmniRoute dashboard + description: > + Two bearer families are accepted. Inference API keys (typically `sk-…`) + authorize `/v1/*`. Management routes also accept `oma_live_…` Access Tokens + (Settings → Access Tokens / `omniroute connect`) and API keys whose metadata + includes `manage` or `admin` scope. See docs/guides/MANAGEMENT-AUTH.md. + Bearer credentials are accepted on management routes that use this scheme; + they are not rejected solely for being Bearer. ManagementSessionAuth: type: apiKey in: cookie name: auth_token - description: Dashboard management session cookie for protected management routes + description: Dashboard management session cookie (auth_token) for protected management routes. Distinct from Bearer Access Tokens and API keys. See docs/guides/MANAGEMENT-AUTH.md. parameters: ResourceId: diff --git a/public/providers/freebuff-dark.svg b/public/providers/freebuff-dark.svg new file mode 100644 index 0000000000..0a6d1156fb --- /dev/null +++ b/public/providers/freebuff-dark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/providers/freebuff-light.svg b/public/providers/freebuff-light.svg new file mode 100644 index 0000000000..0a6d1156fb --- /dev/null +++ b/public/providers/freebuff-light.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/providers/freebuff.png b/public/providers/freebuff.png new file mode 100644 index 0000000000..54806e0831 Binary files /dev/null and b/public/providers/freebuff.png differ diff --git a/public/providers/freebuff.svg b/public/providers/freebuff.svg new file mode 100644 index 0000000000..0a6d1156fb --- /dev/null +++ b/public/providers/freebuff.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/scripts/ad-hoc/discord-en.json b/scripts/ad-hoc/discord-en.json new file mode 100644 index 0000000000..c0d668592b --- /dev/null +++ b/scripts/ad-hoc/discord-en.json @@ -0,0 +1,212 @@ +[ + { + "bucket": "A", + "match": "failed to load external module playwright", + "text": "That error means the Playwright install shipped with OmniRoute is broken, not that you misconfigured anything. Reinstall with npm i -g omniroute and run npx playwright install chromium on the same host. See open-sse/executors/gemini-web.ts." + }, + { + "bucket": "A", + "match": "duckduckgo ai chat error", + "text": "That ERR_BAD_REQUEST usually means the model you picked is retired or unknown in Duck.ai's lineup, or a reasoningEffort setting the lineup doesn't accept. Try a current model like gpt-5.4-mini. See open-sse/executors/duckduckgo-web.ts." + }, + { + "bucket": "A", + "match": "what does endpoints do", + "text": "Endpoints are the OpenAI-compatible surface OmniRoute exposes. You point any client at base http://localhost:20128/v1 with your API key and it behaves like a normal provider. For opencode there is a dedicated guide at docs/frameworks/OPENCODE.md." + }, + { + "bucket": "A", + "match": "setup omniroute in opencode", + "text": "You don't need /connect. Run 'omniroute config opencode --base-url http://localhost:20128 --api-key YOUR_KEY' and point opencode at it. The most common bug is ending with /v1/v1, so keep a single /v1. See docs/frameworks/OPENCODE.md." + }, + { + "bucket": "A", + "match": "best way to integrate jules", + "text": "Use the Cloud Agents API: POST /api/v1/agents/tasks with providerId jules and OmniRoute spins a remote agent for that task. Selection is manual per task and control is via REST or the dashboard. See docs/frameworks/CLOUD_AGENT.md." + }, + { + "bucket": "A", + "match": "codex cloud and devin", + "text": "Same API, just swap the providerId: jules, devin, codex-cloud or cursor-cloud. Antigravity and Qwen are chat providers, not cloud agents, so they stay on chat routes. The choice is manual per task. See docs/frameworks/CLOUD_AGENT.md." + }, + { + "bucket": "A", + "match": "handle everything from claude", + "text": "Not quite. Cloud agents are controlled through the REST API and the dashboard, not through Claude Code or MCP. So keep them as separate tooling that talks to OmniRoute. See docs/frameworks/CLOUD_AGENT.md." + }, + { + "bucket": "A", + "match": "huggingchat returned http 500", + "text": "A 500 is a passthrough from the upstream HuggingChat endpoint (huggingface.co/chat), not something in your config. Just retry; if it keeps failing the service itself is likely having trouble. See open-sse/executors/huggingchat.ts." + }, + { + "bucket": "A", + "match": "use this on termux", + "text": "In Termux run 'pkg install nodejs' and then 'npx -y omniroute' to start the server. Your phone browser opens the dashboard over localhost afterwards. Walkthrough at docs/guides/TERMUX_GUIDE.md." + }, + { + "bucket": "A", + "match": "run the entire thing im on android", + "text": "You run everything in Termux with no root: pkg install nodejs, then npx -y omniroute starts the server. The dashboard opens in your phone's browser and all of it stays on the device." + }, + { + "bucket": "A", + "match": "i dont have omniroute", + "text": "Quick start: npm i -g omniroute on any machine with Node. Start it, open http://localhost:20128, and the auto model already answers so you don't even need an API key to try it." + }, + { + "bucket": "A", + "match": "api endpoints allowed", + "text": "Endpoints are their own API surface: anyone with a valid API key can call them. To lock it down, set REQUIRE_API_KEY=true so only the keys you issue get access. See docs/getting-started/QUICK-START.md." + }, + { + "bucket": "A", + "match": "need which host", + "text": "The host is wherever you run the server, localhost:20128 by default. Clients just need the base URL (http://host:20128/v1) plus an API key, so a VPS or Fly instance works the same." + }, + { + "bucket": "A", + "match": "hosting web in cpanel", + "text": "Self-host anywhere Node runs: a VPS, Docker or Fly.io. cPanel usually can't keep a long-running Node process alive, so prefer a real server or container. See docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md." + }, + { + "bucket": "A", + "match": "run fly.io docker file", + "text": "Use the repo's fly.toml: fly launch and then fly deploy, and the Dockerfile builds the image. Full steps and env vars are in docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md." + }, + { + "bucket": "A", + "match": "website https://fly.io", + "text": "Yes, the site runs on Fly.io, that's the host. From the repo run fly launch and fly deploy, and the app gets a public URL on a domain you own." + }, + { + "bucket": "A", + "match": "github are down", + "text": "You don't need GitHub to run OmniRoute. It installs straight from npm and you host it anywhere you want, a VPS, Docker, or Fly. GitHub matters only if you build from source." + }, + { + "bucket": "A", + "match": "2000 models is there a better way", + "text": "With that many models, Auto-Combo is the way: set the model to auto, auto/coding, auto/fast or auto/cheap and OmniRoute scores every option per request. The 14-factor scorer is in docs/routing/AUTO-COMBO.md." + }, + { + "bucket": "A", + "match": "is there a combo already", + "text": "Yes, there is a ready one for exactly this: auto/coding. It picks a good free coding model with no setup. The other auto strategies are explained in docs/routing/AUTO-COMBO.md." + }, + { + "bucket": "A", + "match": "where do i put auto", + "text": "You set it as the model field on your client exactly like a model name: auto/coding, or auto/fast and auto/cheap for other strategies. Their differences are in docs/routing/AUTO-COMBO.md." + }, + { + "bucket": "A", + "match": "dont see my combos as models", + "text": "Only auto/ combos are advertised in /v1/models. Custom combos are internal destinations that never appear in the list, so call them directly by the combo id you set up." + }, + { + "bucket": "A", + "match": "where is my circuit breaker", + "text": "It lives in the dashboard Health tab, in the circuit breaker states section, one status per provider. The closed, open, half-open model is in docs/architecture/RESILIENCE_GUIDE.md." + }, + { + "bucket": "A", + "match": "with claude desktop app", + "text": "Two ways: Claude Code pointed at OmniRoute via ANTHROPIC_BASE_URL plus setup-claude, or the Claude Desktop app as an MCP client via omniroute --mcp. Both are in docs/guides/CLAUDE-CODE-CONFIGURATION.md." + }, + { + "bucket": "A", + "match": "retrying in 30s", + "text": "That is a 429 rate limit from the provider, so retrying is expected. OmniRoute applies the cooldown and can fall back to another key or model automatically, so you don't need to touch anything." + }, + { + "bucket": "A", + "match": "cliproxyapi is configured", + "text": "It is informative, not an error. CLIProxyAPI is an upstream proxy layer, managed at runtime in the CLI Tools and toggled per provider between native, cliproxyapi and fallback modes. See docs/ops/PROXY_GUIDE.md." + }, + { + "bucket": "A", + "match": "getaddrinfo enotfound", + "text": "That is a doubled URL in the proxy registry: the host field carries the scheme. Use type=http, host=127.0.0.1 with no scheme, and port=20130. Steps are in docs/ops/PROXY_GUIDE.md." + }, + { + "bucket": "A", + "match": "proxy connection failed", + "text": "The registry expects type, host and port as separate fields, not one combined URL. Set host to 127.0.0.1 with no scheme and port to 20130, and the connection error clears. Same recipe in docs/ops/PROXY_GUIDE.md." + }, + { + "bucket": "B", + "match": "only 14 providers out of the 50", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "music play when i enable modal", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "do you mean the global proxy", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "provider's limits from docs", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "combine deepseek", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "store limits within the app", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "manually write these limits", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "official omniroute doesn't support", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "set limits in omniroute for a provider", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "create a compact prompt", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "continue your answer from where you left off", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "i am android that sorry", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "B", + "match": "yes", + "text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)" + }, + { + "bucket": "C", + "match": "no such tool available: bash", + "text": "Here I only help with OmniRoute questions :)" + }, + { + "bucket": "C", + "match": "interupt the code", + "text": "Here I only help with OmniRoute questions :)" + } +] diff --git a/scripts/ad-hoc/mesh-run.mjs b/scripts/ad-hoc/mesh-run.mjs new file mode 100644 index 0000000000..95c45efd7d --- /dev/null +++ b/scripts/ad-hoc/mesh-run.mjs @@ -0,0 +1,93 @@ +// Runner generico para a mesh. Recebe um arquivo JSON de plano: +// [ +// { "bucket": "A"|"B"|"C", "match": "", "text": "" } +// ] +// Fases: A=reply (answered), B=notice mode:note (fica pending), C=recusa note + mark ignored (por ultimo). +// Envs: BOT_URL, BOT_TOKEN. Uso: node mesh-run.mjs +import { readFileSync } from "node:fs"; +import { env } from "node:process"; + +const BOT_URL = env.BOT_URL; +const BOT_TOKEN = env.BOT_TOKEN; +const FILTER = "platform=discord&language=en&direct=only"; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function api(path, opts = {}) { + const res = await fetch(BOT_URL + path, { + ...opts, + headers: { + Authorization: "Bearer " + BOT_TOKEN, + "Content-Type": "application/json", + ...(opts.headers || {}), + }, + }); + return { status: res.status, json: await res.json().catch(() => null) }; +} + +const planPath = process.argv[2]; +const plan = JSON.parse(readFileSync(planPath, "utf8")); + +async function main() { + console.log("Fetching pendentes (" + FILTER + ")..."); + const { json } = await api("/internal/bridge/questions?" + FILTER); + const pending = (json && json.data) || []; + console.log("-> " + pending.length + " pendentes"); + + const out = { A: [], B: [], C: [], U: [] }; + + // fase 1-2: A (reply) e B (notice) + for (const msg of pending) { + const t = (msg.text || "").toLowerCase(); + const hit = plan.find((e) => t.includes(e.match.toLowerCase())); + if (!hit) { + out.U.push(msg.id + " :: " + (msg.text || "").slice(0, 60)); + continue; + } + if (hit.bucket === "A") { + const r = await api("/internal/bridge/reply", { + method: "POST", + body: JSON.stringify({ messageId: msg.id, text: hit.text }), + }); + out.A.push(r.status + " " + msg.id); + } else if (hit.bucket === "B") { + const r = await api("/internal/bridge/reply", { + method: "POST", + body: JSON.stringify({ messageId: msg.id, text: hit.text, mode: "note" }), + }); + out.B.push(r.status + " " + msg.id); + } else if (hit.bucket === "C") { + out.C.push(msg.id); + } + await sleep(1000); + } + + // fase 3: bucket C — nota de recusa + mark ignored (por último) + for (const id of out.C) { + const msg = pending.find((m) => m.id === id); + const t = (msg.text || "").toLowerCase(); + const hit = plan.find((e) => e.bucket === "C" && t.includes(e.match.toLowerCase())); + if (!hit) continue; + const note = await api("/internal/bridge/reply", { + method: "POST", + body: JSON.stringify({ messageId: id, text: hit.text, mode: "note" }), + }); + const mark = await api("/internal/bridge/mark", { + method: "POST", + body: JSON.stringify({ messageIds: [id], status: "ignored", ref: "auto-declined" }), + }); + out.C[out.C.indexOf(id)] = + "note:" + note.status + " mark:" + (mark.json && mark.json.updated) + " " + id; + await sleep(1000); + } + + console.log("\n=== RESUMO ==="); + console.log("A (respondidas):", out.A); + console.log("B (notices, pending):", out.B); + console.log("C (recusa+ignoradas):", out.C); + console.log("U (nao classif., relatar):", out.U); +} + +main().catch((e) => { + console.error("ERRO:", e); + process.exit(1); +}); diff --git a/scripts/ad-hoc/mesh-send.mjs b/scripts/ad-hoc/mesh-send.mjs new file mode 100644 index 0000000000..8c6feb6de6 --- /dev/null +++ b/scripts/ad-hoc/mesh-send.mjs @@ -0,0 +1,42 @@ +// Helper único para enviar replies/notes no bridge do bot da mesh. +// Lê BOT_URL e BOT_TOKEN do ambiente (nunca embutidos). +// Uso: BOT_URL=... BOT_TOKEN=... node scripts/ad-hoc/mesh-send.mjs +// cmd: reply | note +import { readFileSync } from "node:fs"; + +const [cmd, path] = process.argv.slice(2); +const BOT_URL = process.env.BOT_URL; +const BOT_TOKEN = process.env.BOT_TOKEN; + +const input = path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8"); +const items = JSON.parse(input); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function send(item) { + // endpoint de reply; mode presente => note + const body = { + messageId: item.id, + text: item.text, + ...(cmd === "note" ? { mode: "note" } : {}), + }; + const res = await fetch(`${BOT_URL}/internal/bridge/reply`, { + method: "POST", + headers: { + Authorization: `Bearer ${BOT_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + const txt = await res.text(); + console.log(`[${cmd}] ${item.id.slice(0, 12)} → ${res.status} ${txt.slice(0, 80)}`); +} + +for (const item of items) { + try { + await send(item); + } catch (e) { + console.log(`[${cmd}] ${item.id.slice(0, 12)} → ERRO ${e.message}`); + } + await sleep(1000); // pace ~1s +} diff --git a/scripts/ad-hoc/verify-coverage.mjs b/scripts/ad-hoc/verify-coverage.mjs new file mode 100644 index 0000000000..05b05384d9 --- /dev/null +++ b/scripts/ad-hoc/verify-coverage.mjs @@ -0,0 +1,36 @@ +// Verificacao de cobertura do plano da mesh. +// Envs: BOT_URL, BOT_TOKEN. Uso: node verify-coverage.mjs +import { readFileSync } from "node:fs"; +import { env } from "node:process"; + +const BOT_URL = env.BOT_URL; +const BOT_TOKEN = env.BOT_TOKEN; +const FILTER = "platform=discord&language=en&direct=only"; + +const plan = JSON.parse(readFileSync(process.argv[2], "utf8")); + +const res = await fetch(BOT_URL + "/internal/bridge/questions?" + FILTER, { + headers: { Authorization: "Bearer " + BOT_TOKEN }, +}); +const json = await res.json(); +const pending = json.data || []; + +const gaps = []; +const amb = []; + +for (const m of pending) { + const t = (m.text || "").toLowerCase(); + const hits = plan.filter((e) => t.includes(e.match.toLowerCase())); + if (hits.length === 0) { + gaps.push(m.id + " :: " + t.slice(0, 80)); + } else if (hits.length > 1) { + const names = hits.map((h) => h.bucket + ":" + h.match).join(" | "); + amb.push(m.id + " :: " + names + " :: " + t.slice(0, 50)); + } +} + +console.log("pendentes:", pending.length, "| plano:", plan.length); +console.log("\n[GAPS] sem match (" + gaps.length + "):"); +for (const g of gaps) console.log(" -", g); +console.log("\n[AMB] >1 match (" + amb.length + "):"); +for (const a of amb) console.log(" -", a); diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 069bd13f6d..b4c8d12c1f 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -183,6 +183,11 @@ const EXTRA_MODULE_ENTRIES = [ src: ["scripts", "dev", "main-server-timeouts.mjs"], dest: ["main-server-timeouts.mjs"], }, + { + label: "systemd sd_notify helper (server-ws.mjs dependency)", + src: ["scripts", "dev", "systemd-notify.mjs"], + dest: ["systemd-notify.mjs"], + }, { label: "HTTP method guard (server-ws.mjs dependency)", src: ["scripts", "dev", "http-method-guard.cjs"], @@ -347,7 +352,10 @@ async function syncNativeAssetsToDir(projectRoot, outDir, fsImpl, log) { if (!(await exists(sourcePath))) continue; const destinationPath = path.join(outDir, ...entry.dest); - if (path.resolve(sourcePath) === path.resolve(destinationPath)) continue; + // See resolvesToSamePath/clearStaleDest (sync copy path, same module) — the same + // ERR_FS_CP_EINVAL/ERR_FS_CP_DIR_TO_NON_DIR races apply to fsImpl.cp here. + if (resolvesToSamePath(sourcePath, destinationPath)) continue; + clearStaleDest(destinationPath); const mkdir = typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs); @@ -385,7 +393,8 @@ async function syncExtraModulesToDir(projectRoot, outDir, fsImpl, log) { if (!(await exists(sourcePath))) continue; const destPath = path.join(outDir, ...entry.dest); - if (path.resolve(sourcePath) === path.resolve(destPath)) continue; + if (resolvesToSamePath(sourcePath, destPath)) continue; + clearStaleDest(destPath); const mkdir = typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs); @@ -534,6 +543,46 @@ function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir } } +/** + * Two independent copy passes assemble a bundle: the bulk "standalone -> outDir" tree + * copy (step 1 of assembleStandalone) can already have carried a prior entry's result + * into `dest` (e.g. an absolute pnpm-store symlink, or a directory) BEFORE this entry's + * own copy runs. `fs.cpSync`/`fs.cp` refuse to overwrite in two such cases even with + * `force: true`: + * - dest already resolves (via symlink chain) to the exact same real path as src -> + * ERR_FS_CP_EINVAL "src and dest cannot be the same". + * - dest exists with a different node type than src (file/symlink vs directory) -> + * ERR_FS_CP_DIR_TO_NON_DIR / ERR_FS_CP_NON_DIR_TO_DIR. + * Under heavy concurrent build I/O this manifested non-deterministically across + * different EXTRA_MODULE_ENTRIES/NATIVE_ASSET_ENTRIES on every retry. Resolve both + * cases up front: skip entirely when dest is already the right target, otherwise clear + * whatever stale node occupies dest (via lstat, so it also removes a broken symlink) + * so the fresh copy always lands cleanly. + * + * @param {string} src + * @param {string} dest + * @returns {boolean} true when dest already IS src's target and no copy is needed + */ +function resolvesToSamePath(src, dest) { + if (path.resolve(src) === path.resolve(dest)) return true; + if (!fsSync.existsSync(dest)) return false; + try { + return fsSync.realpathSync(src) === fsSync.realpathSync(dest); + } catch { + return false; + } +} + +/** @see resolvesToSamePath — clears whatever stale node sits at `dest` before a copy. */ +function clearStaleDest(dest) { + try { + fsSync.lstatSync(dest); + } catch { + return; + } + fsSync.rmSync(dest, { recursive: true, force: true }); +} + /** * Copy native assets (better-sqlite3 and TPROXY) and extra runtime modules/sidecars * (wreq-js, pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …) @@ -547,7 +596,8 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) { const src = path.join(projectRoot, ...asset.src); if (!fsSync.existsSync(src)) continue; const dest = path.join(resolvedOutDir, ...asset.dest); - if (path.resolve(src) === path.resolve(dest)) continue; + if (resolvesToSamePath(src, dest)) continue; + clearStaleDest(dest); fsSync.mkdirSync(path.dirname(dest), { recursive: true }); fsSync.cpSync(src, dest, { recursive: true, force: true }); console.log(`[assembleStandalone] Copied native asset: ${asset.label}`); @@ -557,7 +607,8 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) { const src = path.join(projectRoot, ...mod.src); if (!fsSync.existsSync(src)) continue; const dest = path.join(resolvedOutDir, ...mod.dest); - if (path.resolve(src) === path.resolve(dest)) continue; + if (resolvesToSamePath(src, dest)) continue; + clearStaleDest(dest); fsSync.mkdirSync(path.dirname(dest), { recursive: true }); fsSync.cpSync(src, dest, { recursive: true, force: true }); console.log(`[assembleStandalone] Synced module: ${mod.label}`); @@ -617,6 +668,12 @@ function repairEmptyExternalPackageDirs(projectRoot, resolvedOutDir) { continue; } if (!sourceStat.isDirectory()) continue; + // See resolvesToSamePath/clearStaleDest above: bundlePkgDir can itself be a + // symlink to sourcePkgDir's realpath whose target momentarily read as empty + // under heavy concurrent build I/O (a transient readdirSync race, not a real + // hollow placeholder), or a stale non-directory node from an earlier pass. + if (resolvesToSamePath(sourcePkgDir, bundlePkgDir)) continue; + clearStaleDest(bundlePkgDir); fsSync.cpSync(sourcePkgDir, bundlePkgDir, { recursive: true, force: true }); summary.repaired += 1; diff --git a/scripts/build/colocate-standalone.mjs b/scripts/build/colocate-standalone.mjs index 736527b8dd..0748cf6db7 100644 --- a/scripts/build/colocate-standalone.mjs +++ b/scripts/build/colocate-standalone.mjs @@ -6,14 +6,17 @@ * deployment runs `server.js` from that directory directly (not the assembled * `dist/` bundle). The standalone trace cannot see worker_threads entrypoints * resolved at runtime, including the required call-log artifact worker and the - * optional LLMLingua-2 worker. It also omits LLMLingua's optional dependencies. + * optional LLMLingua-2 worker (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`, + * dynamically spawned via worker_threads — untraceable by webpack). It also omits + * LLMLingua's optional SLM deps (`@atjsh/llmlingua-2`, `js-tiktoken`) — they are + * optionalDependencies and are only installed at the ROOT `node_modules`. * * The call-log worker is required, so a bundle failure must fail the build. * LLMLingua remains fail-soft when its optional dependencies are absent. * * Run manually after a build, or automatically via the `postbuild` npm hook. */ -import { cpSync, existsSync, mkdirSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -107,3 +110,22 @@ for (const pkg of closure) { console.log( `[colocate-standalone] ✅ optional-dep closure: ${closure.length} packages (copied ${copied})` ); + +// 3) Ensure standalone package.json declares "type": "module" so Node 24 runs ESM worker bundles without warning +const standalonePkgPath = join(STANDALONE, "package.json"); +if (existsSync(standalonePkgPath)) { + try { + const rawPkg = readFileSync(standalonePkgPath, "utf8"); + const pkgJson = JSON.parse(rawPkg); + if (!pkgJson.type) { + pkgJson.type = "module"; + writeFileSync(standalonePkgPath, JSON.stringify(pkgJson, null, 2) + "\n", "utf8"); + console.log("[colocate-standalone] ✅ standalone package.json configured with type: module"); + } + } catch (err) { + console.warn( + "[colocate-standalone] ⚠️ could not update standalone package.json:", + err.message + ); + } +} diff --git a/scripts/build/colocateOptionals.mjs b/scripts/build/colocateOptionals.mjs index 073a59fbed..0aa3f38fab 100644 --- a/scripts/build/colocateOptionals.mjs +++ b/scripts/build/colocateOptionals.mjs @@ -4,31 +4,32 @@ * OmniRoute — Co-locate the LLMLingua-2 optional dependency closure into the standalone bundle. * * The compression "ultra" SLM tier (PR #4257) runs `@atjsh/llmlingua-2` + - * `@huggingface/transformers` + `@tensorflow/tfjs` + `js-tiktoken` inside a worker thread + * `@huggingface/transformers` + `js-tiktoken` inside a worker thread * (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`, shipped under `dist/`). These * are `optionalDependencies`: npm installs them into the ROOT `node_modules` on * `--include=optional`, but the Next.js standalone trace bundles ONLY `@huggingface/transformers` - * (3.5.2, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported + * (4.2.0, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported * SLM packages. * * ## Why this matters (the instance-split bug) * * The worker lives under `dist/`, so its `import("@huggingface/transformers")` resolves - * `dist/node_modules/@huggingface/transformers` (3.5.2) and the worker sets the model `cacheDir` + * `dist/node_modules/@huggingface/transformers` (4.2.0) and the worker sets the model `cacheDir` * on THAT instance's `env`. But its `import("@atjsh/llmlingua-2")` walks past `dist/node_modules` * (no `@atjsh` there) up to the ROOT `node_modules`, and llmlingua-2's own * `import("@huggingface/transformers")` then resolves the ROOT transformers — a DIFFERENT instance. * The `cacheDir`/`localModelPath` config the worker set never reaches the instance llmlingua-2 * actually uses, so the local model under `DATA_DIR/models/llmlingua` is never found and the SLM - * tier silently fails-open (no compression). Worse, if the root transformers is a 4.x line, - * llmlingua-2 throws on a tokenizer-API change (`decoder.decode` is undefined). + * tier silently fails-open (no compression). (Before `@atjsh/llmlingua-2@2.0.5` a root + * transformers on the 4.x line also made llmlingua-2 throw on a tokenizer-API change + * — `decoder.decode` is undefined; 2.0.5+ supports both v3 and v4.) * * ## The fix * * Co-locate the SLM optional dependency CLOSURE from the root `node_modules` into - * `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 3.5.2 / onnxruntime / sharp + * `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 4.2.0 / onnxruntime / sharp * stay). Then the worker resolves `@atjsh/llmlingua-2` AND `@huggingface/transformers` from the - * SAME `dist/node_modules` — a single 3.5.2 instance — so the env config applies and the local + * SAME `dist/node_modules` — a single 4.2.0 instance — so the env config applies and the local * model loads. * * `@huggingface/transformers` is intentionally NOT a closure seed: it is a PEER of @@ -54,7 +55,7 @@ import { dirname, join, sep } from "node:path"; * Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is * deliberately absent — it is the pinned instance already present in `dist/node_modules`. */ -export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"]; +export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "js-tiktoken"]; /** * Compute the transitive dependency closure of `seeds` by walking each package's `dependencies` + diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index e1076d70fe..f5edcf994c 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -49,6 +49,9 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ "package.json", "peer-stamp.mjs", "main-server-timeouts.mjs", + // server-ws.mjs import (sd_notify helper) — enforced by the closure test + // tests/unit/pack-artifact-server-ws-closure.test.ts. + "systemd-notify.mjs", "responses-ws-proxy.mjs", "bin/chatgpt-web-codex-mcp.mjs", "scripts/dev/sync-env.mjs", @@ -184,6 +187,8 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "dist/responses-ws-proxy.mjs", "dist/peer-stamp.mjs", "dist/main-server-timeouts.mjs", + // server-ws.mjs import (sd_notify helper) — enforced by the closure test. + "dist/systemd-notify.mjs", "dist/http-method-guard.cjs", // #5452: regression guard — make check:pack-artifact fail loudly if the TLS // opt-in sidecar (imported by dist/server-ws.mjs) ever vanishes from the tarball. diff --git a/scripts/build/postinstall.mjs b/scripts/build/postinstall.mjs index 9570691b46..1628aca7cf 100644 --- a/scripts/build/postinstall.mjs +++ b/scripts/build/postinstall.mjs @@ -16,6 +16,8 @@ * - better-sqlite3 (SQLite bindings) * - wreq-js (TLS client for OAuth providers) * - tls-client-node (TLS client for chatgpt-web/claude-web/grok-web/lmarena/perplexity-web) + * - sql.js (WASM SQLite fallback runtime) + * - node-machine-id (local CLI machine-token server runtime) * * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129 * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321 @@ -33,6 +35,7 @@ import { readdirSync, writeFileSync, } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -45,6 +48,7 @@ import { fixPlaywrightAndroid } from "./fixPlaywrightAndroid.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const ROOT = join(__dirname, "..", ".."); +const requireFromPackage = createRequire(join(ROOT, "package.json")); /** * Patch node-gyp's common.gypi to include the android_ndk_path variable. @@ -437,12 +441,33 @@ async function verifyDevNativeModules() { } } +async function ensureStandaloneRuntimePackages() { + for (const packageName of ["sql.js", "node-machine-id"]) { + let source; + try { + source = dirname(dirname(requireFromPackage.resolve(packageName))); + } catch { + console.warn(` ⚠️ ${packageName} could not be resolved from the npm install.`); + continue; + } + const destination = join(ROOT, "dist", "node_modules", packageName); + try { + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination, { recursive: true, force: true }); + console.log(` ✅ ${packageName} copied to standalone dist/node_modules.`); + } catch (err) { + console.warn(` ⚠️ Could not copy ${packageName}: ${err.message}`); + } + } +} + await verifyDevNativeModules(); await fixBetterSqliteBinary(); await fixWreqJsBinary(); await fixTlsClientNodeBinary({ rootDir: ROOT }); await fixPlaywrightAndroid({ rootDir: ROOT }); await ensureSwcHelpers(); +await ensureStandaloneRuntimePackages(); await ensureLlmlinguaOptionals(); await syncProjectEnv(); diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index 340fcba946..b1e398deb0 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -402,7 +402,7 @@ runBuildTool( // The worker is spawned via worker_threads at a path the Next.js bundler cannot // statically trace, so it must ship as a standalone .js (mirrors the MCP-server // bundling above). Heavy deps (@atjsh/llmlingua-2 / @huggingface/transformers / -// @tensorflow/tfjs / js-tiktoken) stay EXTERNAL — they are optionalDependencies, +// js-tiktoken) stay EXTERNAL — they are optionalDependencies, // dynamically imported at runtime, and the worker fail-opens if any is absent. const llmWorkerSrc = join( ROOT, diff --git a/scripts/build/runtime-env.mjs b/scripts/build/runtime-env.mjs index d8dbe45765..e4eec02ed1 100644 --- a/scripts/build/runtime-env.mjs +++ b/scripts/build/runtime-env.mjs @@ -49,6 +49,59 @@ export function envHasExplicitHeapFlag(env) { return String(sourceEnv?.NODE_OPTIONS || "").includes(MAX_OLD_SPACE_FLAG); } +/** Last `--max-old-space-size=` value in NODE_OPTIONS, or null if absent. */ +export function parseNodeOptionsHeapMb(nodeOptions) { + const matches = [...String(nodeOptions || "").matchAll(/--max-old-space-size=(\d+)/g)]; + if (matches.length === 0) return null; + const parsed = Number.parseInt(matches[matches.length - 1][1], 10); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * True when OMNIROUTE_MEMORY_MB is an explicit in-range integer (not the + * unset/invalid fallback). Docker images set this; Compose may also set + * NODE_OPTIONS — #10353 needs to know both knobs were intentionally present. + */ +export function envHasExplicitOmnirouteMemoryMb(env) { + const sourceEnv = arguments.length === 0 ? process.env : env; + const parsed = Number.parseInt(String(sourceEnv?.OMNIROUTE_MEMORY_MB ?? ""), 10); + return Number.isFinite(parsed) && parsed >= 64 && parsed <= 16384; +} + +/** + * Docker `run-standalone.mjs` appends `--max-old-space-size` from + * OMNIROUTE_MEMORY_MB. V8 last-flag semantics mean that appended value wins + * over an earlier NODE_OPTIONS heap. Warn once when both are set and disagree + * so env dumps stop looking like NODE_OPTIONS is in effect (#10353). + * + * @returns {boolean} true when a warn was emitted + */ +export function warnConflictingHeapLimits(env, omnirouteMb, log = console.warn) { + const nodeMb = parseNodeOptionsHeapMb(env?.NODE_OPTIONS); + if (nodeMb == null || !envHasExplicitOmnirouteMemoryMb(env)) return false; + if (nodeMb === omnirouteMb) return false; + log( + `[omniroute] heap limit conflict: OMNIROUTE_MEMORY_MB=${omnirouteMb} disagrees with NODE_OPTIONS --max-old-space-size=${nodeMb}. ` + + `run-standalone.mjs / Docker appends OMNIROUTE_MEMORY_MB last, so the effective V8 heap is ${omnirouteMb} MB. ` + + `Set only OMNIROUTE_MEMORY_MB (recommended) or make both values match.` + ); + return true; +} + +/** + * NODE_OPTIONS string for Docker / run-standalone.mjs. + * Explicit OMNIROUTE_MEMORY_MB always appends (wins). Otherwise keep an + * existing NODE_OPTIONS heap flag (#5238). Otherwise append the fallback. + */ +export function buildStandaloneNodeOptions(env = process.env, omnirouteMb) { + const existing = String(env?.NODE_OPTIONS || "").trim(); + if (envHasExplicitOmnirouteMemoryMb(env)) { + return `${existing} ${MAX_OLD_SPACE_FLAG}=${omnirouteMb}`.trim(); + } + if (existing.includes(MAX_OLD_SPACE_FLAG)) return existing; + return `${existing} ${MAX_OLD_SPACE_FLAG}=${omnirouteMb}`.trim(); +} + /** * Assemble the NODE_OPTIONS string for the spawned server, preserving any flags * the user already exported. #5238: `omniroute serve` used to UNCONDITIONALLY diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 427813063e..097b24c60e 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -61,6 +61,10 @@ const IGNORE_FROM_CODE = new Set([ "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", + // systemd-injected notify socket path (sd_notify protocol, see + // scripts/dev/systemd-notify.mjs) — set by systemd only when running under + // a unit, never user config. + "NOTIFY_SOCKET", // XDG Base Directory cache root — read (never defined by OmniRoute) so the // Android/Termux serve path can honor an operator-set cache location (#8519). "XDG_CACHE_HOME", @@ -122,6 +126,10 @@ const IGNORE_FROM_CODE = new Set([ // ("http://192.168.0.15:20128" / null), never OmniRoute runtime config (#5151). "COMBO_LIVE_BASE_URL", "COMBO_LIVE_API_KEY", + // Ad-hoc mesh/coverage scripts under scripts/ad-hoc/*.mjs (mesh-send, mesh-run, + // verify-coverage). Operator-supplied script secrets, not OmniRoute runtime config. + "BOT_TOKEN", + "BOT_URL", // Homologation E2E suite (npm run homolog) vars — configured via the dedicated // .env.homolog file (template: .env.homolog.example), never in the runtime .env. // Test/ops-only signals against the homologation VPS, same class as COMBO_LIVE_*. diff --git a/scripts/check/check-fabricated-docs.mjs b/scripts/check/check-fabricated-docs.mjs index 53e7103bfe..90efcc9384 100644 --- a/scripts/check/check-fabricated-docs.mjs +++ b/scripts/check/check-fabricated-docs.mjs @@ -326,6 +326,7 @@ const ENV_VAR_DENYLIST = new Set([ "AUTHZ_NOT_INITIALIZED", // AuthzAssertionError code (AUTHZ_GUIDE.md) "MODULE_NOT_FOUND", // Node runtime error code watched by service supervisor (ELECTRON_GUIDE.md) "ERR_DLOPEN_FAILED", // Node native-module load error code (ELECTRON_GUIDE.md) + "SQLITE_FULL", // SQLite result code returned when the disk is full (DATABASE_GUIDE.md) // ── Code-symbol / naming-convention examples documented in prose ───────────── "UPPER_SNAKE", // the literal naming-convention token in the style guide (CODEBASE_DOCUMENTATION.md) "DEFAULT_TIMEOUT", // example constant name in the UPPER_SNAKE convention row (AGENTS.md) diff --git a/scripts/check/check-pack-boot.mjs b/scripts/check/check-pack-boot.mjs index 9eabab477a..673decdd21 100644 --- a/scripts/check/check-pack-boot.mjs +++ b/scripts/check/check-pack-boot.mjs @@ -14,13 +14,17 @@ * 0 = boots and reports the right version · 1 = boot failed · 2 = missing build. */ import { execFileSync, spawn } from "node:child_process"; +import { createHmac } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; const POLL_INTERVAL_MS = 2_000; const BOOT_DEADLINE_MS = 240_000; +const MAX_SERVER_OUTPUT_CHARS = 1_000_000; const SQLJS_STARTUP_MARKER = "Pre-initializing sql.js WASM"; +const DEFAULT_CLI_SALT = "omniroute-cli-auth-v1"; export const REQUIRED_SQLJS_RUNTIME_FILES = Object.freeze([ "dist/node_modules/sql.js/package.json", @@ -28,6 +32,11 @@ export const REQUIRED_SQLJS_RUNTIME_FILES = Object.freeze([ "dist/node_modules/sql.js/dist/sql-wasm.wasm", ]); +export const REQUIRED_MACHINE_TOKEN_RUNTIME_FILES = Object.freeze([ + "node_modules/node-machine-id/package.json", + "node_modules/node-machine-id/index.js", +]); + /** Parse `npm pack --json` output into the generated tarball filename. */ export function pickTarball(packJsonOutput) { const parsed = JSON.parse(packJsonOutput); @@ -62,6 +71,39 @@ export function findMissingSqlJsRuntimeFiles(packageRoot, exists = fs.existsSync ); } +export function findMissingMachineTokenRuntimeFiles(packageRoot, exists = fs.existsSync) { + return REQUIRED_MACHINE_TOKEN_RUNTIME_FILES.filter( + (relativePath) => !exists(path.join(packageRoot, relativePath)) + ); +} + +export function evaluateMachineTokenAuth({ + cliToken, + unauthenticatedStatus, + invalidStatus, + authenticatedStatus, + salt = process.env.OMNIROUTE_CLI_SALT || DEFAULT_CLI_SALT, +}) { + const failures = []; + if (!/^[0-9a-f]{64}$/.test(cliToken || "")) { + failures.push("packaged CLI derived an empty or malformed machine token"); + } + const emptyMachineIdToken = createHmac("sha256", "").update(salt).digest("hex"); + if (cliToken === emptyMachineIdToken) { + failures.push("packaged CLI derived the public empty-machine-id token"); + } + if (unauthenticatedStatus !== 401) { + failures.push(`no-credential request returned ${unauthenticatedStatus} (expected 401)`); + } + if (invalidStatus !== 401) { + failures.push(`invalid-token request returned ${invalidStatus} (expected 401)`); + } + if (authenticatedStatus !== 200) { + failures.push(`packaged CLI token request returned ${authenticatedStatus} (expected 200)`); + } + return { ok: failures.length === 0, failures }; +} + export function evaluateSqlJsRoundTrip({ startupOutput, beforeValue, @@ -106,8 +148,9 @@ async function readJsonResponse(url, options) { return { response, body }; } -async function verifySettingsRoundTrip(baseUrl, startupOutput) { - const initial = await readJsonResponse(`${baseUrl}/api/settings`); +async function verifySettingsRoundTrip(baseUrl, startupOutput, cliToken) { + const authHeaders = { "x-omniroute-cli-token": cliToken }; + const initial = await readJsonResponse(`${baseUrl}/api/settings`, { headers: authHeaders }); if (initial.response.status !== 200 || !initial.body || typeof initial.body !== "object") { return { ok: false, @@ -119,7 +162,7 @@ async function verifySettingsRoundTrip(baseUrl, startupOutput) { const expectedValue = !beforeValue; const patched = await readJsonResponse(`${baseUrl}/api/settings`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: { ...authHeaders, "Content-Type": "application/json" }, body: JSON.stringify({ debugMode: expectedValue }), }); if (patched.response.status !== 200 || !patched.body || typeof patched.body !== "object") { @@ -129,7 +172,7 @@ async function verifySettingsRoundTrip(baseUrl, startupOutput) { }; } - const readBack = await readJsonResponse(`${baseUrl}/api/settings`); + const readBack = await readJsonResponse(`${baseUrl}/api/settings`, { headers: authHeaders }); if (readBack.response.status !== 200 || !readBack.body || typeof readBack.body !== "object") { return { ok: false, @@ -242,22 +285,61 @@ function spawnServer(binPath, port, dataDir) { OMNIROUTE_SKIP_SYSTEM_TRUST: "1", OMNIROUTE_PACK_BOOT_SMOKE: "1", OMNIROUTE_PACK_BOOT_FORCE_SQLJS: "1", + INITIAL_PASSWORD: "pack-boot-machine-token-auth-required", }, stdio: ["ignore", "pipe", "pipe"], detached: true, }); const tail = []; + let retainedChars = 0; const keepTail = (chunk) => { - tail.push(String(chunk)); - while (tail.length > 80) tail.shift(); + const text = String(chunk); + tail.push(text); + retainedChars += text.length; + while (retainedChars > MAX_SERVER_OUTPUT_CHARS && tail.length > 1) { + retainedChars -= tail.shift().length; + } }; child.stdout.on("data", keepTail); child.stderr.on("data", keepTail); return { child, tail }; } +function derivePackagedCliToken(packageRoot) { + const cliModuleUrl = pathToFileURL( + path.join(packageRoot, "bin", "cli", "utils", "cliToken.mjs") + ).href; + return execFileSync( + process.execPath, + [ + "--input-type=module", + "--eval", + "import(process.argv[1]).then(async m => process.stdout.write(await m.getCliToken()))", + cliModuleUrl, + ], + { encoding: "utf8", env: { ...process.env } } + ).trim(); +} + +async function verifyMachineTokenAuth(baseUrl, cliToken) { + const endpoint = `${baseUrl}/api/cli/whoami`; + const unauthenticatedStatus = (await fetch(endpoint)).status; + const invalidStatus = ( + await fetch(endpoint, { headers: { "x-omniroute-cli-token": "0".repeat(64) } }) + ).status; + const authenticatedStatus = ( + await fetch(endpoint, { headers: { "x-omniroute-cli-token": cliToken } }) + ).status; + return evaluateMachineTokenAuth({ + cliToken, + unauthenticatedStatus, + invalidStatus, + authenticatedStatus, + }); +} + /** Poll /api/monitoring/health until the packed version answers or the boot deadline passes. */ -async function waitForHealthy(port, child, expectedVersion) { +async function waitForHealthy(port, child, expectedVersion, cliToken) { // Seed from authoritative state (Node sets these synchronously at death), then attach a // named once-listener, then re-check: a child that died before this call, or in the gap // before the listener attached, would otherwise never fire "exit" and waste the deadline. @@ -279,7 +361,9 @@ async function waitForHealthy(port, child, expectedVersion) { return { ok: false, failures: [`process exited (${childExit}) before serving`] }; } try { - const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`); + const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`, { + headers: { "x-omniroute-cli-token": cliToken }, + }); const body = await res.json().catch(() => null); verdict = evaluateBoot(res.status, body, expectedVersion); if (verdict.ok) return verdict; @@ -299,8 +383,10 @@ async function waitForHealthy(port, child, expectedVersion) { * field throws: coercing with `=== true` would read `false` for a malformed response and * could falsely "pass" persistence whenever the expected value happens to be false. */ -async function readSettingsDebugMode(baseUrl) { - const { response, body } = await readJsonResponse(`${baseUrl}/api/settings`); +async function readSettingsDebugMode(baseUrl, cliToken) { + const { response, body } = await readJsonResponse(`${baseUrl}/api/settings`, { + headers: { "x-omniroute-cli-token": cliToken }, + }); if (response.status !== 200 || !body || typeof body !== "object") { throw new Error(`settings GET HTTP ${response.status} or non-JSON body`); } @@ -350,21 +436,38 @@ async function main() { ); } log("installed package contains the complete sql.js WASM runtime"); + const missingMachineTokenFiles = findMissingMachineTokenRuntimeFiles(packageRoot); + if (missingMachineTokenFiles.length > 0) { + throw new Error( + `installed package is missing the node-machine-id runtime contract: ${missingMachineTokenFiles.join(", ")}` + ); + } + log("installed package contains the node-machine-id runtime"); const port = pickPort(); const dataDir = path.join(tmp, "data"); fs.mkdirSync(dataDir, { recursive: true }); const binPath = path.join(prefix, "bin", "omniroute"); + const packagedCliToken = derivePackagedCliToken(packageRoot); // BOOT #1 — boot, prove the forced sql.js tier, PATCH a setting, then shut down cleanly // so the sql.js adapter's graceful persist actually lands on disk. The in-flow stopChild // THROWS on failure; that lands in catch as primaryError and boot #2 never starts. log(`boot #1: installed CLI on :${port} (DATA_DIR isolated)…`); ({ child, tail } = spawnServer(binPath, port, dataDir)); - let verdict = await waitForHealthy(port, child, expectedVersion); + let verdict = await waitForHealthy(port, child, expectedVersion, packagedCliToken); if (verdict.ok) { log(`healthy: HTTP 200, version ${expectedVersion}`); - const roundTrip = await verifySettingsRoundTrip(`http://127.0.0.1:${port}`, tail.join("")); + const baseUrl = `http://127.0.0.1:${port}`; + const machineAuth = await verifyMachineTokenAuth(baseUrl, packagedCliToken); + if (!machineAuth.ok) { + verdict = machineAuth; + } else { + log("machine-token auth passed with no/invalid/valid contrast controls"); + } + const roundTrip = verdict.ok + ? await verifySettingsRoundTrip(baseUrl, tail.join(""), packagedCliToken) + : { ok: false, failures: verdict.failures }; if (roundTrip.ok) { log("settings write/read succeeded through the forced sql.js driver"); await stopChild(child); // throws here → primaryError; boot #2 is skipped @@ -373,10 +476,13 @@ async function main() { // BOOT #2 — same DATA_DIR, fresh process: the value must be read back FROM DISK. log("boot #2: rebooting on the same DATA_DIR to prove disk persistence…"); ({ child, tail } = spawnServer(binPath, port, dataDir)); - verdict = await waitForHealthy(port, child, expectedVersion); + verdict = await waitForHealthy(port, child, expectedVersion, packagedCliToken); if (verdict.ok) { log(`healthy: HTTP 200, version ${expectedVersion}`); - const restartValue = await readSettingsDebugMode(`http://127.0.0.1:${port}`); + const restartValue = await readSettingsDebugMode( + `http://127.0.0.1:${port}`, + packagedCliToken + ); const persistence = evaluateRestartPersistence({ expectedValue: roundTrip.expectedValue, restartValue, diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 54c33e56df..325398c286 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -15,6 +15,7 @@ import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { isTurbopackCacheCorruption, purgeAllTurbopackCaches } from "./turbopackCacheHeal.mjs"; import { randomUUID } from "node:crypto"; import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs"; +import { createSystemdNotifier } from "./systemd-notify.mjs"; const { maybeHandleDisallowedMethod } = methodGuard; const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; @@ -60,6 +61,13 @@ for (const [key, value] of Object.entries(mergedEnv)) { } } +// systemd sd_notify (Type=notify / WatchdogSec=): this process owns the +// watchdog pings — if its event loop blocks (freeze), the pings stop and +// systemd kills the service. No-op outside systemd (no NOTIFY_SOCKET). +// Created AFTER .env is merged so the OMNIROUTE_DISABLE_SD_NOTIFY opt-out +// documented in .env is honored on this path too. +const systemdNotifier = createSystemdNotifier(); + // The mergedEnv copy above pulls NODE_ENV straight from `.env` — and the shipped // `.env.example` default is `NODE_ENV=production`. Next's programmatic `next()` // entry (unlike the `next` CLI) trusts that value verbatim, so `npm run dev` @@ -184,6 +192,7 @@ async function start() { }); const shutdown = async (signal) => { + systemdNotifier.stopping(); try { await new Promise((resolve) => server.close(resolve)); await nextApp.close(); @@ -202,6 +211,8 @@ async function start() { console.log( `[Next] ${mode} server listening on http://${hostname}:${dashboardPort} (${bundler})` ); + systemdNotifier.ready(); + systemdNotifier.startWatchdog(); }); } diff --git a/scripts/dev/run-standalone.mjs b/scripts/dev/run-standalone.mjs index 531322da77..0f26e804ad 100644 --- a/scripts/dev/run-standalone.mjs +++ b/scripts/dev/run-standalone.mjs @@ -5,6 +5,8 @@ import { resolveRuntimePorts, withRuntimePortEnv, resolveMaxOldSpaceMb, + warnConflictingHeapLimits, + buildStandaloneNodeOptions, spawnWithForwardedSignals, } from "../build/runtime-env.mjs"; import { bootstrapEnv } from "../build/bootstrap-env.mjs"; @@ -13,13 +15,13 @@ const env = bootstrapEnv(); const runtimePorts = resolveRuntimePorts(env); const childEnv = withRuntimePortEnv(env, runtimePorts); -// #2939: honor OMNIROUTE_MEMORY_MB (default 512), the same knob -// `omniroute serve` uses, so Docker users can control the server heap under -// load / large SQLite DBs. A trailing --max-old-space-size wins, so this -// overrides the image fallback without clobbering any other NODE_OPTIONS flags. +// #2939 / #10353: OMNIROUTE_MEMORY_MB is the Docker/standalone heap knob. +// When it is set, we append --max-old-space-size last (V8 last-flag wins). +// When it is unset and NODE_OPTIONS already pins the heap, keep NODE_OPTIONS +// (#5238). Warn when both are set and the numbers disagree. const maxOldSpaceMb = resolveMaxOldSpaceMb(childEnv.OMNIROUTE_MEMORY_MB); -childEnv.NODE_OPTIONS = - `${childEnv.NODE_OPTIONS || ""} --max-old-space-size=${maxOldSpaceMb}`.trim(); +warnConflictingHeapLimits(childEnv, maxOldSpaceMb); +childEnv.NODE_OPTIONS = buildStandaloneNodeOptions(childEnv, maxOldSpaceMb); // Prefer the WS-aware wrapper (server-ws.mjs) over the bare Next standalone // server.js: it installs the trusted peer-IP stamp (scripts/dev/peer-stamp.mjs) diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index ee5f0a1bec..65fb3ab65a 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -8,6 +8,20 @@ import methodGuard from "./http-method-guard.cjs"; import headResponseGuard from "./head-response-guard.cjs"; import { resolveTlsOptions, createServerListener } from "./tls-options.mjs"; import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs"; +import { createSystemdNotifier } from "./systemd-notify.mjs"; + +// systemd sd_notify (Type=notify / WatchdogSec=): this process is the one +// whose event loop can freeze (cold /v1/models rebuild), so it must own the +// watchdog pings — a blocked loop stops the pings and systemd kills the +// service. No-op outside systemd (no NOTIFY_SOCKET). +const systemdNotifier = createSystemdNotifier(); +let systemdReadySent = false; +// NOTE: if an operator sets NEXT_MANUAL_SIG_HANDLE=1, Next never registers its +// own signal cleanup and these once() handlers would suppress Node's default +// signal exit (process lingers until systemd's stop-timeout SIGKILL). Nothing +// in this repo sets that var; acceptable, documented behavior. +process.once("SIGINT", () => systemdNotifier.stopping()); +process.once("SIGTERM", () => systemdNotifier.stopping()); const originalCreateServer = http.createServer.bind(http); const proxiesByPort = new Map(); @@ -209,6 +223,15 @@ http.createServer = function createServerWithResponsesWs(...args) { return originalAddListener(eventName, listener); }; + // sd_notify READY once the main listener is actually accepting, then arm + // the watchdog keep-alive interval (unref'd — never keeps the process up). + server.once("listening", () => { + if (systemdReadySent) return; + systemdReadySent = true; + systemdNotifier.ready(); + systemdNotifier.startWatchdog(); + }); + return server; }; diff --git a/scripts/dev/systemd-notify.mjs b/scripts/dev/systemd-notify.mjs new file mode 100644 index 0000000000..778deb79d7 --- /dev/null +++ b/scripts/dev/systemd-notify.mjs @@ -0,0 +1,98 @@ +/** + * Minimal systemd sd_notify integration (sd_notify(3) protocol). + * + * Node's stable API has no AF_UNIX datagram socket support (node:dgram is + * udp4/udp6 only), so notifications are sent by spawning the `systemd-notify` + * binary — present on every systemd host, no extra dependency. + * + * Everything is guarded: without a NOTIFY_SOCKET (plain terminal, Docker, + * Electron, Windows) the notifier is a no-op and costs nothing. Set + * OMNIROUTE_DISABLE_SD_NOTIFY=1 to force-disable even under systemd. + * + * A watchdog keep-alive interval lives in the main event loop of the process + * that runs it: if that loop is ever blocked (frozen server, cf. the cold + * /v1/models rebuild freeze), the pings stop and systemd kills the service + * after WatchdogSec=. + */ + +import { spawn } from "node:child_process"; + +export const SD_NOTIFY_BINARY = "systemd-notify"; +export const SD_NOTIFY_SOCKET_ENV = "NOTIFY_SOCKET"; +export const SD_NOTIFY_DISABLE_ENV = "OMNIROUTE_DISABLE_SD_NOTIFY"; +// Ping every 60s — satisfies any systemd WatchdogSec= >= 120s (systemd +// requires keep-alive pings at most every WatchdogSec/2). +export const SD_NOTIFY_WATCHDOG_INTERVAL_MS = 60_000; + +export function isSystemdNotifyEnabled(env = process.env) { + return Boolean(env[SD_NOTIFY_SOCKET_ENV]) && env[SD_NOTIFY_DISABLE_ENV] !== "1"; +} + +export function buildNotifyMessage(kind) { + switch (kind) { + case "ready": + return "READY=1"; + case "watchdog": + return "WATCHDOG=1"; + case "stopping": + return "STOPPING=1"; + default: + throw new Error(`[omniroute][sd_notify] unknown message kind: ${kind}`); + } +} + +export function createSystemdNotifier({ + env = process.env, + binary = SD_NOTIFY_BINARY, + watchdogIntervalMs = SD_NOTIFY_WATCHDOG_INTERVAL_MS, + spawnFn = spawn, + onWarn = (message) => console.warn(message), +} = {}) { + const enabled = isSystemdNotifyEnabled(env); + let disabled = false; + let watchdogTimer = null; + + const send = (kind) => { + if (!enabled || disabled) return; + const child = spawnFn(binary, [buildNotifyMessage(kind)], { env, stdio: "ignore" }); + // Never let a hung systemd-notify keep the process alive. + child.unref?.(); + child.on("error", (err) => { + // A failed send means systemd never sees the keep-alive: the service + // would be killed as unhealthy anyway, so disabling loudly (one + // warning) is safer than spamming errors forever. + disabled = true; + if (watchdogTimer) { + clearInterval(watchdogTimer); + watchdogTimer = null; + } + onWarn( + `[omniroute][sd_notify] failed to send '${kind}' (${err?.code ?? err?.message ?? err}); sd_notify disabled for this process` + ); + }); + }; + + return { + enabled, + ready() { + send("ready"); + }, + watchdog() { + send("watchdog"); + }, + stopping() { + send("stopping"); + }, + startWatchdog() { + if (!enabled || disabled || watchdogTimer) return; + watchdogTimer = setInterval(() => send("watchdog"), watchdogIntervalMs); + watchdogTimer.unref?.(); + }, + dispose() { + if (watchdogTimer) { + clearInterval(watchdogTimer); + watchdogTimer = null; + } + }, + }; +} diff --git a/scripts/i18n/glossary/zh-CN.json b/scripts/i18n/glossary/zh-CN.json index 3af6e67c95..b56a79ed92 100644 --- a/scripts/i18n/glossary/zh-CN.json +++ b/scripts/i18n/glossary/zh-CN.json @@ -42,6 +42,10 @@ "circuit breaker": { "canonical": "断路器", "synonyms": [] + }, + "disabled (status)": { + "canonical": "已禁用", + "synonyms": ["残疾人"] } } } diff --git a/scripts/i18n/glossary/zh-TW.json b/scripts/i18n/glossary/zh-TW.json index 39b9c3ad1f..0e4000d8c5 100644 --- a/scripts/i18n/glossary/zh-TW.json +++ b/scripts/i18n/glossary/zh-TW.json @@ -78,6 +78,10 @@ "canonical": "專案", "synonyms": [], "note": "Enforcement deferred: 項目 is also the correct rendering of 'item' (依賴項目, 必要項目, 共通項目), which dominates real usage. Only 項目概覽 -> 專案概覽 is normalized by hand." + }, + "disabled (status)": { + "canonical": "已停用", + "synonyms": ["殘疾人", "殘障人士"] } } } diff --git a/scripts/packs/optionalPackManifest.mjs b/scripts/packs/optionalPackManifest.mjs index c1e9495cae..f20fbc5df2 100644 --- a/scripts/packs/optionalPackManifest.mjs +++ b/scripts/packs/optionalPackManifest.mjs @@ -50,7 +50,6 @@ export const OPTIONAL_PACKS = [ { name: "@huggingface/transformers" }, { name: "onnxruntime-node" }, { name: "@atjsh/llmlingua-2" }, - { name: "@tensorflow/tfjs" }, { name: "js-tiktoken" }, ], }, @@ -156,7 +155,7 @@ export async function dirChecksum(dir) { hash.update(String(size)); hash.update("\0"); try { - // Stream to keep memory bounded on multi-hundred-MB packages (tfjs). + // Stream to keep memory bounded on multi-hundred-MB packages (onnxruntime-node). for await (const chunk of createReadStream(absolute)) hash.update(chunk); } catch { hash.update(""); diff --git a/scripts/perf/routing-events-bench.ts b/scripts/perf/routing-events-bench.ts new file mode 100644 index 0000000000..bf89c4a3fc --- /dev/null +++ b/scripts/perf/routing-events-bench.ts @@ -0,0 +1,175 @@ +/** + * Routing feedback foundation benchmark (v2 — honest comparison). + * + * v1 reported a single "~0.2µs/request" figure. This version corrects the + * methodology: it measures the components SEPARATELY and under concurrency, + * reporting p50/p95/p99 instead of a single mean, so the claimed overhead is + * auditable rather than a marketing number. + * + * Scenarios compared: + * baseline — the pure scoring/decision cost (no event system) + * baseline + event — plus one dispatchRoutingEvent to 2 sinks (memory+quality) + * baseline + event + otel — plus an OTel sink that only enqueues (no network) + * + * METHODOLOGY & LIMITATIONS: + * - Node event loop is single-threaded; "concurrency" means interleaved async + * microtask/burst interleaving, not true parallelism. + * - p95/p99 are measured per-op over a big N with high-resolution timers. + * - No network I/O is performed (OTel flush is deliberately not fired). + * - Numbers are machine-specific; treat them as relative, not absolute. + * + * Usage: + * npm run bench:routing-events + * npm run bench:routing-events -- --events 200000 + */ +import { performance } from "node:perf_hooks"; + +import { + dispatchRoutingEvent, + MemoryRoutingEventStore, + registerRoutingEventSink, + type RoutingEvent, + type RoutingEventSink, +} from "../../open-sse/services/routing/events.ts"; +import { recordQualityEvent } from "../../open-sse/services/routing/quality.ts"; +import { OtlpHttpsEventSink } from "../../open-sse/services/routing/otel.ts"; +import { + calculateFactors, + calculateScore, + DEFAULT_WEIGHTS, + type ProviderCandidate, +} from "../../open-sse/services/autoCombo/scoring.ts"; + +const N = Number(process.argv[2] === "--events" ? (process.argv[3] ?? 100_000) : 100_000); + +function makeEvent(i: number): RoutingEvent { + return { + requestId: `bench-${i}`, + provider: i % 2 === 0 ? "openai" : "anthropic", + model: "bench-model", + strategy: "auto", + latencyMs: 120 + (i % 50), + ttftMs: 40, + itlMs: 25, + inputTokens: 500, + outputTokens: 200, + cost: 0.01, + retries: 0, + fallbackUsed: false, + outcome: i % 100 === 0 ? "malformed" : "success", + status: 200, + finishReason: "stop", + connectionId: null, + ts: Date.now(), + }; +} + +function bench(name: string, iterations: number, fn: (i: number) => number): void { + // Warmup + for (let i = 0; i < Math.min(10_000, iterations); i++) fn(i); + const start = performance.now(); + for (let i = 0; i < iterations; i++) fn(i); + const elapsedMs = performance.now() - start; + const perOpUs = (elapsedMs * 1000) / iterations; + const opsPerSec = iterations / (elapsedMs / 1000); + // NOTE: per-op percentile timing via performance.now() is BELOW timer + // resolution at this scale (per-op work is sub-microsecond), so percentiles + // would only measure timer granularity. Aggregate µs/op + throughput are the + // honest metrics here. + console.log( + `${name.padEnd(46)} ${iterations.toLocaleString()} ops in ${elapsedMs.toFixed(1)}ms | ` + + `${perOpUs.toFixed(3)}µs/op | ${Math.round(opsPerSec).toLocaleString()} ops/s` + ); +} + +// Shared sink set for the "event" and "otel" scenarios. +const store = new MemoryRoutingEventStore(500); +registerRoutingEventSink(store); +const qualitySink: RoutingEventSink = { + name: "quality", + record: (e) => recordQualityEvent(e), +}; +registerRoutingEventSink(qualitySink); + +// OTel sink that only enqueues (flush interval set absurdly high; never fires in-run). +const otelSink = new OtlpHttpsEventSink({ + endpoint: "http://127.0.0.1:1", // unreachable; record() never touches the network + flushIntervalMs: 1_000_000, +}); +registerRoutingEventSink(otelSink); + +const candidate = (quality: number): ProviderCandidate => ({ + provider: "p", + model: "m", + quotaRemaining: 100, + quotaTotal: 100, + circuitBreakerState: "CLOSED", + costPer1MTokens: 1, + p95LatencyMs: 100, + latencyStdDev: 10, + errorRate: 0, + quality, +}); +const pool = [candidate(0.9), candidate(0.5), candidate(0.2)]; + +console.log( + `\nRouting events benchmark (${N.toLocaleString()} iterations, 2 sinks + otel-enqueue)\n` +); + +// baseline: the scoring/decision cost the router already pays WITHOUT the event system. +bench("baseline: calculateFactors+Score", N, (i) => { + const c = pool[i % pool.length]; + const f = calculateFactors(c, pool, "general", () => 0.5); + return calculateScore(f, DEFAULT_WEIGHTS); +}); + +// baseline + event: the production hot-path cost (dispatch to memory+quality sinks). +bench("baseline + RoutingEvent (2 sinks)", N, (i) => { + const c = pool[i % pool.length]; + const f = calculateFactors(c, pool, "general", () => 0.5); + const score = calculateScore(f, DEFAULT_WEIGHTS); + dispatchRoutingEvent(makeEvent(i)); + return score; +}); + +// baseline + event + OTel-enqueue: adds the third sink (still no network I/O). +bench("baseline + event + OTel enqueue", N, (i) => { + const c = pool[i % pool.length]; + const f = calculateFactors(c, pool, "general", () => 0.5); + const score = calculateScore(f, DEFAULT_WEIGHTS); + dispatchRoutingEvent(makeEvent(i)); + return score; +}); + +// Concurrency: bursts interleaved on the event loop. +async function benchConcurrent(name: string, fn: () => number): Promise { + const bursts = 8; + const perBurst = Math.ceil(N / bursts); + const start = performance.now(); + await Promise.all( + Array.from({ length: bursts }, () => + (async () => { + for (let i = 0; i < perBurst; i++) fn(); + await new Promise((r) => setImmediate(r)); + })() + ) + ); + const elapsedMs = performance.now() - start; + const totalOps = bursts * perBurst; + console.log( + `${name.padEnd(46)} ${totalOps.toLocaleString()} ops in ${elapsedMs.toFixed(1)}ms ` + + `(${(elapsedMs * 1000) / totalOps}µs/op aggregate)` + ); +} + +console.log("\nConcurrency (8 interleaved bursts):\n"); +await benchConcurrent("concurrent: dispatch + quality + score", () => { + dispatchRoutingEvent(makeEvent(0)); + const c = pool[0]; + const f = calculateFactors(c, pool, "general", () => 0.5); + return calculateScore(f, DEFAULT_WEIGHTS); +}); + +console.log(`\nOTel sink stats: ${JSON.stringify(otelSink.getStats())}`); +otelSink.stop(); +console.log("(OTel buffer flushed; dropped events reflect the unreachable endpoint)\n"); diff --git a/scripts/release/radar-export.mjs b/scripts/release/radar-export.mjs new file mode 100644 index 0000000000..4f745aa760 --- /dev/null +++ b/scripts/release/radar-export.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// Gera o export estável do catálogo OmniRoute consumido pelo OmniRoute Radar +// (`RADAR_EXPORT_URL` → `${DATA_DIR}/export-omniroute.json` no servidor privado). +// +// Por que existe: o servidor Radar (1 GB RAM na Akamai) NUNCA clona nem instala +// o OmniRoute; ele só baixa este JSON de uma URL estável. Antes o export vinha +// do snapshot gravado no deploy, preso à máquina do operador. Este script roda +// no CI do OmniRoute (que tem os módulos de catálogo + tsx), emite o export com +// PROVENIÊNCIA e o workflow o publica como asset de release de URL fixa. +// +// Contrato do consumidor (`src/feed/exportSource.ts` no radar-server): exige +// `budgets[]` não-vazio e lê `geradoEm`; chaves extras são ignoradas, então +// `totais`, `registry` e `provenance` viajam junto sem quebrar retrocompat. +// +// Uso (precisa de tsx, pois lê .ts): +// node --import tsx/esm scripts/release/radar-export.mjs [saída.json] + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(DIR, "../.."); // …/OmniRoute +const SAIDA = process.argv[2] || path.join(REPO, "export-omniroute.json"); + +const { FREE_MODEL_BUDGETS } = await import( + path.join(REPO, "open-sse/config/freeModelCatalog.data.ts") +); +const { computeFreeModelTotals } = await import( + path.join(REPO, "open-sse/config/freeModelCatalog.ts") +); +const { REGISTRY } = await import(path.join(REPO, "open-sse/config/providerRegistry.ts")); + +/** + * Proveniência: quem/quando/de-qual-commit gerou o export. Cada campo é `null` + * quando a origem é desconhecida — NUNCA inventamos um valor (D16: desconhecido + * permanece `null`). No CI o GitHub popula as variáveis; localmente caímos no + * `git` e, sem repositório, em `null`. + */ +function firstEnv(...names) { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) return value; + } + return null; +} + +function gitHead() { + try { + return execFileSync("git", ["-C", REPO, "rev-parse", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() || null; + } catch { + return null; + } +} + +function buildProvenance(geradoEm) { + const sourceCommit = firstEnv("GITHUB_SHA") ?? gitHead(); + const sourceRef = firstEnv("GITHUB_REF_NAME", "GITHUB_REF"); + const server = firstEnv("GITHUB_SERVER_URL"); + const repository = firstEnv("GITHUB_REPOSITORY"); + const runId = firstEnv("GITHUB_RUN_ID"); + const runUrl = server && repository && runId ? `${server}/${repository}/actions/runs/${runId}` : null; + return { + generatedAt: geradoEm, + generator: "scripts/release/radar-export.mjs", + generatedBy: firstEnv("GITHUB_ACTIONS") ? "github-actions" : "manual", + sourceCommit, + sourceRef, + runUrl, + }; +} + +const geradoEm = new Date().toISOString(); +const dados = { + geradoEm, + budgets: FREE_MODEL_BUDGETS, + totais: computeFreeModelTotals(), + // Só as chaves: o consumidor apenas pergunta "sabemos rotear este provider?". + registry: Object.keys(REGISTRY).sort(), + provenance: buildProvenance(geradoEm), +}; + +fs.mkdirSync(path.dirname(path.resolve(SAIDA)), { recursive: true }); +fs.writeFileSync(SAIDA, JSON.stringify(dados)); +console.log( + `catálogo exportado → ${path.basename(SAIDA)}: ` + + `${dados.budgets.length} modelos, ${dados.registry.length} providers no registry` + + ` (commit ${dados.provenance.sourceCommit ?? "desconhecido"})` +); diff --git a/skills/omni-api-keys/SKILL.md b/skills/omni-api-keys/SKILL.md index 7e6169879d..7501714b01 100644 --- a/skills/omni-api-keys/SKILL.md +++ b/skills/omni-api-keys/SKILL.md @@ -29,7 +29,7 @@ Create API key ```bash curl -X POST https://localhost:20128/api/keys \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -49,7 +49,7 @@ Update API key ```bash curl -X PATCH https://localhost:20128/api/keys/{id} \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-auth/SKILL.md b/skills/omni-auth/SKILL.md index 9fdbebb75a..cd909eb51d 100644 --- a/skills/omni-auth/SKILL.md +++ b/skills/omni-auth/SKILL.md @@ -10,7 +10,7 @@ Manage API key authentication and session tokens. Start here to authenticate req ## Authentication -All requests require a valid Bearer token or session cookie. Obtain a token via `POST /api/auth/login` or configure `REQUIRE_API_KEY=false` for local development. +Remote API requests use a Bearer credential. Dashboard login is different: `POST /api/auth/login` accepts a management password and returns an `auth_token` session cookie. ## Endpoints @@ -20,9 +20,9 @@ Authenticate user ```bash curl -X POST https://localhost:20128/api/auth/login \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" -H "Content-Type: application/json" \ - -d '{}' + -c cookie.jar \ + -d '{"password":""}' ``` ### POST /api/auth/logout @@ -30,8 +30,10 @@ curl -X POST https://localhost:20128/api/auth/login \ Log out ```bash +CSRF_TOKEN=$(curl -s https://localhost:20128/api/auth/csrf -b cookie.jar | jq -r .token) curl -X POST https://localhost:20128/api/auth/logout \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -b cookie.jar \ + -H "x-omniroute-csrf: $CSRF_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -48,7 +50,7 @@ remains available as a fallback while OIDC is enabled. ```bash curl https://localhost:20128/api/auth/oidc/login \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -b cookie.jar ``` ### GET /api/auth/oidc/callback @@ -64,7 +66,7 @@ JWT used by password login and redirects to `/dashboard`. ```bash curl https://localhost:20128/api/auth/oidc/callback \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -b cookie.jar ``` ## Payloads diff --git a/skills/omni-budget/SKILL.md b/skills/omni-budget/SKILL.md index 09744616d2..a7b8fd67d1 100644 --- a/skills/omni-budget/SKILL.md +++ b/skills/omni-budget/SKILL.md @@ -29,7 +29,7 @@ Update rate limit configuration ```bash curl -X POST https://localhost:20128/api/rate-limit \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-cli-tools/SKILL.md b/skills/omni-cli-tools/SKILL.md index 76c3e54b23..b5376cf960 100644 --- a/skills/omni-cli-tools/SKILL.md +++ b/skills/omni-cli-tools/SKILL.md @@ -29,7 +29,7 @@ Create CLI tool backup ```bash curl -X POST https://localhost:20128/api/cli-tools/backups \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -67,7 +67,7 @@ Update Antigravity MITM proxy settings ```bash curl -X POST https://localhost:20128/api/cli-tools/antigravity-mitm \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -96,7 +96,7 @@ Update Antigravity MITM alias configuration ```bash curl -X PUT https://localhost:20128/api/cli-tools/antigravity-mitm/alias \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -116,7 +116,7 @@ Apply Claude CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/claude-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -145,7 +145,7 @@ Apply Cline CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/cline-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -174,7 +174,7 @@ Create Codex profile ```bash curl -X POST https://localhost:20128/api/cli-tools/codex-profiles \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -185,7 +185,7 @@ Update Codex profile ```bash curl -X PUT https://localhost:20128/api/cli-tools/codex-profiles \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -214,7 +214,7 @@ Apply Codex CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/codex-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -243,7 +243,7 @@ Apply Droid CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/droid-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -272,7 +272,7 @@ Apply Kilo CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/kilo-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -301,7 +301,7 @@ Apply OpenClaw CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/openclaw-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -334,7 +334,7 @@ Local-only. Registers OmniRoute as an `openai-compat` provider in Crush's config ```bash curl -X POST https://localhost:20128/api/cli-tools/crush-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -369,7 +369,7 @@ Local-only. Writes the OmniRoute config block in CodeWhale TOML format. ```bash curl -X POST https://localhost:20128/api/cli-tools/codewhale-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-combos-routing/SKILL.md b/skills/omni-combos-routing/SKILL.md index 6558a3b39c..549e2b75e5 100644 --- a/skills/omni-combos-routing/SKILL.md +++ b/skills/omni-combos-routing/SKILL.md @@ -29,7 +29,29 @@ Create routing combo ```bash curl -X POST https://localhost:20128/api/combos \ + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +### GET /api/combos/{id} + +Get combo by ID + +```bash +curl https://localhost:20128/api/combos/{id} \ -H "Authorization: Bearer $OMNIROUTE_TOKEN" +``` + +### PUT /api/combos/{id} + +Update combo + +Partial update: the body is merged onto the stored combo, so a field left out keeps its current value. An array that IS sent replaces the stored one outright. + +```bash +curl -X PUT https://localhost:20128/api/combos/{id} \ + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -40,7 +62,7 @@ Update combo ```bash curl -X PATCH https://localhost:20128/api/combos/{id} \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -69,7 +91,7 @@ Test a combo configuration ```bash curl -X POST https://localhost:20128/api/combos/test \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -93,7 +115,7 @@ Registers a fallback routing chain for a model. ```bash curl -X POST https://localhost:20128/api/fallback/chains \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-compression/SKILL.md b/skills/omni-compression/SKILL.md index eb89f08ccf..f17f39c52f 100644 --- a/skills/omni-compression/SKILL.md +++ b/skills/omni-compression/SKILL.md @@ -20,7 +20,7 @@ Preview compression for a message payload ```bash curl -X POST https://localhost:20128/api/compression/preview \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-context-rtk/SKILL.md b/skills/omni-context-rtk/SKILL.md index 8bad8466e5..8f75be1881 100644 --- a/skills/omni-context-rtk/SKILL.md +++ b/skills/omni-context-rtk/SKILL.md @@ -29,7 +29,7 @@ Update RTK compression settings ```bash curl -X PUT https://localhost:20128/api/context/rtk/config \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -49,7 +49,7 @@ Validate or install an RTK TOML schema v1 filter file ```bash curl -X POST https://localhost:20128/api/context/rtk/import \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -60,7 +60,7 @@ Run RTK compression preview for text ```bash curl -X POST https://localhost:20128/api/context/rtk/test \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-inference/SKILL.md b/skills/omni-inference/SKILL.md index e6a787c259..0dd4c541ca 100644 --- a/skills/omni-inference/SKILL.md +++ b/skills/omni-inference/SKILL.md @@ -27,7 +27,7 @@ returns 429 `WAITING_FOR_CAPACITY` with `Retry-After`. ```bash curl -X POST https://localhost:20128/api/v1/session-leases \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -40,7 +40,7 @@ OpenAI-compatible chat completions endpoint. Routes to configured providers. ```bash curl -X POST https://localhost:20128/api/v1/chat/completions \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -64,7 +64,7 @@ Routes to a specific provider by name. ```bash curl -X POST https://localhost:20128/api/v1/providers/{provider}/chat/completions \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -77,7 +77,7 @@ Provides compatibility with Ollama's /api/chat format. ```bash curl -X POST https://localhost:20128/api/v1/api/chat \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -90,7 +90,7 @@ Anthropic Messages API endpoint. Routes to Claude providers. ```bash curl -X POST https://localhost:20128/api/v1/messages \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -101,7 +101,7 @@ Count tokens for a message ```bash curl -X POST https://localhost:20128/api/v1/messages/count_tokens \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -114,7 +114,7 @@ OpenAI Responses API endpoint. ```bash curl -X POST https://localhost:20128/api/v1/responses \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -125,7 +125,7 @@ Create embeddings ```bash curl -X POST https://localhost:20128/api/v1/embeddings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -147,7 +147,7 @@ Same handler as `POST /api/v1/embeddings`. Provided so Jina-compatible clients t ```bash curl -X POST https://localhost:20128/api/v1/multimodal-embeddings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -158,7 +158,7 @@ Create embeddings (provider-specific) ```bash curl -X POST https://localhost:20128/api/v1/providers/{provider}/embeddings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -169,7 +169,7 @@ Generate images ```bash curl -X POST https://localhost:20128/api/v1/images/generations \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -180,7 +180,7 @@ Generate images (provider-specific) ```bash curl -X POST https://localhost:20128/api/v1/providers/{provider}/images/generations \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -193,7 +193,7 @@ Text-to-speech endpoint. Routes to configured TTS providers. ```bash curl -X POST https://localhost:20128/api/v1/audio/speech \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -206,7 +206,7 @@ Audio-to-text transcription endpoint. ```bash curl -X POST https://localhost:20128/api/v1/audio/transcriptions \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -219,7 +219,7 @@ Content moderation endpoint. Routes to configured moderation providers. ```bash curl -X POST https://localhost:20128/api/v1/moderations \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -232,7 +232,7 @@ Document reranking endpoint. ```bash curl -X POST https://localhost:20128/api/v1/rerank \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -278,7 +278,7 @@ Creates a subscription record. If `mode` is `rule`, at least one entry in `ruleP ```bash curl -X POST https://localhost:20128/api/v1/management/proxy-subscriptions \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -300,7 +300,7 @@ Partial update — only fields present in the body are changed (name/url/mode/ru ```bash curl -X PATCH https://localhost:20128/api/v1/management/proxy-subscriptions/{id} \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -335,7 +335,7 @@ Re-fetches and re-parses the subscription URL, syncs its nodes into `proxy_regis ```bash curl -X POST https://localhost:20128/api/v1/management/proxy-subscriptions/{id}/refresh \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -348,7 +348,7 @@ Multi-provider document OCR endpoint (Mistral OCR–compatible request and respo ```bash curl -X POST https://localhost:20128/api/v1/ocr \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -361,7 +361,7 @@ OpenAI Whisper–compatible audio translation (multipart/form-data). Unlike `/ap ```bash curl -X POST https://localhost:20128/api/v1/audio/translations \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-models/SKILL.md b/skills/omni-models/SKILL.md index a86ac9fd79..4d3b9aed0b 100644 --- a/skills/omni-models/SKILL.md +++ b/skills/omni-models/SKILL.md @@ -40,7 +40,7 @@ Create or update a model alias ```bash curl -X POST https://localhost:20128/api/models/alias \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-providers/SKILL.md b/skills/omni-providers/SKILL.md index 8a973dc354..532a134e41 100644 --- a/skills/omni-providers/SKILL.md +++ b/skills/omni-providers/SKILL.md @@ -29,7 +29,7 @@ Create provider connection ```bash curl -X POST https://localhost:20128/api/providers \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -49,7 +49,7 @@ Update provider connection ```bash curl -X PATCH https://localhost:20128/api/providers/{id} \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -69,7 +69,7 @@ Test provider connection ```bash curl -X POST https://localhost:20128/api/providers/{id}/test \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -100,7 +100,7 @@ Test multiple providers at once ```bash curl -X POST https://localhost:20128/api/providers/test-batch \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -111,7 +111,7 @@ Validate provider credentials ```bash curl -X POST https://localhost:20128/api/providers/validate \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -131,7 +131,7 @@ Import an Antigravity CLI (agy) token file as an `agy` connection ```bash curl -X POST https://localhost:20128/api/providers/agy-auth/import \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -142,7 +142,7 @@ Bulk-import multiple Antigravity CLI (agy) token files (up to 50) ```bash curl -X POST https://localhost:20128/api/providers/agy-auth/import-bulk \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -153,7 +153,7 @@ Extract `.json` token files from an uploaded ZIP for agy bulk import ```bash curl -X POST https://localhost:20128/api/providers/agy-auth/zip-extract \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -164,7 +164,7 @@ Auto-detect and import the local Antigravity CLI (agy) login from disk ```bash curl -X POST https://localhost:20128/api/providers/agy-auth/apply-local \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -184,7 +184,7 @@ Create provider node ```bash curl -X POST https://localhost:20128/api/provider-nodes \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -195,7 +195,7 @@ Update provider node ```bash curl -X PATCH https://localhost:20128/api/provider-nodes/{id} \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -215,7 +215,7 @@ Validate a provider node ```bash curl -X POST https://localhost:20128/api/provider-nodes/validate \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-settings/SKILL.md b/skills/omni-settings/SKILL.md index 9ab8f95dfb..f3d3a2ae5a 100644 --- a/skills/omni-settings/SKILL.md +++ b/skills/omni-settings/SKILL.md @@ -33,7 +33,7 @@ Update any subset of the extended memory settings. All fields are optional; only ```bash curl -X PUT https://localhost:20128/api/settings/memory \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -57,7 +57,7 @@ Update Qdrant configuration. Pass `apiKey: ""` to remove the stored key. Schema: ```bash curl -X PUT https://localhost:20128/api/settings/qdrant \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -81,7 +81,7 @@ Performs a test semantic search against the Qdrant collection. Useful for valida ```bash curl -X POST https://localhost:20128/api/settings/qdrant/search \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -94,7 +94,7 @@ Removes Qdrant points for memories that have expired or exceeded the configured ```bash curl -X POST https://localhost:20128/api/settings/qdrant/cleanup \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -125,7 +125,7 @@ Update settings ```bash curl -X PATCH https://localhost:20128/api/settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -138,7 +138,7 @@ Deletes `call_logs`, legacy `request_detail_logs`, and local request artifact fi ```bash curl -X POST https://localhost:20128/api/settings/purge-request-history \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -158,7 +158,7 @@ Update global compression settings ```bash curl -X PUT https://localhost:20128/api/settings/compression \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -180,7 +180,7 @@ Partial-merge update. Numeric floors (e.g. a maxTextChars below the truncation-t ```bash curl -X PUT https://localhost:20128/api/settings/compression/mcp-accessibility \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -212,7 +212,7 @@ Requires a dashboard management session cookie when management auth is enabled. ```bash curl -X PUT https://localhost:20128/api/settings/payload-rules \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -241,7 +241,7 @@ Update proxy settings ```bash curl -X PATCH https://localhost:20128/api/settings/proxy \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -252,7 +252,7 @@ Test proxy connection ```bash curl -X POST https://localhost:20128/api/settings/proxy/test \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -263,7 +263,7 @@ Toggle login requirement ```bash curl -X POST https://localhost:20128/api/settings/require-login \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -288,7 +288,7 @@ Configure IP filtering with blacklist/whitelist modes, add/remove individual IPs ```bash curl -X PUT https://localhost:20128/api/settings/ip-filter \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -310,7 +310,7 @@ Update system prompt configuration ```bash curl -X PUT https://localhost:20128/api/settings/system-prompt \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -332,7 +332,7 @@ Update thinking budget configuration ```bash curl -X PUT https://localhost:20128/api/settings/thinking-budget \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -365,7 +365,7 @@ Update quota store driver settings ```bash curl -X PUT https://localhost:20128/api/settings/quota-store \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -378,7 +378,7 @@ Dashboard-only. Purges stored usage-history records. ```bash curl -X POST https://localhost:20128/api/settings/purge-usage-history \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-sync-cloud/SKILL.md b/skills/omni-sync-cloud/SKILL.md index d6d7c5d41a..68c2d4a4a1 100644 --- a/skills/omni-sync-cloud/SKILL.md +++ b/skills/omni-sync-cloud/SKILL.md @@ -22,7 +22,7 @@ Authenticates with the OmniRoute cloud worker for remote access. ```bash curl -X POST https://localhost:20128/api/cloud/auth \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -33,7 +33,7 @@ Update cloud worker credentials ```bash curl -X PUT https://localhost:20128/api/cloud/credentials/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -46,7 +46,7 @@ Resolves a model request through the cloud worker. ```bash curl -X POST https://localhost:20128/api/cloud/model/resolve \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -66,7 +66,7 @@ Update cloud model alias ```bash curl -X PUT https://localhost:20128/api/cloud/models/alias \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -77,7 +77,7 @@ Sync with cloud ```bash curl -X POST https://localhost:20128/api/sync/cloud \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -88,7 +88,7 @@ Initialize cloud sync ```bash curl -X POST https://localhost:20128/api/sync/initialize \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-usage-logs/SKILL.md b/skills/omni-usage-logs/SKILL.md index dd090853a7..af685e0e35 100644 --- a/skills/omni-usage-logs/SKILL.md +++ b/skills/omni-usage-logs/SKILL.md @@ -105,7 +105,7 @@ Set or update budget limits for usage tracking. ```bash curl -X POST https://localhost:20128/api/usage/budget \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-version-manager/SKILL.md b/skills/omni-version-manager/SKILL.md index 711a8f3e8e..eceda51dbc 100644 --- a/skills/omni-version-manager/SKILL.md +++ b/skills/omni-version-manager/SKILL.md @@ -22,7 +22,7 @@ Installs the `9router` npm package under DATA_DIR/services/9router/. Uses execFi ```bash curl -X POST https://localhost:20128/api/services/9router/install \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -35,7 +35,7 @@ Spawns the 9Router process. Idempotent if already running. **LOCAL_ONLY** — lo ```bash curl -X POST https://localhost:20128/api/services/9router/start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -48,7 +48,7 @@ Gracefully stops 9Router (SIGTERM → 15 s → SIGKILL). Idempotent. **LOCAL_ONL ```bash curl -X POST https://localhost:20128/api/services/9router/stop \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -61,7 +61,7 @@ Equivalent to stop() then start() under the operation lock. **LOCAL_ONLY** — l ```bash curl -X POST https://localhost:20128/api/services/9router/restart \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -74,7 +74,7 @@ Stops the service (if running), installs the newer npm version, then restarts. * ```bash curl -X POST https://localhost:20128/api/services/9router/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -87,7 +87,7 @@ Generates a new API key, encrypts it at-rest, and restarts the service to apply ```bash curl -X POST https://localhost:20128/api/services/9router/rotate-key \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -111,7 +111,7 @@ When enabled, 9Router starts automatically on the next OmniRoute boot. **LOCAL_O ```bash curl -X POST https://localhost:20128/api/services/9router/auto-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -124,7 +124,7 @@ When enabled, an externally-adopted (not OmniRoute-spawned) 9Router process is r ```bash curl -X POST https://localhost:20128/api/services/9router/auto-restart-adopted \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -137,7 +137,7 @@ Installs the CLIProxyAPI package under DATA_DIR/services/cliproxy/. **LOCAL_ONLY ```bash curl -X POST https://localhost:20128/api/services/cliproxy/install \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -150,7 +150,7 @@ Spawns the CLIProxyAPI process. Idempotent if already running. **LOCAL_ONLY** ```bash curl -X POST https://localhost:20128/api/services/cliproxy/start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -163,7 +163,7 @@ Gracefully stops CLIProxyAPI. Idempotent. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/cliproxy/stop \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -176,7 +176,7 @@ stop() then start() under the operation lock. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/cliproxy/restart \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -189,7 +189,7 @@ Stops, installs newer version, restarts. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/cliproxy/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -213,7 +213,7 @@ When enabled, CLIProxyAPI starts automatically on the next OmniRoute boot. **LOC ```bash curl -X POST https://localhost:20128/api/services/cliproxy/auto-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -226,7 +226,7 @@ When enabled, an externally-adopted (not OmniRoute-spawned) CLIProxyAPI process ```bash curl -X POST https://localhost:20128/api/services/cliproxy/auto-restart-adopted \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -239,7 +239,7 @@ Installs the `mux` npm package (coder/mux — local agent-orchestration daemon) ```bash curl -X POST https://localhost:20128/api/services/mux/install \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -252,7 +252,7 @@ Spawns `mux server --host 127.0.0.1 --port `. Idempotent if already runnin ```bash curl -X POST https://localhost:20128/api/services/mux/start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -265,7 +265,7 @@ Gracefully stops Mux. Idempotent. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/mux/stop \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -278,7 +278,7 @@ stop() then start() under the operation lock. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/mux/restart \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -291,7 +291,7 @@ Stops, installs newer version, restarts. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/mux/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -315,7 +315,7 @@ When enabled, Mux starts automatically on the next OmniRoute boot. **LOCAL_ONLY* ```bash curl -X POST https://localhost:20128/api/services/mux/auto-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -328,7 +328,7 @@ When enabled, an externally-adopted (not OmniRoute-spawned) Mux process is resta ```bash curl -X POST https://localhost:20128/api/services/mux/auto-restart-adopted \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -341,7 +341,7 @@ Installs the `@maximhq/bifrost` npm package under DATA_DIR/services/bifrost/. Th ```bash curl -X POST https://localhost:20128/api/services/bifrost/install \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -354,7 +354,7 @@ Starts the supervised Bifrost process. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/bifrost/start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -367,7 +367,7 @@ Stops the supervised Bifrost process. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/bifrost/stop \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -380,7 +380,7 @@ Restarts the supervised Bifrost process. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/bifrost/restart \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -393,7 +393,7 @@ Updates Bifrost to the latest npm version. Stops the running process, installs t ```bash curl -X POST https://localhost:20128/api/services/bifrost/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -417,7 +417,7 @@ When enabled, Bifrost starts automatically on the next OmniRoute boot. **LOCAL_O ```bash curl -X POST https://localhost:20128/api/services/bifrost/auto-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -430,7 +430,7 @@ When enabled, an externally-adopted (not OmniRoute-spawned) Bifrost process is r ```bash curl -X POST https://localhost:20128/api/services/bifrost/auto-restart-adopted \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -443,7 +443,7 @@ Installs the `@askalf/dario` npm package (Claude-account-pool proxy) under DATA_ ```bash curl -X POST https://localhost:20128/api/services/dario/install \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -456,7 +456,7 @@ Spawns the Dario process. Idempotent if already running. **LOCAL_ONLY** — loop ```bash curl -X POST https://localhost:20128/api/services/dario/start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -469,7 +469,7 @@ Gracefully stops Dario. Idempotent — returns a stopped status even if no super ```bash curl -X POST https://localhost:20128/api/services/dario/stop \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -482,7 +482,7 @@ Equivalent to stop() then start() under the operation lock. **LOCAL_ONLY** — l ```bash curl -X POST https://localhost:20128/api/services/dario/restart \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -495,7 +495,7 @@ Stops the service (if running), installs the newer npm version, then restarts it ```bash curl -X POST https://localhost:20128/api/services/dario/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -519,7 +519,7 @@ When enabled, Dario starts automatically on the next OmniRoute boot. **LOCAL_ONL ```bash curl -X POST https://localhost:20128/api/services/dario/auto-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -532,7 +532,7 @@ When enabled, an externally-adopted (not OmniRoute-spawned) Dario process is res ```bash curl -X POST https://localhost:20128/api/services/dario/auto-restart-adopted \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -545,7 +545,7 @@ Forwards to the running Dario instance's `POST /admin/login/start` using the sto ```bash curl -X POST https://localhost:20128/api/services/dario/admin/login-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -558,7 +558,7 @@ Forwards to the running Dario instance's `POST /admin/login/complete`. On succes ```bash curl -X POST https://localhost:20128/api/services/dario/admin/login-complete \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -604,7 +604,7 @@ Writes the source connection's access/refresh token pair directly into Dario's o ```bash curl -X POST https://localhost:20128/api/services/dario/admin/import-from-omniroute \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index d427f4fcc4..897445fd0c 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -19,6 +19,7 @@ import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron"; import { HomeProviderTopologySection } from "./HomeProviderTopologySection"; import { shouldShowProviderTopologyOnHome } from "./homeAppearance"; +import HomeRecentRequests from "../home/HomeRecentRequests"; type UpdateStep = { step: string; @@ -1126,12 +1127,15 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { )} {showProviderTopologyOnHome && ( - +
+ + +
)} {/* Provider Models Modal */} diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 5cf1c76784..fd9d1e3b7e 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -188,6 +188,8 @@ const ADVANCED_FIELD_HELP_FALLBACK = { "Delay between set-level retry attempts, giving transient issues time to resolve.", nestedComboMode: "How references to other combos are handled. Flatten expands a combo ref into this combo's target list (legacy). Execute treats a combo ref as a black-box target: the parent strategy selects the child combo, then the child runs its own strategy and retries.", + reasoningTransportFallback: + "What to do when the next combo target cannot accept the original reasoning transport. Drop is the default: it removes reasoning state and tries the target. Skip leaves the request body untouched and falls through.", }; const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ @@ -1440,6 +1442,33 @@ function ComboUsageGuide({ onHide, onHideForever, onCreateCombo }) { })} +
+

+ {getI18nOrFallback(t, "usageGuideInvokeTitle", "How to call this combo")} +

+

+ {getI18nOrFallback( + t, + "usageGuideInvokeDesc", + 'Send the combo\'s exact name as the model, e.g. model: "my-combo" (or combo/my-combo).' + )} +

+

+ {getI18nOrFallback( + t, + "usageGuideInvokeAutoNote", + "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto)." + )} +

+

+ {getI18nOrFallback( + t, + "usageGuideInvokeOpenrouterNote", + "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models." + )} +

+
+
- ); - })} +
+ + +
-

- {getI18nOrFallback( - t, - "builderRestrictAccountsHint", - "Leave empty to use the whole active pool. When selected, round-robin / weighted picks stay within this subset of accounts." - )} -

- - ) : null} - {isExpertMode ? ( -
- - {builderHasDuplicate && ( - - {getI18nOrFallback( - t, - "builderDuplicateExact", - "This exact provider/model/account step is already in the combo." - )} - - )} -
- ) : ( -
-

- {getI18nOrFallback(t, "builderPreview", "Current step preview")} -

-

- {builderCandidateStep - ? formatModelDisplay(builderCandidateStep) - : getI18nOrFallback( - t, - "previewNextStep", - "Choose provider and model to preview the next step." - )} -

-
- - {builderHasDuplicate && ( - + {builderConnectionId === COMBO_BUILDER_AUTO_CONNECTION && + selectedBuilderConnections.length > 1 ? ( +
+
-
- )} + +
+ {selectedBuilderConnections.map((connection) => { + const checked = builderAllowedConnectionIds.includes(connection.id); + return ( + + ); + })} +
+

+ {getI18nOrFallback( + t, + "builderRestrictAccountsHint", + "Leave empty to use the whole active pool. When selected, round-robin / weighted picks stay within this subset of accounts." + )} +

+
+ ) : null} -
- -
- - -
-
+ + ) : ( +
+

+ {getI18nOrFallback(t, "builderPreview", "Current step preview")} +

+

+ {builderCandidateStep + ? formatModelDisplay(builderCandidateStep) + : getI18nOrFallback( + t, + "previewNextStep", + "Choose provider and model to preview the next step." + )} +

+
+ + {builderHasDuplicate && ( + + {getI18nOrFallback( + t, + "builderDuplicateExact", + "This exact provider/model/account step is already in the combo." + )} + + )} +
+
+ )} + +
+ +
+ + +
+
)} @@ -3796,6 +3844,58 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
+
+ + + {config.reasoningTransportFallback !== "skip" && ( +

+ {getI18nOrFallback( + t, + "reasoningTransportFallbackDropWarning", + "May lose continuation context or cause tool-call continuations to fail." + )} +

+ )} +
= { "Content-Type": "application/json" }; + const chatEndpoint = isChatCompletionsEndpoint(configState.endpoint); + const requestBody = chatEndpoint + ? buildRequestBody(chatMessages) + : buildNonChatRequestBody( + configState.endpoint, + lastUserContent(chatMessages), + configState.model + ); - const res = await fetch("/api/v1/chat/completions", { + const res = await fetch(resolveChatTabRequestPath(configState.endpoint), { method: "POST", headers: fetchHeaders, - body: JSON.stringify(buildRequestBody(chatMessages)), + body: JSON.stringify(requestBody), signal: controller.signal, }); @@ -150,6 +165,20 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps) return; } + if (!chatEndpoint) { + const rawText = await res.text(); + setMessages((prev) => { + const next = [...prev]; + const idx = appendIndex !== undefined ? appendIndex : next.length - 1; + next[idx] = { ...next[idx], content: formatNonChatResponse(rawText) }; + return next; + }); + setResponseDuration(Date.now() - startTime); + setLoading(false); + streamMetrics.reset(); + return; + } + let firstChunk = true; const reader = res.body?.getReader(); const decoder = new TextDecoder(); diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts b/src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts new file mode 100644 index 0000000000..dda2b7825a --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts @@ -0,0 +1,59 @@ +// src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts +// +// #10592 — ChatTab.tsx hardcoded every "Send" click to POST /api/v1/chat/completions, +// ignoring configState.endpoint entirely. Selecting a search-only provider (exa-search, +// tavily-search, serper-search) in the Endpoint selector still sent a chat.completions +// request, which has no notion of search-provider credentials and 404s. +// +// This module gives ChatTab a small, testable seam for routing non-chat endpoints +// (currently "search" and "web.fetch") to their real path with a query-shaped body, +// instead of the chat.completions messages/SSE shape. + +import { endpointToPath, type PlaygroundEndpoint } from "@/lib/playground/codeExport"; + +/** Chat-shaped endpoints keep the existing messages[] + SSE-delta request/response flow. */ +export function isChatCompletionsEndpoint(endpoint: PlaygroundEndpoint | undefined): boolean { + return !endpoint || endpoint === "chat.completions"; +} + +/** Resolves the fetch path (mounted under `/api`) for the selected Playground endpoint. */ +export function resolveChatTabRequestPath(endpoint: PlaygroundEndpoint | undefined): string { + return `/api${endpointToPath(endpoint ?? "chat.completions")}`; +} + +/** + * Builds the request body for a non-chat endpoint from the user's free-text query. + * "search" and "web.fetch" both take a single string field instead of a messages array. + */ +export function buildNonChatRequestBody( + endpoint: PlaygroundEndpoint | undefined, + query: string, + model: string +): Record { + if (endpoint === "web.fetch") { + return { url: query }; + } + const body: Record = { query }; + if (model) body.model = model; + return body; +} + +/** Renders a non-chat endpoint's raw response text as a chat-bubble-friendly string. */ +export function formatNonChatResponse(rawText: string): string { + try { + const parsed = JSON.parse(rawText) as unknown; + return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```"; + } catch { + return rawText; + } +} + +/** Finds the most recent user-authored message content to use as a non-chat query. */ +export function lastUserContent( + chatMessages: Array<{ role: string; content: string }> +): string { + for (let i = chatMessages.length - 1; i >= 0; i--) { + if (chatMessages[i].role === "user") return chatMessages[i].content; + } + return ""; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CodexAccountDetails.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CodexAccountDetails.tsx new file mode 100644 index 0000000000..02cbb8ecd8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CodexAccountDetails.tsx @@ -0,0 +1,72 @@ +"use client"; + +import type { CodexAccountPoolProjection } from "@omniroute/open-sse/services/codexAccount/index.ts"; +import { useLocale, useTranslations } from "next-intl"; + +export interface CodexAccountDetailsProps { + pool: CodexAccountPoolProjection; +} + +function formatQuota( + window: CodexAccountPoolProjection["children"][number]["quota"]["windows"]["5h"], + usedLabel: string +): string { + if (!window) return "—"; + if (window.usedPercentage !== null) return `${Math.round(window.usedPercentage)}% ${usedLabel}`; + if (window.usage !== null && window.limit !== null) return `${window.usage}/${window.limit}`; + return "—"; +} + +export default function CodexAccountDetails({ pool }: CodexAccountDetailsProps) { + const t = useTranslations("providers"); + const locale = useLocale(); + const statusLabels = { + available: t("codexPoolAvailable"), + partially_limited: t("codexPoolPartiallyLimited"), + fully_limited: t("codexPoolFullyLimited"), + }; + return ( +
+
+ {t("codexQuotaPools")} + + {statusLabels[pool.aggregate.status]} ·{" "} + {t("codexPoolLimited", { count: pool.aggregate.limitedChildCount })} + +
+
+ {pool.children.map((child) => ( +
+
+ {child.key.scope === "codex" ? "Codex" : "Spark"} + + {child.quota.exhaustedWindow + ? t("codexPoolQuotaExhausted") + : child.cooldown.active + ? t("codexPoolCoolingDown") + : t("codexPoolAvailable")} + +
+
+ 5h: {formatQuota(child.quota.windows["5h"], t("codexPoolUsed"))} + 7d: {formatQuota(child.quota.windows["7d"], t("codexPoolUsed"))} +
+ {child.cooldown.rateLimitedUntil ? ( +
+ {t("codexPoolUntil", { + value: new Intl.DateTimeFormat(locale, { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(child.cooldown.rateLimitedUntil)), + })} +
+ ) : null} +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx index 02f1d27267..ddfe36ba2e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx @@ -17,6 +17,8 @@ import { } from "@/lib/providers/codexFastTier"; import { normalizeCodexLimitPolicy, providerText, ERROR_TYPE_LABELS } from "../providerPageHelpers"; import { getCodexPlanLabel } from "../codexPlanLabel"; +import type { CodexAccountPoolProjection } from "@omniroute/open-sse/services/codexAccount/index.ts"; +import CodexAccountDetails from "./CodexAccountDetails"; import ProviderQuotaVisibilityToggle from "./ProviderQuotaVisibilityToggle"; // --------------------------------------------------------------------------- @@ -48,6 +50,7 @@ export interface ConnectionRowConnection { proxyEnabled?: boolean; perKeyProxyEnabled?: boolean; quotaVisible?: boolean; + codexAccountPool?: CodexAccountPoolProjection; } export interface ConnectionRowProps { @@ -963,6 +966,9 @@ export default function ConnectionRow({
+ {isCodex && connection.codexAccountPool ? ( + + ) : null} ); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index de377cd9cb..8bdddc2aa7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -88,6 +88,7 @@ export default function AddApiKeyModal({ const isModal = provider === "modal"; const isGlm = isGlmProvider(provider); const isQoder = provider === "qoder"; + const isFreebuff = provider === "freebuff"; const openRouterPreset = useOpenRouterPresetControl(provider, t); const isCloudflare = provider === "cloudflare-ai"; const localProviderMetadata = getLocalProviderMetadata(provider); @@ -191,9 +192,11 @@ export default function AddApiKeyModal({ ? webSessionCredential.placeholder : isQoder ? t("qoderPatPlaceholder") - : apiKeyOptional - ? t("optional") - : undefined; + : isFreebuff + ? "Enter Freebuff / Codebuff Auth Token (e.g. 038fcdf9-...)" + : apiKeyOptional + ? t("optional") + : undefined; const apiCredentialHint = isModal ? providerText( t, @@ -202,8 +205,10 @@ export default function AddApiKeyModal({ ) : isQoder ? t("qoderPatHint") - : isWebSessionCredential - ? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false) + : isFreebuff + ? "Freebuff uses an authentic CLI auth token obtained via codebuff CLI login or automated harvester." + : isWebSessionCredential + ? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false) : isLocalSelfHostedProvider ? t("localProviderApiKeyOptionalHint", { provider: localProviderMetadata?.name || providerName || provider || "", diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index 4c33d2882a..39028de136 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -208,6 +208,7 @@ export default function EditConnectionModal({ (provider.startsWith("openai-compatible-responses-") || connectionProviderSpecificData?.apiType === "responses" || formData.targetFormat === "openai-responses")); + const isCustomResponsesConnection = isResponsesConnection && !isCodex && provider !== "openai"; const isClaude = provider === "claude"; const isAntigravityFamily = provider === "antigravity" || provider === "agy"; const localProviderMetadata = getLocalProviderMetadata(provider); @@ -667,8 +668,12 @@ export default function EditConnectionModal({ } } if (isResponsesConnection && updates.providerSpecificData) { - updates.providerSpecificData.preserveEncryptedReasoning = - formData.preserveEncryptedReasoning === true; + if (isCustomResponsesConnection) { + updates.providerSpecificData.preserveEncryptedReasoning = + formData.preserveEncryptedReasoning === true; + } else { + delete updates.providerSpecificData.preserveEncryptedReasoning; + } updates.providerSpecificData.openaiStoreEnabled = formData.openaiResponsesStoreEnabled === true; } @@ -701,7 +706,7 @@ export default function EditConnectionModal({ !testResult?.valid && testResult?.diagnosis?.type ? ERROR_TYPE_LABELS[testResult.diagnosis.type] || null : null; - const preserveEncryptedReasoningToggle = isResponsesConnection ? ( + const preserveEncryptedReasoningToggle = isCustomResponsesConnection ? ( setFormData({ ...formData, preserveEncryptedReasoning: checked })} diff --git a/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx index d5eb058a73..d4e9b88f9b 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx @@ -126,7 +126,7 @@ export default function AccessTokensTab() {

{L( "accessTokensDescription", - "Scoped tokens that let the omniroute CLI manage this server remotely. Distinct from inference API keys. The secret is shown once." + "Scoped tokens that let the omniroute CLI manage this server remotely. Distinct from inference API keys. The secret is shown once. Automation guide: /docs/guides/MANAGEMENT-AUTH." )}

diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx index 4989c397eb..e0b0ee66ab 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx @@ -9,7 +9,9 @@ import { type PricingCatalogProvider, } from "@/lib/modelCapabilityOverrideTargets"; -type ModelOverrideKey = "context_length" | "max_input_tokens" | "max_output_tokens"; +type ModelOverrideKey = + "context_length" | "max_input_tokens" | "max_output_tokens" | "reasoning_efforts"; +type ModelOverrideValue = number | string[]; type StatusTone = "success" | "error" | "info"; type ModelOverrideTarget = import("@/lib/modelCapabilityOverrideTargets").ModelOverrideTarget; @@ -22,7 +24,7 @@ interface PricingCatalogModel { interface ModelCapabilityOverride { target: string; key: ModelOverrideKey; - value: number; + value: ModelOverrideValue; } interface StatusMessage { @@ -70,7 +72,7 @@ function useModelCapabilityOverridesData() { }, [showStatus, t]); const saveOverride = useCallback( - async (target: string, key: ModelOverrideKey, value: number) => { + async (target: string, key: ModelOverrideKey, value: number | string) => { try { const response = await fetch("/api/model-capability-overrides", { method: "PATCH", @@ -150,7 +152,7 @@ function ModelCapabilityOverridesPanel({ }: { targets: ModelOverrideTarget[]; overrides: ModelCapabilityOverride[]; - onSave: (target: string, key: ModelOverrideKey, value: number) => void; + onSave: (target: string, key: ModelOverrideKey, value: number | string) => void; onRemove: (target: string, key: ModelOverrideKey) => void; }) { const [selectedTarget, setSelectedTarget] = useState(""); @@ -294,7 +296,7 @@ function ModelOverrideEditor({ activeOverrides: ModelCapabilityOverride[]; activeTarget: string; onRemove: (target: string, key: ModelOverrideKey) => void; - onSave: (target: string, key: ModelOverrideKey, value: number) => void; + onSave: (target: string, key: ModelOverrideKey, value: number | string) => void; }) { const t = useTranslations("settings"); return ( @@ -316,13 +318,18 @@ function ModelOverrideForm({ onSave, }: { activeTarget: string; - onSave: (target: string, key: ModelOverrideKey, value: number) => void; + onSave: (target: string, key: ModelOverrideKey, value: number | string) => void; }) { const t = useTranslations("settings"); const [key, setKey] = useState("context_length"); const [value, setValue] = useState(""); + const isReasoningEfforts = key === "reasoning_efforts"; const numericValue = Number(value); - const saveDisabled = !activeTarget || !Number.isInteger(numericValue) || numericValue <= 0; + const saveDisabled = + !activeTarget || + (isReasoningEfforts + ? value.length === 0 + : !Number.isInteger(numericValue) || numericValue <= 0); return (
@@ -334,14 +341,19 @@ function ModelOverrideForm({ + setValue(event.target.value)} - placeholder={t("modelOverrideValuePlaceholder")} + placeholder={t( + isReasoningEfforts + ? "modelOverrideReasoningEffortsPlaceholder" + : "modelOverrideValuePlaceholder" + )} className="flex-1 px-3 py-2 text-xs bg-bg-base border border-border rounded-md focus:outline-none focus:border-primary" />
Claude Code
- Claude Code + کد Claude

⭐ 67.3K
Kilo Code
- Kilo Code + کد کیلو

⭐ 15.5K
+ + + + + + + + + {rows.map((row, i) => { + const state = requestState(row); + return ( + + + + + + + ); + })} + +
+ {t("recentRequestsModel")} + {t("recentRequestsTokens")} + {t("recentRequestsWhen")}
+ + + {row.model || "—"} + + {fmtCompact(row.tokens?.in)}↑{" "} + {fmtCompact(row.tokens?.out)}↓ + + {state === "active" ? ( + ••• + ) : ( + timeAgo(row.timestamp, nowMs) + )} +
+
+ )} + + ); +} diff --git a/src/app/api/analytics/auto-routing/route.ts b/src/app/api/analytics/auto-routing/route.ts index f4a5874b20..27fd7e8350 100644 --- a/src/app/api/analytics/auto-routing/route.ts +++ b/src/app/api/analytics/auto-routing/route.ts @@ -16,7 +16,6 @@ export async function GET(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; try { - // Query usage_logs for auto/ prefix requests const totalRequests = getAutoRoutingTotalCount(); // Variant breakdown diff --git a/src/app/api/cache/stats/route.ts b/src/app/api/cache/stats/route.ts index bdeedb7bec..33f3c447f9 100644 --- a/src/app/api/cache/stats/route.ts +++ b/src/app/api/cache/stats/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPromptCache } from "@/lib/cacheLayer"; +import { clearMemoryCache, getMemoryCacheStats } from "@/lib/semanticCache"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; @@ -9,9 +9,7 @@ export async function GET(req: NextRequest) { } try { - const cache = getPromptCache(); - const stats = cache.getStats(); - return NextResponse.json(stats); + return NextResponse.json(getMemoryCacheStats()); } catch (error) { return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 }); } @@ -23,8 +21,7 @@ export async function DELETE(req: NextRequest) { } try { - const cache = getPromptCache(); - cache.clear(); + clearMemoryCache(); return NextResponse.json({ success: true, message: "Cache cleared" }); } catch (error) { return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 }); diff --git a/src/app/api/combos/[id]/route.ts b/src/app/api/combos/[id]/route.ts index 869114486f..dd35562bdf 100644 --- a/src/app/api/combos/[id]/route.ts +++ b/src/app/api/combos/[id]/route.ts @@ -265,6 +265,12 @@ export async function PUT(request, { params }) { } } +// PATCH /api/combos/[id] - partial update. PUT merges the body onto the stored +// combo, so both verbs share one handler (same shape as /api/providers/[id]). +export async function PATCH(request, ctx) { + return PUT(request, ctx); +} + // DELETE /api/combos/[id] - Delete combo export async function DELETE(request, { params }) { const authError = await requireManagementAuth(request); diff --git a/src/app/api/cursor-cli/[...path]/route.ts b/src/app/api/cursor-cli/[...path]/route.ts new file mode 100644 index 0000000000..04d53d194f --- /dev/null +++ b/src/app/api/cursor-cli/[...path]/route.ts @@ -0,0 +1,14 @@ +import { handleCursorCliProxy } from "@omniroute/open-sse/handlers/cursorCliProxy.ts"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type RouteContext = { params: Promise<{ path: string[] }> }; + +async function proxy(request: Request, context: RouteContext): Promise { + const { path } = await context.params; + return handleCursorCliProxy(request, path ?? []); +} + +export const GET = proxy; +export const POST = proxy; diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000000..2ead593b78 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; + +/** + * GET /api/health — canonical liveness probe, no auth required. + * + * Without this route, `/api/health` fell through to the `/api/*` catch-all, and the + * management-auth boundary answered before routing: an unauthenticated caller got a 401, + * which is exactly what a wrong or missing key returns. An orchestrator (Docker HEALTHCHECK, + * a Kubernetes probe, a monitoring curl) cannot tell "service down" from "bad credentials" + * from "no such route" — the ambiguity the #6424 catch-all was written to remove for + * authenticated callers, still intact for the one caller that never authenticates. + * + * Deliberately minimal: `{ status, timestamp }` and nothing else. Whatever this returns is + * public on an exposed instance, so version, uptime and memory stay behind the authenticated + * `/api/monitoring/health`. For a probe that also confirms the database answers, use + * `/api/health/ping`. + */ + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json( + { status: "ok", timestamp: new Date().toISOString() }, + { + status: 200, + headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, + } + ); +} diff --git a/src/app/api/model-capability-overrides/route.ts b/src/app/api/model-capability-overrides/route.ts index a5673df4d5..2b03ef8a3a 100644 --- a/src/app/api/model-capability-overrides/route.ts +++ b/src/app/api/model-capability-overrides/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { resolveProviderAlias } from "@omniroute/open-sse/services/model.ts"; +import { parseReasoningEffortsOverride } from "@/shared/reasoning/reasoningEffortsOverride"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { listModelCapabilityOverrides, removeModelCapabilityOverride, setModelCapabilityOverride, - type ModelCapabilityOverride, type ModelCapabilityOverrideKey, } from "@/lib/db/modelCapabilityOverrides"; import { @@ -16,9 +16,21 @@ import { } from "@/lib/db/modelContextOverrides"; import { getProviderPrefixIndex, type ProviderPrefixEntry } from "@/lib/providerNodePrefixes"; -const overrideKeySchema = z.enum(["context_length", "max_input_tokens", "max_output_tokens"]); +const overrideKeySchema = z.enum([ + "context_length", + "max_input_tokens", + "max_output_tokens", + "reasoning_efforts", +]); type PublicOverrideKey = z.infer; -type PublicOverride = Omit & { key: PublicOverrideKey }; +type PublicOverride = { + provider: string; + modelId: string; + target: string; + key: PublicOverrideKey; + value: number | string[]; + refreshedAt: string; +}; /** * One-time per-request snapshot of the provider-node prefix index. Loaded once @@ -79,12 +91,28 @@ async function listPublicOverrides( .sort((left, right) => right.refreshedAt.localeCompare(left.refreshedAt)); } -const upsertOverrideSchema = z.object({ - target: z.string().min(3), - key: overrideKeySchema, - value: z.coerce.number().int().positive(), +const reasoningEffortsValueSchema = z.string().transform((value, context) => { + const parsed = parseReasoningEffortsOverride(value); + if (!parsed.ok) { + context.addIssue({ code: "custom", message: parsed.error }); + return z.NEVER; + } + return parsed.efforts; }); +const upsertOverrideSchema = z.discriminatedUnion("key", [ + z.object({ + target: z.string().min(3), + key: z.enum(["context_length", "max_input_tokens", "max_output_tokens"]), + value: z.coerce.number().int().positive(), + }), + z.object({ + target: z.string().min(3), + key: z.literal("reasoning_efforts"), + value: reasoningEffortsValueSchema, + }), +]); + /** * Canonicalize a public `/` target to `/` * so the override is stored where runtime lookup reads it. Mirrors runtime diff --git a/src/app/api/providers/[id]/models/discovery/codex.ts b/src/app/api/providers/[id]/models/discovery/codex.ts index 6e71592a3f..4d113863f3 100644 --- a/src/app/api/providers/[id]/models/discovery/codex.ts +++ b/src/app/api/providers/[id]/models/discovery/codex.ts @@ -1,4 +1,5 @@ import { + CODEX_CLI_RS_ORIGINATOR, getCodexClientVersion, getCodexDefaultHeaders, } from "@omniroute/open-sse/config/codexClient.ts"; @@ -464,7 +465,7 @@ export async function fetchCodexDiscoveryModels({ Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, - originator: "codex_cli_rs", + originator: CODEX_CLI_RS_ORIGINATOR, }; if (workspaceId) headers["chatgpt-account-id"] = workspaceId; diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index 4cb2eb02e3..62988798d7 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -3,6 +3,7 @@ import { GROK_BUILD_DEFAULT_CONTEXT_WINDOW, getGrokBuildModelsHeaders, GROK_BUILD_MODELS_URL, + GROK_BUILD_SUPPORTED_REASONING_EFFORTS, } from "@omniroute/open-sse/config/grokBuild.ts"; import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser"; @@ -220,7 +221,10 @@ function getGrokBuildModelItems(data: unknown): unknown[] { return Array.isArray(envelope.models) ? envelope.models : []; } -function hasGrokBuildReasoning(model: GrokBuildModelRecord, metadata: GrokBuildModelRecord) { +function hasGrokBuildReasoning( + model: GrokBuildModelRecord, + metadata: GrokBuildModelRecord +): boolean { const flags = [ model.supportsReasoningEffort, model.supports_reasoning_effort, @@ -245,6 +249,35 @@ function hasGrokBuildReasoning(model: GrokBuildModelRecord, metadata: GrokBuildM ); } +function getGrokBuildReasoningEfforts( + model: GrokBuildModelRecord, + metadata: GrokBuildModelRecord +): string[] { + const supported = new Set(GROK_BUILD_SUPPORTED_REASONING_EFFORTS); + const effortLists = [ + model.reasoningEfforts, + model.reasoning_efforts, + metadata.reasoningEfforts, + metadata.reasoning_efforts, + ]; + const hasExplicitEffortList = effortLists.some((value) => Array.isArray(value)); + const discovered = effortLists + .flatMap((value) => (Array.isArray(value) ? value : [])) + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim().toLowerCase()) + .filter((value) => supported.has(value)); + if (hasExplicitEffortList) return [...new Set(discovered)]; + + const singleEffort = grokBuildString( + model.reasoningEffort, + model.reasoning_effort, + metadata.reasoningEffort, + metadata.reasoning_effort + )?.toLowerCase(); + if (singleEffort && supported.has(singleEffort)) return [singleEffort]; + return hasGrokBuildReasoning(model, metadata) ? [...GROK_BUILD_SUPPORTED_REASONING_EFFORTS] : []; +} + function normalizeGrokBuildModel(value: unknown): GrokBuildModelRecord | null { const model = asGrokBuildRecord(value); const metadata = asGrokBuildRecord(model._meta); @@ -285,6 +318,8 @@ function normalizeGrokBuildModel(value: unknown): GrokBuildModelRecord | null { model.max_completion_tokens ); const description = grokBuildString(model.description); + const supportsThinking = hasGrokBuildReasoning(model, metadata); + const supportedThinkingEfforts = getGrokBuildReasoningEfforts(model, metadata); return { id, @@ -293,7 +328,8 @@ function normalizeGrokBuildModel(value: unknown): GrokBuildModelRecord | null { ...(description ? { description } : {}), inputTokenLimit, ...(outputTokenLimit ? { outputTokenLimit } : {}), - ...(hasGrokBuildReasoning(model, metadata) ? { supportsThinking: true } : {}), + ...(supportsThinking ? { supportsThinking: true } : {}), + ...(supportedThinkingEfforts.length > 0 ? { supportedThinkingEfforts } : {}), apiFormat: "responses", supportedEndpoints: ["responses"], }; diff --git a/src/app/api/providers/[id]/models/discovery/providerSets.ts b/src/app/api/providers/[id]/models/discovery/providerSets.ts index d443cfabc7..8238681479 100644 --- a/src/app/api/providers/[id]/models/discovery/providerSets.ts +++ b/src/app/api/providers/[id]/models/discovery/providerSets.ts @@ -23,6 +23,7 @@ export const NAMED_OPENAI_STYLE_PROVIDERS = new Set([ "openadapter", "dit", "tokenrouter", + "token-kiosk", // provider-model-sweep (2026-06-19): same class as #3976/#4202/#4249 — keyed // openai-style providers with a real live `/models` catalog, served // their small hardcoded seed because unclassified. Seed stays as offline fallback. diff --git a/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts b/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts index 6e521e81ce..dc2d8966aa 100644 --- a/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts +++ b/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts @@ -19,3 +19,44 @@ export function isDegradedLocalCatalog(modelsData: { typeof modelsData?.source === "string" ? modelsData.source.trim().toLowerCase() : ""; return source === "local_catalog" && modelsData?.intentional !== true; } + +/** + * #9683 — the same degradation, one branch further up. + * + * When remote discovery fails, the models route falls back to the CACHED + * catalog if it has one and only falls back to the local catalog when it does + * not (`buildDiscoveryFallbackResponse`). A provider that was imported + * successfully once therefore has a cache, so an expired key produced + * `source: "cache"` + a warning and HTTP 200 — model-sync treated that as a + * successful discovery, found every cached model already imported, and reported + * "No new models were added" instead of the credential error. Retest, which + * does not go through this path, failed correctly, which is what made the + * import look like a real "nothing to do". + * + * The discriminator is the warning: the fallback builder always attaches one, + * while the ordinary cache hit (`maybeReturnCachedDiscovery`, a non-refresh + * read) attaches none. Model-sync always requests `refresh=true`, so the one + * warning-carrying cache response it can observe is a degraded one. + */ +export function isDegradedCachedCatalog(modelsData: { + source?: unknown; + warning?: unknown; +}): boolean { + const source = + typeof modelsData?.source === "string" ? modelsData.source.trim().toLowerCase() : ""; + if (source !== "cache") return false; + return typeof modelsData?.warning === "string" && modelsData.warning.trim().length > 0; +} + +/** + * Either degraded shape. Model-sync must refuse to treat these as a successful + * discovery: persisting them would silently pin a stale catalog and hide the + * real failure from the operator. + */ +export function isDegradedDiscovery(modelsData: { + source?: unknown; + intentional?: unknown; + warning?: unknown; +}): boolean { + return isDegradedLocalCatalog(modelsData) || isDegradedCachedCatalog(modelsData); +} diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 95986e15a9..1d46c269f0 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -22,7 +22,7 @@ import { autoSyncCodexProfilesFromLiveCatalog } from "@/lib/cli-helper/codexProf import { autoSyncClaudeProfilesFromLiveCatalog } from "@/lib/cli-helper/claudeProfileAutoSync"; import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability"; import { GET as getProviderModels } from "../models/route"; -import { isDegradedLocalCatalog } from "./degradedLocalCatalog"; +import { isDegradedDiscovery } from "./degradedLocalCatalog"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; type JsonRecord = Record; @@ -465,9 +465,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const modelSource = toNonEmptyString(modelsData.source)?.toLowerCase() || "unknown"; const modelWarning = toNonEmptyString(modelsData.warning); - if (isDegradedLocalCatalog(modelsData)) { + if (isDegradedDiscovery(modelsData)) { const responseError = - modelWarning || "Remote model discovery failed; local catalog fallback not synced"; + modelWarning || "Remote model discovery failed; catalog fallback not synced"; await saveCallLog({ method: "GET", path: `/api/providers/${id}/models`, diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index 41a9aa3f20..c43ea7c5b3 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -2,6 +2,7 @@ import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oaut import { ANTIGRAVITY_RUNTIME_BASE_URLS } from "@omniroute/open-sse/config/antigravityUpstream.ts"; import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { getAntigravityClientProfile } from "@omniroute/open-sse/services/antigravityClientProfile.ts"; +import { isGeoBlockedError } from "@omniroute/open-sse/services/errorClassifier.ts"; // Real model-surface probe for antigravity/agy. The previous probe only hit the // OAuth userinfo endpoint, which is NOT geo-restricted — so "Test Connection" @@ -79,6 +80,7 @@ export interface OAuthTestConfigEntry { extraHeaders?: Record; body?: string; acceptStatuses?: number[]; + inconclusiveStatuses?: number[]; checkExpiry?: boolean; refreshable?: boolean; getUrl?: (connection: any) => string; @@ -88,6 +90,39 @@ export interface OAuthTestConfigEntry { ) => OAuthTestProbeRequest | Promise; } +export interface OAuthProbeInconclusiveClassification { + warning: string; + diagnosisType: "ok"; + diagnosisCode: "probe_inconclusive"; +} + +export function classifyOAuthProbeInconclusive( + config: OAuthTestConfigEntry, + provider: string, + status: number, + bodyText: string +): OAuthProbeInconclusiveClassification | null { + if ( + !Array.isArray(config.inconclusiveStatuses) || + !config.inconclusiveStatuses.includes(status) + ) { + return null; + } + + // Preserve the current upstream geo-block contract. Google's explicit + // location refusal is an egress/upstream availability failure, not a + // successful connection-test result. + if ((provider === "antigravity" || provider === "agy") && isGeoBlockedError(bodyText)) { + return null; + } + + return { + warning: `${provider} probe returned HTTP ${status}; credential validity is inconclusive`, + diagnosisType: "ok", + diagnosisCode: "probe_inconclusive", + }; +} + export const OAUTH_TEST_CONFIG: Record = { claude: { // Claude doesn't have userinfo, we verify token exists and not expired @@ -126,6 +161,7 @@ export const OAUTH_TEST_CONFIG: Record = { // Real model-surface probe (see buildAntigravityProbe above): userinfo-only // probing stayed green while the model API was geo-blocked. buildProbe: buildAntigravityProbe, + inconclusiveStatuses: [400], refreshable: true, }, // `agy` is a separate connection id that shares the Antigravity backend and the same @@ -135,6 +171,7 @@ export const OAUTH_TEST_CONFIG: Record = { // perfectly good account. Probe the same model surface as antigravity. agy: { buildProbe: buildAntigravityProbe, + inconclusiveStatuses: [400], refreshable: true, }, xai: XAI_CHAT_OAUTH_TEST_CONFIG, diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index f8afbb6cb0..7ac785c7f5 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -32,7 +32,7 @@ import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotat import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult"; -import { OAUTH_TEST_CONFIG } from "./oauthTestConfig"; +import { classifyOAuthProbeInconclusive, OAUTH_TEST_CONFIG } from "./oauthTestConfig"; import { isGeoBlockedError } from "@omniroute/open-sse/services/errorClassifier.ts"; // Bound the OAuth probe so a hung upstream can't block the connection-test queue @@ -153,6 +153,7 @@ export function classifyFailure({ normalized.includes("fetch failed") || normalized.includes("network") || normalized.includes("timeout") || + normalized.includes("timed out") || normalized.includes("econn") || normalized.includes("enotfound") || normalized.includes("socket") @@ -510,6 +511,38 @@ export async function testOAuthConnection( if (builtProbe?.body) fetchInit.body = builtProbe.body; const res = await fetch(url, fetchInit); + const inconclusiveBody = + Array.isArray(config.inconclusiveStatuses) && config.inconclusiveStatuses.includes(res.status) + ? await res + .clone() + .text() + .catch(() => "") + : ""; + + const inconclusive = classifyOAuthProbeInconclusive( + config, + connection.provider, + res.status, + inconclusiveBody + ); + + if (inconclusive) { + return { + valid: true, + error: null, + warning: inconclusive.warning, + refreshed, + newTokens, + statusCode: res.status, + diagnosis: makeDiagnosis( + inconclusive.diagnosisType, + "upstream", + inconclusive.warning, + inconclusive.diagnosisCode + ), + }; + } + // Port of decolua/9router#347: some providers (Codex) intentionally trigger a // 400 because the probe body is invalid. A 400 from such a provider means auth // succeeded; only 401/403 means the token is bad. @@ -572,6 +605,39 @@ export async function testOAuthConnection( else if (config.body) retryInit.body = config.body; const retryRes = await fetch(url, retryInit); + const retryInconclusiveBody = + Array.isArray(config.inconclusiveStatuses) && + config.inconclusiveStatuses.includes(retryRes.status) + ? await retryRes + .clone() + .text() + .catch(() => "") + : ""; + + const retryInconclusive = classifyOAuthProbeInconclusive( + config, + connection.provider, + retryRes.status, + retryInconclusiveBody + ); + + if (retryInconclusive) { + return { + valid: true, + error: null, + warning: retryInconclusive.warning, + refreshed: true, + newTokens: tokens, + statusCode: retryRes.status, + diagnosis: makeDiagnosis( + retryInconclusive.diagnosisType, + "upstream", + retryInconclusive.warning, + retryInconclusive.diagnosisCode + ), + }; + } + const retryAccepted = retryRes.ok || (Array.isArray(config.acceptStatuses) && config.acceptStatuses.includes(retryRes.status)); @@ -703,6 +769,7 @@ async function testApiKeyConnection(connection: any) { const error = "Provider test not supported"; return { valid: false, + skipped: true, error, diagnosis: classifyFailure({ error, unsupported: true, provider: connection.provider }), }; @@ -795,6 +862,18 @@ export async function testSingleConnection(connectionId: string, validationModel const latencyMs = Date.now() - startTime; + // Unsupported validation capability is neutral: the probe established that + // this provider cannot be verified through the generic test surface, not + // that its credential is invalid. Do not mutate persisted credential health. + if (result.skipped === true) { + return { + ...result, + latencyMs, + runtime: runtime || null, + testedAt: null, + }; + } + // Build update data const now = new Date().toISOString(); const diagnosis = diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 6941d126d0..f1825674c2 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -17,6 +17,7 @@ import { isClaudeCodeCompatibleProvider, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, + resolveProviderId, } from "@/shared/constants/providers"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; @@ -26,6 +27,7 @@ import { } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { normalizeQoderPatProviderData } from "@omniroute/open-sse/services/qoderCli"; +import { projectCodexAccountPool } from "@omniroute/open-sse/services/codexAccount/index.ts"; import { normalizeProviderSpecificData, sanitizeProviderSpecificDataForResponse, @@ -64,16 +66,31 @@ export async function GET(request: Request) { const revealKeys = isApiKeyRevealEnabled(); // Hide or mask sensitive fields - const safeConnections = connections.map((c) => ({ - ...c, - apiKey: revealKeys ? c.apiKey : c.apiKey ? maskStoredApiKey(c.apiKey) : undefined, - accessToken: undefined, - refreshToken: undefined, - idToken: undefined, - providerSpecificData: c.providerSpecificData + const safeConnections = connections.map((c) => { + const providerSpecificData = c.providerSpecificData ? sanitizeProviderSpecificDataForResponse(c.providerSpecificData) - : undefined, - })); + : undefined; + return { + ...c, + apiKey: revealKeys ? c.apiKey : c.apiKey ? maskStoredApiKey(c.apiKey) : undefined, + accessToken: undefined, + refreshToken: undefined, + idToken: undefined, + providerSpecificData, + ...(c.provider === "codex" + ? { + codexAccountPool: projectCodexAccountPool( + { + id: c.id, + provider: c.provider, + providerSpecificData: c.providerSpecificData ?? {}, + }, + Date.now() + ), + } + : {}), + }; + }); return NextResponse.json({ connections: safeConnections, total }); } catch (error) { @@ -98,7 +115,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: validation.error }, { status: 400 }); } const { - provider, + provider: requestedProvider, apiKey, name, priority, @@ -107,6 +124,7 @@ export async function POST(request: Request) { testStatus, providerSpecificData: incomingPsd, } = validation.data; + const provider = resolveProviderId(requestedProvider); // Business validation const isValidProvider = diff --git a/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts b/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts index 2cfb6ddefa..2e13845746 100644 --- a/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts +++ b/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts @@ -6,6 +6,7 @@ import { createProxyDispatcher, proxyConfigToUrl, } from "@omniroute/open-sse/utils/proxyDispatcher.ts"; +import { probeEchoTargets } from "@/lib/proxyEchoTarget"; type ConnectivityTester = ( host: string, @@ -23,31 +24,37 @@ async function testProxyConnectivity( const dispatcher = createProxyDispatcher(proxyUrl); const start = Date.now(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); try { - const res = await undiciRequest("https://api64.ipify.org?format=json", { - method: "GET", - dispatcher, - signal: controller.signal, - headersTimeout: 5000, - bodyTimeout: 5000, - }); - const text = await res.body.text(); + // #9694: try the IPv6-first echo target, then the IPv4-only one, so a proxy + // with no IPv6 route is not reported dead. + const { result } = await probeEchoTargets(async (url, timeoutMs) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await undiciRequest(url, { + method: "GET", + dispatcher, + signal: controller.signal, + headersTimeout: timeoutMs, + bodyTimeout: timeoutMs, + }); + return { statusCode: res.statusCode, text: await res.body.text() }; + } finally { + clearTimeout(timeout); + } + }, 5000); let parsed: { ip?: string } = {}; try { - parsed = JSON.parse(text) as { ip?: string }; + parsed = JSON.parse(result.text) as { ip?: string }; } catch {} return { - success: res.statusCode === 200, + success: result.statusCode === 200, latencyMs: Date.now() - start, publicIp: parsed.ip, }; } catch { return { success: false, latencyMs: Date.now() - start }; - } finally { - clearTimeout(timeout); } } diff --git a/src/app/api/settings/free-proxies/bulk-add-to-pool/route.ts b/src/app/api/settings/free-proxies/bulk-add-to-pool/route.ts index 20d9f2c53c..c7d2108d5e 100644 --- a/src/app/api/settings/free-proxies/bulk-add-to-pool/route.ts +++ b/src/app/api/settings/free-proxies/bulk-add-to-pool/route.ts @@ -8,6 +8,7 @@ import { createProxyDispatcher, proxyConfigToUrl, } from "@omniroute/open-sse/utils/proxyDispatcher.ts"; +import { probeEchoTargets } from "@/lib/proxyEchoTarget"; type QuickTester = ( host: string, @@ -24,22 +25,29 @@ async function testProxyQuick( if (!proxyUrl) return { ok: false, latencyMs: 0 }; const dispatcher = createProxyDispatcher(proxyUrl); const start = Date.now(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); try { - const res = await undiciRequest("https://api64.ipify.org?format=json", { - method: "GET", - dispatcher, - signal: controller.signal, - headersTimeout: 5000, - bodyTimeout: 5000, - }); - await res.body.dump(); - return { ok: res.statusCode === 200, latencyMs: Date.now() - start }; + // #9694: try the IPv6-first echo target, then the IPv4-only one, so a proxy + // with no IPv6 route is not reported dead. + const { result } = await probeEchoTargets(async (url, timeoutMs) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await undiciRequest(url, { + method: "GET", + dispatcher, + signal: controller.signal, + headersTimeout: timeoutMs, + bodyTimeout: timeoutMs, + }); + await res.body.dump(); + return res.statusCode; + } finally { + clearTimeout(timeout); + } + }, 5000); + return { ok: result === 200, latencyMs: Date.now() - start }; } catch { return { ok: false, latencyMs: Date.now() - start }; - } finally { - clearTimeout(timeout); } } diff --git a/src/app/api/settings/proxies/auto-test/route.ts b/src/app/api/settings/proxies/auto-test/route.ts index 9023f4f909..4d7b4f3a4e 100644 --- a/src/app/api/settings/proxies/auto-test/route.ts +++ b/src/app/api/settings/proxies/auto-test/route.ts @@ -4,15 +4,23 @@ import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { createProxyDispatcher, proxyConfigToUrl } from "@omniroute/open-sse/utils/proxyDispatcher"; import { fetch as undiciFetch } from "undici"; +import { classifyProbeStatus } from "@/lib/proxyHealth/decision"; import { resolveHealthCheckStatusWrite } from "@/lib/proxyHealth/statusPolicy"; +import { + resolveProbeConcurrency, + resolveProbeStaggerMs, + resolveProbeTarget, + waitForProbeSlot, +} from "@/lib/proxyHealth/probeTarget"; +import { resolveProviderProbeTarget } from "@/lib/proxyHealth/providerProbeTarget"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { createErrorResponse } from "@/lib/api/errorResponse"; const TEST_TIMEOUT_MS = 5000; -// Reachability probe target. Configurable so operators can point it at an -// internal/self-hosted endpoint instead of the public default. -const TEST_URL = process.env.PROXY_HEALTH_TEST_URL || "https://httpbin.org/ip"; -const CONCURRENCY = 10; +// Shared with the background sweep — see src/lib/proxyHealth/probeTarget.ts. +const TEST_URL = resolveProbeTarget(); +const CONCURRENCY = resolveProbeConcurrency(); +const STAGGER_MS = resolveProbeStaggerMs(); const autoTestSchema = z.object({ ids: z.array(z.string()).optional(), @@ -24,6 +32,13 @@ interface TestResult { host: string; port: number; alive: boolean; + /** + * The proxy relayed, but the target refused this egress IP (401/403/429). + * Reported alongside `alive` rather than inside it: a refused IP is still a + * reachable proxy, so folding it into `alive` would change what the opt-in + * status write (`PROXY_HEALTH_AUTO_DEACTIVATE`) deactivates. + */ + blockedByTarget?: boolean; latencyMs: number | null; error?: string; } @@ -54,25 +69,41 @@ async function testSingleProxy(proxy: { }; } const start = Date.now(); + // Same rationale as the background sweep: a real provider's models endpoint is GET-only, + // unlike httpbin.org/ip. The generic target keeps its existing HEAD. + const providerTarget = await resolveProviderProbeTarget(proxy.id); + const target = providerTarget ?? TEST_URL; + const method = providerTarget ? "GET" : "HEAD"; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS); try { const dispatcher = createProxyDispatcher(proxyUrl); - const resp = await undiciFetch(TEST_URL, { - method: "HEAD", + const resp = await undiciFetch(target, { + method, signal: controller.signal, dispatcher, headers: { "User-Agent": "OmniRoute/1.0" }, }); const latencyMs = Date.now() - start; - const alive = resp.status < 500; + const outcome = classifyProbeStatus(resp.status); + // Same shared classifier the sweep uses. `alive` keeps its exact prior meaning + // (any status under 500): "blocked" covers 401/403/429, which were — and stay — + // alive here, so no proxy changes state because of this field. + const alive = outcome === "ok" || outcome === "blocked"; // #6246: "Test All" is a test, not test-and-set. By default an automated probe // never mutates a proxy's status (only the operator does). Opt back into the // legacy write with PROXY_HEALTH_AUTO_DEACTIVATE=true. const statusWrite = resolveHealthCheckStatusWrite(alive); if (statusWrite) await updateProxy(proxy.id, { status: statusWrite }).catch(() => {}); - return { proxyId: proxy.id, host: proxy.host, port: proxy.port, alive, latencyMs }; + return { + proxyId: proxy.id, + host: proxy.host, + port: proxy.port, + alive, + ...(outcome === "blocked" ? { blockedByTarget: true } : {}), + latencyMs, + }; } catch (err) { const latencyMs = Date.now() - start; const statusWrite = resolveHealthCheckStatusWrite(false); @@ -130,7 +161,14 @@ export async function POST(request: Request) { const results: TestResult[] = []; for (let i = 0; i < proxiesToTest.length; i += CONCURRENCY) { const batch = proxiesToTest.slice(i, i + CONCURRENCY); - const batchResults = await Promise.allSettled(batch.map((proxy) => testSingleProxy(proxy))); + const batchResults = await Promise.allSettled( + batch.map(async (proxy, indexInBatch) => { + // Same intra-batch spacing as the background sweep: "Test All" fires the whole + // batch at once too, so it is just as capable of tripping a rate-limited target. + await waitForProbeSlot(indexInBatch, STAGGER_MS); + return testSingleProxy(proxy); + }) + ); for (const result of batchResults) { if (result.status === "fulfilled") results.push(result.value); } diff --git a/src/app/api/settings/proxies/egress/route.ts b/src/app/api/settings/proxies/egress/route.ts index b43fcd6e6f..dbad250361 100644 --- a/src/app/api/settings/proxies/egress/route.ts +++ b/src/app/api/settings/proxies/egress/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; -import { diagnoseAllEgressIps, validateProxyPool } from "@/lib/proxyEgress"; +import { + diagnoseAllEgressIps, + getRecentEgressSharingSummary, + validateProxyPool, +} from "@/lib/proxyEgress"; /** * GET /api/settings/proxies/egress — diagnose the egress IP of every OAuth @@ -17,8 +21,11 @@ export async function GET(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; try { - const diagnostic = await diagnoseAllEgressIps(); - return NextResponse.json(diagnostic); + const [diagnostic, { summary }] = await Promise.all([ + diagnoseAllEgressIps(), + getRecentEgressSharingSummary(), + ]); + return NextResponse.json({ ...diagnostic, summary }); } catch (error) { return createErrorResponseFromUnknown(error, "Failed to diagnose egress IPs"); } diff --git a/src/app/api/settings/proxy/test/route.ts b/src/app/api/settings/proxy/test/route.ts index c1eaf085b9..c2ceef616b 100644 --- a/src/app/api/settings/proxy/test/route.ts +++ b/src/app/api/settings/proxy/test/route.ts @@ -6,6 +6,7 @@ import { proxyConfigToUrl, proxyUrlForLogs, } from "@omniroute/open-sse/utils/proxyDispatcher.ts"; +import { probeEchoTargets } from "@/lib/proxyEchoTarget"; import { testProxySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; @@ -215,20 +216,28 @@ export async function POST(request: Request) { const publicProxyUrl = proxyUrlForLogs(proxyUrl); const startTime = Date.now(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); const dispatcher = createProxyDispatcher(proxyUrl); try { - const result = await undiciRequest("https://api64.ipify.org?format=json", { - method: "GET", - dispatcher, - signal: controller.signal, - headersTimeout: 10000, - bodyTimeout: 10000, - }); - - const responseText = await result.body.text(); + // #9694: an IPv4-only SOCKS5/SSH tunnel has no route to the IPv6-first + // echo target and used to hang here until the deadline, reporting a + // healthy proxy as dead. Each target gets its own slice of the budget. + const { result: responseText } = await probeEchoTargets(async (url, timeoutMs) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const result = await undiciRequest(url, { + method: "GET", + dispatcher, + signal: controller.signal, + headersTimeout: timeoutMs, + bodyTimeout: timeoutMs, + }); + return await result.body.text(); + } finally { + clearTimeout(timeout); + } + }, 10000); let parsed: { ip?: string }; try { const parsedJson = JSON.parse(responseText); @@ -260,8 +269,6 @@ export async function POST(request: Request) { latencyMs: Date.now() - startTime, proxyUrl: publicProxyUrl, }); - } finally { - clearTimeout(timeout); } } catch (error) { return createErrorResponseFromUnknown(error, "Unexpected server error"); diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 2fa930dd7d..3c662c0f78 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -35,6 +35,7 @@ import { AUTHZ_HEADER_AUTH_KIND, AUTHZ_HEADER_PEER_LOCALITY, } from "@/server/authz/headers"; +import { readSubjectFromHeaders } from "@/server/authz/assertAuth"; /** * Force this route to run dynamically per-request and never be cached/prerendered. @@ -133,6 +134,8 @@ async function deriveAuditActor(request: Request): Promise { } catch { /* fall through */ } + const subject = readSubjectFromHeaders(request.headers); + if (subject.kind === "management_key" && subject.label === "local-cli-token") return "cli"; try { if (await isCliTokenAuthValid(request)) return "cli"; } catch { diff --git a/src/app/api/settings/task-routing/route.ts b/src/app/api/settings/task-routing/route.ts index a979ff040b..0b06c033f6 100644 --- a/src/app/api/settings/task-routing/route.ts +++ b/src/app/api/settings/task-routing/route.ts @@ -4,6 +4,7 @@ import { setTaskRoutingConfig, resetTaskRoutingStats, getDefaultTaskModelMap, + getDefaultTaskPatterns, } from "@omniroute/open-sse/services/taskAwareRouter.ts"; import { updateSettings } from "@/lib/db/settings"; import { taskRoutingActionSchema, updateTaskRoutingSchema } from "@/shared/validation/schemas"; @@ -21,6 +22,7 @@ export async function GET(request: Request) { return NextResponse.json({ ...getTaskRoutingConfig(), defaultTaskModelMap: getDefaultTaskModelMap(), + defaultTaskPatterns: getDefaultTaskPatterns(), }); } catch (error) { console.error("[API ERROR] /api/settings/task-routing GET:", error); @@ -31,7 +33,8 @@ export async function GET(request: Request) { /** * PUT /api/settings/task-routing * Update the task-aware routing configuration. - * Body: { enabled?: boolean, taskModelMap?: { coding?: "...", ... }, detectionEnabled?: boolean } + * Body: { enabled?: boolean, taskModelMap?: { coding?: "...", ... }, detectionEnabled?: boolean, + * patternOverrides?: { coding?: { patterns?: string[], userPatterns?: string[] }, ... } } */ export async function PUT(request: Request) { const authError = await requireManagementAuth(request); diff --git a/src/app/api/tools/agent-bridge/cert/regenerate/route.ts b/src/app/api/tools/agent-bridge/cert/regenerate/route.ts index 2459275e71..c6b66eba1b 100644 --- a/src/app/api/tools/agent-bridge/cert/regenerate/route.ts +++ b/src/app/api/tools/agent-bridge/cert/regenerate/route.ts @@ -9,11 +9,10 @@ import { createErrorResponse } from "@/lib/api/errorResponse"; export async function POST(): Promise { try { - // generateCert checks for existing files — force-regenerate by deleting first - // is not in scope; the function is idempotent (returns existing paths). If a - // caller needs a fresh cert they must delete the old one manually. We expose - // whatever generateCert decides. - const result = await generateCert(); + // #10467: generateCert() returns the existing paths untouched when a cert is already + // on disk, which made this endpoint a no-op — the download still served the old file. + // This route is the one caller that must always mint a fresh cert. + const result = await generateCert({ force: true }); return Response.json({ ok: true, certPath: result.cert, keyPath: result.key }); } catch (err) { const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index d90480964e..306292865a 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -21,7 +21,7 @@ import { getWeeklyPatternRows, getPresetCostModelRows, } from "@/lib/db/usageAnalytics"; -import { getFallbackStats } from "@/lib/db/callLogStats"; +import { getFallbackStats, getErrorTypeBreakdown } from "@/lib/db/callLogStats"; import { buildByProviderRows } from "@/lib/usage/providerDisplayNames"; import { toNumber } from "@/shared/utils/numeric"; @@ -481,6 +481,7 @@ export async function GET(request: Request) { const weeklyRows = getWeeklyPatternRows(unifiedSource, unifiedParams) as UsageRows; const fallbackRow = getFallbackStats(whereClause, params) as Record; + const errorBreakdown = getErrorTypeBreakdown(whereClause, params); const summary = { totalRequests: Number(summaryRow?.totalRequests || 0), @@ -869,6 +870,7 @@ export async function GET(request: Request) { weeklyCounts, dailyByModel, modelNames, + errorBreakdown, range, } as any; diff --git a/src/app/api/usage/call-logs/route.ts b/src/app/api/usage/call-logs/route.ts index 3b3b8ddcc0..0a737ea1f9 100644 --- a/src/app/api/usage/call-logs/route.ts +++ b/src/app/api/usage/call-logs/route.ts @@ -143,6 +143,9 @@ export async function GET(request: Request) { if (searchParams.get("correlationId")) filter.correlationId = searchParams.get("correlationId"); if (searchParams.get("limit")) filter.limit = parseInt(searchParams.get("limit")); if (searchParams.get("offset")) filter.offset = parseInt(searchParams.get("offset")); + // Home Recent Requests feed sets excludeTests=1 so connection-test probe rows + // are dropped at the SQL layer (before LIMIT), not client-side after slicing. + if (searchParams.get("excludeTests") === "1") filter.excludeTests = true; const [logs, connections, providerNodes] = await Promise.all([ getCallLogs(filter), diff --git a/src/app/api/usage/combo-trace/[id]/route.ts b/src/app/api/usage/combo-trace/[id]/route.ts new file mode 100644 index 0000000000..0615daf309 --- /dev/null +++ b/src/app/api/usage/combo-trace/[id]/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getComboTrace } from "@omniroute/open-sse/services/combo/decisionTrace.ts"; + +/** + * #10681: read the ordered per-target decision trace for one combo invocation. + * Safe by construction: the trace holds routing metadata only (provider/model, + * decision, allowlisted skip reason, terminal status) — never prompts, request + * or response bodies, headers, credentials, account ids, or raw upstream + * errors. Retention is bounded in-memory (30min TTL, 2000 invocations). + */ +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const { id } = await params; + if (!id || !id.startsWith("combo-")) { + return NextResponse.json({ error: "Invalid invocation id" }, { status: 400 }); + } + const trace = getComboTrace(id); + if (!trace) { + return NextResponse.json({ error: "Combo trace not found or expired" }, { status: 404 }); + } + return NextResponse.json(trace); +} diff --git a/src/app/api/v1/antigravity/route.ts b/src/app/api/v1/antigravity/route.ts index 89f12036ae..aba16415ee 100644 --- a/src/app/api/v1/antigravity/route.ts +++ b/src/app/api/v1/antigravity/route.ts @@ -1,5 +1,6 @@ import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; let initialized = false; @@ -41,7 +42,9 @@ export async function OPTIONS() { * already-registered bidirectional translators. The AgentBridge MITM proxy * (`server.cjs`) forwards the IDE's intercepted cloudcode request here. */ -export async function POST(request: Request): Promise { +async function postHandler(request: Request): Promise { await ensureInitialized(); return await handleChat(request); } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/api/chat/route.ts b/src/app/api/v1/api/chat/route.ts index c531911121..d07a7509a0 100644 --- a/src/app/api/v1/api/chat/route.ts +++ b/src/app/api/v1/api/chat/route.ts @@ -1,6 +1,7 @@ import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { transformToOllama } from "@omniroute/open-sse/utils/ollamaTransform.ts"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; let initialized = false; @@ -21,7 +22,7 @@ export async function OPTIONS() { }); } -export async function POST(request) { +async function postHandler(request) { await ensureInitialized(); const clonedReq = request.clone(); @@ -34,3 +35,5 @@ export async function POST(request) { const response = await handleChat(request); return transformToOllama(response, modelName); } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/completions/route.ts b/src/app/api/v1/completions/route.ts index f11f7cb904..c2106271f1 100644 --- a/src/app/api/v1/completions/route.ts +++ b/src/app/api/v1/completions/route.ts @@ -7,6 +7,7 @@ import { readCompressionRequestHeader, withCompressionHeaderEcho, } from "@/shared/utils/compressionHeaderEcho"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; let initPromise = null; const injectionGuard = createInjectionGuard(); @@ -41,7 +42,7 @@ export async function OPTIONS() { * * @see https://platform.openai.com/docs/api-reference/completions */ -export async function POST(request: Request) { +async function postHandler(request: Request) { await ensureInitialized(); // #6422 — capture the compression request header once so we can echo it back @@ -122,3 +123,5 @@ export async function POST(request: Request) { compressionRequestHeader ); } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/explain/routing/route.ts b/src/app/api/v1/explain/routing/route.ts new file mode 100644 index 0000000000..313bcf27b5 --- /dev/null +++ b/src/app/api/v1/explain/routing/route.ts @@ -0,0 +1,72 @@ +/** + * GET /v1/explain/routing — routing explainability + feedback state. + * + * Returns the most recent routing events (bounded in-memory ring buffer) and + * the per-provider/model quality snapshot produced by the feedback foundation + * (open-sse/services/routing). This is REAL decision data — the events were + * emitted by the request hot path, not recomputed after the fact. + * + * Safety: only routing metadata (provider/model/strategy/timing/tokens/outcome/ + * status/finish_reason). Never prompts, bodies, headers, credentials, accounts. + * + * Auth mirrors /v1/combos: valid Bearer API key or dashboard session. With + * REQUIRE_API_KEY=false (single-user local deployments) anonymous read is + * allowed, matching /v1/models behavior. + */ +import { NextResponse } from "next/server"; +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; +import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; +import { + recentRoutingEvents, + routingQualitySnapshot, + routingOtelStats, + initRoutingObservability, + classifyQuality, +} from "@omniroute/open-sse/services/routing/index.ts"; + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +export async function GET(request: Request) { + const apiKeyRaw = extractApiKey(request); + const apiKeyOk = apiKeyRaw ? await isValidApiKey(apiKeyRaw) : false; + const dashboardOk = !apiKeyOk ? await isDashboardSessionAuthenticated(request) : false; + + if (!apiKeyOk && !dashboardOk && isRequireApiKeyEnabled()) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Authentication required"); + } + + try { + const limit = Math.min( + 500, + Math.max(1, Number(new URL(request.url).searchParams.get("limit")) || 50) + ); + const { sinks, otelEnabled } = initRoutingObservability(); + const quality = routingQualitySnapshot(limit).map((q) => ({ + ...q, + classification: classifyQuality(q), + })); + return NextResponse.json( + { + object: "routing_explain", + sinks, + otelEnabled, + events: recentRoutingEvents(limit), + quality, + otel: routingOtelStats(), + }, + { headers: { "Cache-Control": "no-store" } } + ); + } catch { + return errorResponse(HTTP_STATUS.SERVER_ERROR, "Failed to build routing explain payload"); + } +} diff --git a/src/app/api/v1/files/route.ts b/src/app/api/v1/files/route.ts index 97925dd3b3..2b9d02e49a 100644 --- a/src/app/api/v1/files/route.ts +++ b/src/app/api/v1/files/route.ts @@ -7,6 +7,61 @@ export async function OPTIONS() { return handleCorsOptions(); } +const DEFAULT_LIST_LIMIT = 20; +const MAX_LIST_LIMIT = 10000; + +export function parseFilesListQuery(searchParams: URLSearchParams): + | { + ok: true; + limit: number; + after: string | undefined; + order: "asc" | "desc"; + purpose: string | undefined; + } + | { ok: false; response: Response } { + const rawLimit = searchParams.get("limit"); + let limit = DEFAULT_LIST_LIMIT; + + if (rawLimit !== null) { + if (!/^\d+$/.test(rawLimit)) { + return { + ok: false, + response: NextResponse.json( + { error: { message: "limit must be a positive integer", type: "invalid_request_error" } }, + { status: 400, headers: CORS_HEADERS } + ), + }; + } + + limit = Number.parseInt(rawLimit, 10); + if (limit < 1 || limit > MAX_LIST_LIMIT) { + return { + ok: false, + response: NextResponse.json( + { + error: { + message: `limit must be between 1 and ${MAX_LIST_LIMIT}`, + type: "invalid_request_error", + }, + }, + { status: 400, headers: CORS_HEADERS } + ), + }; + } + } + + const orderParam = searchParams.get("order"); + const order = orderParam === "asc" ? "asc" : "desc"; + + return { + ok: true, + limit, + after: searchParams.get("after") || undefined, + order, + purpose: searchParams.get("purpose") || undefined, + }; +} + export async function POST(request: Request) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; @@ -78,10 +133,9 @@ export async function GET(request: Request) { const apiKeyId = scope.apiKeyId; const { searchParams } = new URL(request.url); - const limit = Math.min(Number.parseInt(searchParams.get("limit") || "20") || 20, 10000); - const after = searchParams.get("after") || undefined; - const order = (searchParams.get("order") as "asc" | "desc") || "desc"; - const purpose = searchParams.get("purpose") || undefined; + const parsed = parseFilesListQuery(searchParams); + if (!parsed.ok) return parsed.response; + const { limit, after, order, purpose } = parsed; // We fetch limit + 1 to check if there are more items const files = listFiles({ diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 9c8871d6bf..282c83a119 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -3,6 +3,7 @@ import { handleCodexImageEdit, handleImageEdit, handleOpenAIImageEdit, + handleOpenRouterImageEdit, } from "@omniroute/open-sse/handlers/imageGeneration.ts"; import { handleFalAIImageEdit, @@ -585,6 +586,55 @@ async function postHandler(request: Request, _context?: unknown) { }); } + // Built-in OpenRouter uses its unified Image API for reference-image + // edits: POST /api/v1/images with input_references. Forward through the + // provider-specific adapter (#10197), rather than the multipart + // /images/edits path used by custom OpenAI-compatible nodes. + if (providerConfig?.id === "openrouter") { + const credentials = await getProviderCredentialsWithQuotaPreflight( + parsed.provider, + null, + allowedConnections, + resolvedModel + ); + if (!credentials) { + return errorResponse( + HTTP_STATUS.UNAUTHORIZED, + `No credentials for provider: ${parsed.provider}` + ); + } + if (credentials.allRateLimited) { + return unavailableResponse( + HTTP_STATUS.RATE_LIMITED, + `[${parsed.provider}] All accounts rate limited`, + credentials.retryAfter, + credentials.retryAfterHuman + ); + } + + const result = await handleOpenRouterImageEdit({ + provider: parsed.provider, + model: parsed.model, + baseUrl: providerConfig.baseUrl, + credentials, + prompt, + imageBytes, + imageMime, + size: size ?? undefined, + n: 1, + log, + }); + + if (result.success) { + await clearRecoveredProviderState(credentials); + return jsonResponse(result.data); + } + return jsonResponse( + toJsonErrorPayload(result.error, "Image edit provider error"), + result.status + ); + } + // Other built-in providers do not expose an OpenAI-compatible edit endpoint. if (providerConfig) { return errorResponse( diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 41916a55e6..aa228a4f75 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -31,6 +31,7 @@ import { getSpecialtyModelsResponse } from "@/app/api/v1/_shared/specialtyCatalo import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth"; import { runWithCallLogApiKeyContext } from "@/lib/usage/callLogApiKeyContext"; import { executeImageWithCredentialFallback } from "@/sse/services/imageCredentialRetry"; +import { AUTHZ_HEADER_PEER_LOCALITY } from "@/server/authz/headers"; export const dynamic = "force-dynamic"; @@ -290,6 +291,12 @@ async function postHandler(request, context) { ...(isCustomModel && { resolvedProvider: provider }), signal: request.signal, clientHeaders: publicBaseUrlHeaders(request.headers), + // Trusted "loopback"|"lan"|"remote" verdict stamped by the authz + // pipeline from the real TCP peer (never the spoofable Host + // header). Only the spawn-capable cursor-agent-image provider + // consumes this (Hard Rules #15 + #17) — every other image + // provider ignores it. + peerLocality: request.headers.get(AUTHZ_HEADER_PEER_LOCALITY), }) ); diff --git a/src/app/api/v1/messages/route.ts b/src/app/api/v1/messages/route.ts index cbbc87f202..97f6af1fb7 100644 --- a/src/app/api/v1/messages/route.ts +++ b/src/app/api/v1/messages/route.ts @@ -1,6 +1,7 @@ import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; import { requireJsonContentType } from "@/shared/middleware/requireJsonContentType"; import { withEarlyStreamKeepalive, @@ -78,4 +79,4 @@ async function postHandler(request: any, context: any, preParsedBody: any = null return await handleChat(request, null, body); } -export const POST = withInjectionGuard(postHandler); +export const POST = withChatAdmission(withInjectionGuard(postHandler)); diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index e086cf1870..c56a543d56 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -40,7 +40,11 @@ import { prepareBuiltinAutoComboInputs, isPaidTierAutoId, } from "@omniroute/open-sse/services/autoCombo/builtinCatalog"; -import type { SyncedAvailableModel } from "@/lib/db/models"; +import { + getSyncedAvailableModelsByConnection, + SYNCED_AVAILABLE_MODELS_MALFORMED, + type SyncedAvailableModel, +} from "@/lib/db/models"; import { getAllActiveSyncedModels } from "@/lib/db/models/activeSyncedCatalog"; import { getModelCatalogCacheVersion } from "@/lib/db/readCache"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; @@ -58,9 +62,11 @@ import { getCatalogDiagnosticsHeaders, type CatalogEnrichmentSnapshot, } from "@/lib/modelMetadataRegistry"; +import { createModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot"; import { getModelsDevPricing, getSyncedCapability } from "@/lib/modelsDevSync"; import { getModelSpec } from "@/shared/constants/modelSpecs"; import { getModelsCatalogPrefixMode } from "@/shared/utils/featureFlags"; +import { buildReservedPrefixes, selectCompatibleNodeForPrefix } from "@/lib/providerNodePrefixes"; import { applyCatalogPostFilters, finalizeCatalogResponse } from "./catalogResponse"; import { isNoAuthProviderBlocked, @@ -68,7 +74,7 @@ import { isNoAuthRawProviderPrefix, normalizeBlockedProviderSet, } from "@/shared/utils/noAuthProviders"; -import { getTokenLimit } from "@omniroute/open-sse/services/contextManager"; +import { getSourcedTokenLimit, getTokenLimit } from "@omniroute/open-sse/services/contextManager"; import { extractApiKey } from "@/sse/services/auth"; import type { ComboModelStep } from "@/lib/combos/steps"; import { @@ -82,6 +88,8 @@ import { maybeOmitCatalogModelName, getThinkingCapabilityFields, mergeComboCapabilities, + getConnectionScopedEffortTiers, + type ConnectionScopedReasoningCatalog, } from "./catalogHelpers"; import { qualifyOpenRouterModelId, @@ -181,7 +189,11 @@ export async function getUnifiedModelsResponse( { corsHeaders, diagnosticHeaders }, buildCatalogPayload, { - hideAutoCombos: settingsForAuth?.hideAutoCombos === true, + // #10831: a disabled router hides auto/* just as hideAutoCombos does, so + // the two collapse into one cache dimension — the resulting catalogs are + // identical and do not need separate entries. + hideAutoCombos: + settingsForAuth?.hideAutoCombos === true || settingsForAuth?.autoRoutingEnabled === false, hideNoThinkVariants: settingsForAuth?.hideNoThinkVariants === true, } ); @@ -271,6 +283,7 @@ async function buildUnifiedModelsResponseCore( // #9147: yield after auth check before DB initialization prologue await yieldCatalogBuildTurn(); + const capabilityResolutionSnapshot = createModelCapabilityResolutionSnapshot(); const { aliasToProviderId, providerIdToAlias } = buildAliasMaps(); const _qp = new URL(request.url).searchParams.get("prefix"); const prefixMode = @@ -292,7 +305,11 @@ async function buildUnifiedModelsResponseCore( // #9418: Opt-in filter — skip the entire auto/* synthesis loop when the operator // does not want built-in virtual combos advertised in the catalog. User-defined // combos are unaffected; routing still works for ids sent explicitly. - const hideAuto = settings.hideAutoCombos === true; + // #10831: also drop them when auto routing is switched off. Unlike + // hideAutoCombos — which only unadvertises ids that still route when sent + // explicitly — a disabled router rejects every auto/* id with a 400, so + // listing them offers the client a choice that cannot succeed. + const hideAuto = settings.hideAutoCombos === true || settings.autoRoutingEnabled === false; const shouldHidePaid = (providerKey: string, modelId: string, pricing?: unknown): boolean => { if (!hidePaid) return false; const provider = aliasToProviderId[providerKey] || providerKey; @@ -323,6 +340,7 @@ async function buildUnifiedModelsResponseCore( // Build map of provider node ID to prefix and type for compatible providers const providerIdToPrefix: Record = {}; + const providerNodeIdByPrefix: Record = {}; const nodeIdToProviderType: Record = {}; for (const node of providerNodes) { const resolvedPrefix = @@ -340,6 +358,12 @@ async function buildUnifiedModelsResponseCore( nodeIdToProviderType[node.id] = node.type; } } + const reservedProviderPrefixes = buildReservedPrefixes(); + for (const prefix of new Set(Object.values(providerIdToPrefix))) { + if (reservedProviderPrefixes.has(prefix)) continue; + const winner = selectCompatibleNodeForPrefix(providerNodes, prefix); + if (winner?.id) providerNodeIdByPrefix[prefix] = winner.id; + } // #8327: `resolveCanonicalProviderId`/`canonicalProviderId` only know the static // AI_PROVIDERS/PROVIDER_MODELS alias maps, so a compatible-provider node (whose raw @@ -396,7 +420,10 @@ async function buildUnifiedModelsResponseCore( // single-stretch event-loop budget this file's own yield mechanism is meant to protect. const connectionsForProviderCache = new Map(); const getConnectionsForProvider = (...keys: Array) => { - const cacheKey = keys.filter((k): k is string => Boolean(k)).sort().join(""); + const cacheKey = keys + .filter((k): k is string => Boolean(k)) + .sort() + .join(""); const cached = connectionsForProviderCache.get(cacheKey); if (cached) return cached; const seen = new Set(); @@ -450,8 +477,41 @@ async function buildUnifiedModelsResponseCore( const getProviderPrefixes = (providerId: string, rawProvider: string) => getProviderPrefixesFromMaps(aliasMaps, providerId, rawProvider); - const getComboTargetModelId = (target: ComboCatalogTarget) => - getComboTargetModelIdFromMaps(aliasMaps, target); + const getComboTargetModelId = (target: ComboCatalogTarget) => { + const resolved = getComboTargetModelIdFromMaps(aliasMaps, target); + if (!resolved) return null; + const nodeId = providerNodeIdByPrefix[resolved.providerId]; + return nodeId ? { ...resolved, providerId: nodeId } : resolved; + }; + + const resolvedComboTargets = combos.flatMap( + (combo) => + resolveNestedComboTargets( + combo as Parameters[0], + combos as Parameters[1] + ) as ComboCatalogTarget[] + ); + const comboProviderIds = new Set( + resolvedComboTargets.flatMap((target) => { + const resolved = getComboTargetModelId(target); + return resolved ? [resolved.providerId] : []; + }) + ); + const comboSyncedModelsByProvider = new Map(); + await Promise.all( + [...comboProviderIds].map(async (providerId) => { + try { + const byConnection = await getSyncedAvailableModelsByConnection(providerId); + comboSyncedModelsByProvider.set( + providerId, + byConnection[SYNCED_AVAILABLE_MODELS_MALFORMED] ? null : byConnection + ); + } catch { + // Unknown connection-scoped capability evidence must never broaden a combo. + comboSyncedModelsByProvider.set(providerId, null); + } + }) + ); const getComboTargetCatalogMetadata = ( target: ComboCatalogTarget @@ -462,23 +522,67 @@ async function buildUnifiedModelsResponseCore( const canonical = getCanonicalModelMetadata({ provider: targetModel.providerId, model: targetModel.modelId, + snapshot: capabilityResolutionSnapshot, }); if (!canonical) return null; - const source = canonical.metadata.source; - if (!source.providerRegistry && !source.staticSpec && !source.syncedCapability) return null; - const providerId = canonical.provider || targetModel.providerId; const modelId = canonical.model || targetModel.modelId; + const providerAlias = providerIdToAlias[providerId] || PROVIDER_ID_TO_ALIAS[providerId]; + const allProviderConnections = getConnectionsForProvider( + providerId, + providerAlias, + targetModel.providerId + ); + const providerConnections = allProviderConnections.filter((connection) => + hasEligibleConnectionForModel([connection], modelId) + ); + const hasExplicitConnectionScope = + Boolean(target.connectionId) || Boolean(target.allowedConnectionIds?.length); + const eligibleConnectionIds = + allProviderConnections.length > 0 || hasExplicitConnectionScope + ? providerConnections.map((connection) => connection.id) + : undefined; + const source = canonical.metadata.source; + const connectionCatalog = comboSyncedModelsByProvider.get(providerId); + // A `reasoning_efforts` model-capability override is operator-declared, + // provider-scoped authoritative evidence — it must win over (and never be + // silently dropped by) the per-connection synced-catalog fail-closed scan + // below, or the override would apply in the direct catalog but vanish from + // combo `effort_tiers`. + const connectionEfforts = source.reasoningEffortsOverride + ? canonical.capabilities.supportedThinkingEfforts + ? [...canonical.capabilities.supportedThinkingEfforts] + : [] + : connectionCatalog === null + ? [] + : getConnectionScopedEffortTiers( + modelId, + target, + eligibleConnectionIds, + connectionCatalog || {} + ); + if ( + connectionEfforts === undefined && + !source.providerRegistry && + !source.staticSpec && + !source.syncedCapability && + !source.reasoningEffortsOverride + ) { + return null; + } + const synced = getSyncedCapability(providerId, modelId); const spec = getModelSpec(modelId); const registryModel = getRegistryModel(providerId, modelId); const syncedInputModalities = parseJsonStringArray(synced?.modalities_input); const syncedOutputModalities = parseJsonStringArray(synced?.modalities_output); - const contextLength = isPositiveFiniteNumber(canonical.limits.contextWindow) - ? canonical.limits.contextWindow - : getTokenLimit(providerId, modelId) || undefined; + const contextLength = getSourcedTokenLimit( + providerId, + modelId, + canonical.limits.contextWindow + ); const maxInputTokens = isPositiveFiniteNumber(canonical.limits.maxInputTokens) ? canonical.limits.maxInputTokens : contextLength; @@ -535,12 +639,21 @@ async function buildUnifiedModelsResponseCore( } Object.assign( capabilities, - getThinkingCapabilityFields( - providerId, - modelId, - canonical.capabilities.supportsThinking, - registryModel?.supportedThinkingEfforts - ) + connectionEfforts === undefined + ? getThinkingCapabilityFields( + providerId, + modelId, + canonical.capabilities.supportsThinking, + registryModel?.supportedThinkingEfforts, + true + ) + : getThinkingCapabilityFields( + providerId, + modelId, + connectionEfforts.length > 0 ? true : canonical.capabilities.supportsThinking, + connectionEfforts, + true + ) ); return { @@ -593,6 +706,9 @@ async function buildUnifiedModelsResponseCore( : []; const capabilities = mergeComboCapabilities(knownMetadata); + if (targetMetadata.some((metadata) => metadata === null)) { + delete capabilities.effort_tiers; + } return { ...baseMetadata, @@ -1740,9 +1856,8 @@ async function buildUnifiedModelsResponseCore( } enrichmentSnapshot = { modelsDevPricing, - providerNodeIdsByPrefix: Object.fromEntries( - Object.entries(providerIdToPrefix).map(([providerId, prefix]) => [prefix, providerId]) - ), + capabilityResolution: capabilityResolutionSnapshot, + providerNodeIdsByPrefix: providerNodeIdByPrefix, }; // The production profile identified pricing snapshot construction as the last // dominant synchronous stage. Let already-queued health checks run before the diff --git a/src/app/api/v1/models/catalogHelpers.ts b/src/app/api/v1/models/catalogHelpers.ts index 05996252a7..42d24fb1d8 100644 --- a/src/app/api/v1/models/catalogHelpers.ts +++ b/src/app/api/v1/models/catalogHelpers.ts @@ -30,8 +30,20 @@ export type ComboCatalogTarget = { modelStr?: string; provider?: string | null; providerId?: string | null; + connectionId?: string | null; + allowedConnectionIds?: string[] | null; }; +type ConnectionScopedReasoningModel = { + id: string; + supportedThinkingEfforts?: string[]; +}; + +export type ConnectionScopedReasoningCatalog = Record< + string, + readonly ConnectionScopedReasoningModel[] +>; + export type ComboTargetCatalogMetadata = { contextLength?: number; maxInputTokens?: number; @@ -83,6 +95,54 @@ export function minKnownNumber(values: Array): number | unde return Math.min(...knownValues); } +/** + * Resolve the adjustable reasoning efforts shared by every connection a combo target can select. + * `undefined` means there is no connection-scoped evidence, so authoritative static metadata may + * still apply. An empty array means at least one selectable connection advertised this model but + * the complete selectable set did not prove any common adjustable tier, so callers must fail + * closed instead of falling back to broader model-family metadata. + */ +export function getConnectionScopedEffortTiers( + modelId: string, + target: Pick, + eligibleConnectionIds: readonly string[] | undefined, + modelsByConnection: ConnectionScopedReasoningCatalog +): string[] | undefined { + const eligible = eligibleConnectionIds ? new Set(eligibleConnectionIds) : undefined; + if (target.connectionId && eligible && !eligible.has(target.connectionId)) return []; + if ( + target.allowedConnectionIds?.length && + eligible && + !target.allowedConnectionIds.some((id) => eligible.has(id)) + ) { + return []; + } + if (!target.connectionId && !target.allowedConnectionIds?.length && eligible?.size === 0) { + return []; + } + + const catalogConnectionIds = Object.keys(modelsByConnection); + if (catalogConnectionIds.length === 0) return undefined; + + let connectionIds: string[]; + if (target.connectionId) { + connectionIds = !eligible || eligible.has(target.connectionId) ? [target.connectionId] : []; + } else if (target.allowedConnectionIds?.length) { + connectionIds = target.allowedConnectionIds.filter((id) => !eligible || eligible.has(id)); + } else { + connectionIds = eligible ? [...eligible] : Object.keys(modelsByConnection); + } + if (connectionIds.length === 0) return []; + + const matching = connectionIds.map((connectionId) => + (modelsByConnection[connectionId] || []).find((model) => model.id === modelId) + ); + if (matching.some((model) => model === undefined)) return []; + + const efforts = matching.map((model) => model?.supportedThinkingEfforts || []); + return intersectStringArrays(efforts); +} + export function getThinkingCapabilityFields( providerId: string, modelId: string, diff --git a/src/app/api/v1/providers/[provider]/chat/completions/route.ts b/src/app/api/v1/providers/[provider]/chat/completions/route.ts index f064163b9e..1149ef8933 100644 --- a/src/app/api/v1/providers/[provider]/chat/completions/route.ts +++ b/src/app/api/v1/providers/[provider]/chat/completions/route.ts @@ -4,6 +4,7 @@ import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; let initialized = false; @@ -31,7 +32,7 @@ export async function OPTIONS() { * Routes to the specified provider, validating model/provider match. * Full body format validation is delegated to handleChat. */ -export async function POST(request, { params }) { +async function postHandler(request, { params }) { const { provider: rawProvider } = await params; const providerEntry = getRegistryEntry(rawProvider); @@ -103,3 +104,5 @@ export async function POST(request, { params }) { return await handleChat(newRequest, () => buildClientRawRequest(request, rawBody)); } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index b92b9cbfe0..28cc5160db 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -8,9 +8,14 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { handleChat } from "@/sse/handlers/chat"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { getRelayTokenByHash, checkRateLimit, recordRelayUsage } from "@/lib/db/relayProxies"; -import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { + buildErrorBody, + parseUpstreamError, + sanitizeErrorMessage, +} from "@omniroute/open-sse/utils/error"; import { checkIpRateLimit, extractToken, @@ -29,6 +34,7 @@ import { import { getProviderPluginManifestEntryForModel } from "@omniroute/open-sse/config/providerPluginManifestRegistry.ts"; import { getProviderPluginManifestHeader } from "@omniroute/open-sse/config/providerPluginManifestUrl.ts"; import { finalizeReadableStream } from "./streamFinalizer"; +import { stripStaleEncodingHeaders } from "@omniroute/open-sse/utils/upstreamResponseHeaders.ts"; import { clearBifrostFailure, getActiveBifrostCooldown, @@ -108,6 +114,32 @@ async function forwardToBifrost( headers.set("Content-Type", upstream.headers.get("Content-Type") ?? "application/json"); } + // Issue #1: Bifrost (or the upstream behind it) may return plain text or HTML + // on a non-OK status (e.g. 502 from a sidecar, "invalid character 'd'" style + // proxy errors). Forwarding `upstream.body` raw leaks non-JSON into a client + // that expects OpenAI-shaped JSON, producing client-side parse failures. + // Normalize any non-OK response through parseUpstreamError + buildErrorBody so + // the client always receives a valid JSON error. (Hard rule #12.) + if (!upstream.ok) { + const parsed = await parseUpstreamError(upstream, null); + const errorBody = buildErrorBody( + parsed.statusCode, + sanitizeErrorMessage(parsed.message), + parsed.responseBody + ); + const errorHeaders = stripStaleEncodingHeaders(headers); + errorHeaders.set("Content-Type", "application/json"); + if (parsed.retryAfterMs && parsed.retryAfterMs > 0) { + errorHeaders.set("Retry-After", String(Math.ceil(parsed.retryAfterMs / 1000))); + } + clearTimeout(tid); + recordUsage(token.id, request, startTime, clientIp, userAgent, "error", parsed.statusCode); + return new Response(JSON.stringify(errorBody), { + status: parsed.statusCode, + headers: errorHeaders, + }); + } + if (wantsStream && upstream.body) { const stream = finalizeReadableStream(upstream.body, (error) => { clearTimeout(tid); @@ -144,7 +176,8 @@ async function forwardToBifrost( startTime, clientIp, userAgent, - upstream.status < 500 ? "success" : "error", + // upstream.ok is guaranteed true here (the !upstream.ok branch above returns early). + "success", upstream.status ); @@ -167,7 +200,7 @@ export async function OPTIONS() { return handleCorsOptions(); } -export async function POST(request: Request) { +async function postHandler(request: Request) { const startTime = Date.now(); const clientIp = getClientIp(request); const userAgent = sanitizeForensicHeader(request.headers.get("user-agent")); @@ -401,3 +434,5 @@ export async function POST(request: Request) { }); } } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/responses/[...path]/route.ts b/src/app/api/v1/responses/[...path]/route.ts index e2f7062f5a..12eb4e05b3 100644 --- a/src/app/api/v1/responses/[...path]/route.ts +++ b/src/app/api/v1/responses/[...path]/route.ts @@ -1,5 +1,6 @@ import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; let initialized = false; @@ -25,7 +26,9 @@ export async function OPTIONS() { * Reuses the shared chat handler so native Codex passthrough can keep * arbitrary Responses suffixes all the way to the upstream provider. */ -export async function POST(request) { +async function postHandler(request) { await ensureInitialized(); return await handleChat(request); } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/responses/route.ts b/src/app/api/v1/responses/route.ts index fe2fcbe8aa..a7d9978898 100644 --- a/src/app/api/v1/responses/route.ts +++ b/src/app/api/v1/responses/route.ts @@ -1,15 +1,27 @@ +import { z } from "zod"; import { handleChat } from "@/sse/handlers/chat"; -import { - withEarlyStreamKeepalive, - RESPONSES_STARTUP_THINKING_FRAME, - OPENAI_RESPONSES_ERROR_FRAME, -} from "@omniroute/open-sse/utils/earlyStreamKeepalive"; -import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; +import { CORS_HEADERS } from "@/shared/utils/cors"; +import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { resolveResponsesApiModel } from "@/app/api/internal/codex-responses-ws/modelResolution"; import { getModelInfo, getComboForModel } from "@/sse/services/model"; -import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold"; -import { resolveStreamFlag } from "@omniroute/open-sse/utils/aiSdkCompat"; import { generateRequestId } from "@/shared/utils/requestId"; +import { + admitChatRequest, + admitChatStructure, + CHAT_ADMISSION_QUEUE_MAX_MS, + releaseChatAdmissionAfterHandler, + releaseChatAdmissionWhenDone, + resolveSessionId, +} from "@/shared/middleware/chatBodyAdmission"; +import { SSE_HEARTBEAT_INTERVAL_MS } from "@omniroute/open-sse/config/constants"; +import { resolveStreamFlag } from "@omniroute/open-sse/utils/aiSdkCompat"; +import { errorResponse } from "@omniroute/open-sse/utils/error"; +import { + withEarlyStreamKeepalive, + OPENAI_RESPONSES_ERROR_FRAME, +} from "@omniroute/open-sse/utils/earlyStreamKeepalive"; +import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold"; +import { OPENAI_RESPONSES_IN_PROGRESS_FRAME } from "@omniroute/open-sse/utils/sseHeartbeat"; // NOTE: We do NOT call initTranslators() here — the translator registry is // bootstrapped at module level inside open-sse/translator/index.ts when it @@ -20,6 +32,8 @@ import { generateRequestId } from "@/shared/utils/requestId"; // The translators are always initialized via the open-sse side (chatCore), // so /v1/responses just delegates to handleChat which handles everything. +const injectionGuard = createInjectionGuard(); + export async function OPTIONS() { return new Response(null, { headers: { @@ -35,8 +49,8 @@ export async function OPTIONS() { * the CLI sends bare "gpt-5.5" over HTTP after WS closes (1008 Policy), and * without this rewrite OmniRoute routes it to openrouter instead of codex. * - * Accepts an optional `preParsedBody` (threaded from withInjectionGuard via #4041) - * to avoid re-cloning the request when the body was already parsed upstream. + * Accepts an optional `preParsedBody` so the route-level admission and injection + * checks can parse once and avoid re-cloning the request on the hot path. * * Safe: only rewrites when codex/model is genuinely registered; all other models * pass through unchanged. Errors are caught and the original request + body are returned. @@ -80,42 +94,117 @@ export async function withCodexPreferredModel( /** * POST /v1/responses - OpenAI Responses API format * Handled by the unified chat handler (openai-responses format auto-detected). - * - * `preParsedBody` is threaded from withInjectionGuard (#4041) so the body is - * parsed at most once per request instead of 3-4x on the hot codex path. */ -async function postHandler(request: any, context: any, preParsedBody: any = null) { - // Codex CLI (wire_api="responses") consumes this endpoint over SSE and its reqwest - // client drops the connection if no bytes arrive within ~5s. Keep the connection - // warm with early keepalives while the upstream produces its first token (#2544). - // Non-streaming callers (JSON) keep the original verbatim path untouched. - const { request: resolved, body: resolvedBody } = await withCodexPreferredModel( - request, - preParsedBody - ); - const accept = String(request.headers?.get?.("accept") || ""); - const wantsStreaming = resolveStreamFlag(resolvedBody?.stream, accept, "openai-responses"); - if (wantsStreaming) { - // Adaptive threshold: web-session and anonymous-fallback providers are slower - // to produce the first byte, so use a longer keepalive threshold (15s vs 2s). - // Reuse resolvedBody.model — no extra clone/parse needed (#4041). - const model = resolvedBody?.model; - const thresholdMs = resolveKeepaliveThreshold(model); - // Generated here (rather than left to handleChatImplementation's own - // fallback) so withEarlyStreamKeepalive can tag its own direct-to-client - // writes with the same id chatCore.ts ends up persisting the call log - // under — see earlyKeepaliveByteBuffer.ts for why this is the only way - // the two sides of that boundary can agree on "which request." - const correlationId = generateRequestId(); - return await withEarlyStreamKeepalive(handleChat(resolved, null, resolvedBody, correlationId), { +async function postHandler(request: any) { + const sessionId = resolveSessionId(request); + const admissionResult = await admitChatRequest(request, { + sessionId, + queueMs: CHAT_ADMISSION_QUEUE_MAX_MS, + }); + if (admissionResult.admit === false) return admissionResult.response; + + const admission = admissionResult; + request = admission.request; + const finishAdmission = (response: Response) => + releaseChatAdmissionWhenDone(response, admission.lease); + + try { + let parsedBody; + try { + parsedBody = await request.json(); + } catch { + return finishAdmission(errorResponse(400, "Invalid JSON body")); + } + const parsed = z.object({}).passthrough().safeParse(parsedBody); + if (!parsed.success || Array.isArray(parsed.data)) { + return finishAdmission(errorResponse(400, "Request body must be a JSON object")); + } + parsedBody = parsed.data; + + const structuralAdmission = await admitChatStructure(parsedBody, admission.lease, { + sessionId, + queueMs: CHAT_ADMISSION_QUEUE_MAX_MS, signal: request.signal, - thresholdMs, - startupFrame: RESPONSES_STARTUP_THINKING_FRAME, - errorFrame: OPENAI_RESPONSES_ERROR_FRAME, - correlationId, }); + if (structuralAdmission.admit === false) { + admission.lease?.release(); + return finishAdmission(structuralAdmission.response); + } + admission.lease = structuralAdmission.lease; + + let guardResult; + try { + guardResult = injectionGuard(parsedBody); + } catch (error) { + console.error("[SECURITY] Injection guard error:", error); + return finishAdmission( + new Response(JSON.stringify({ error: "Security check failed" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }) + ); + } + + const { blocked, result } = guardResult; + if (blocked) { + return finishAdmission( + new Response( + JSON.stringify({ + error: { + message: "Request blocked: potential prompt injection detected", + type: "injection_detected", + code: "SECURITY_001", + detections: result.detections.length, + }, + }), + { status: 400, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } } + ) + ); + } + if (result.flagged) { + try { + request.headers.set("X-Injection-Flagged", "true"); + request.headers.set("X-Injection-Detections", String(result.detections.length)); + } catch { + // Detection already ran; metadata propagation is best-effort. + } + } + + // Codex CLI (wire_api="responses") consumes this endpoint over SSE and its reqwest + // client drops the connection if no bytes arrive within ~5s. Keep the connection + // warm with transport comments plus sparse parser-visible events while the upstream + // produces its first token (#2544). + const { request: resolved, body: resolvedBody } = await withCodexPreferredModel( + request, + parsedBody + ); + const accept = String(request.headers?.get?.("accept") || ""); + const wantsStreaming = resolveStreamFlag(resolvedBody?.stream, accept, "openai-responses"); + if (wantsStreaming) { + const thresholdMs = resolveKeepaliveThreshold(resolvedBody?.model); + const correlationId = generateRequestId(); + const handlerResponse = releaseChatAdmissionAfterHandler( + handleChat(resolved, null, resolvedBody, correlationId), + admission.lease + ); + return await withEarlyStreamKeepalive(handlerResponse, { + signal: request.signal, + thresholdMs, + startupFrame: OPENAI_RESPONSES_IN_PROGRESS_FRAME, + applicationKeepalive: { + frame: OPENAI_RESPONSES_IN_PROGRESS_FRAME, + intervalMs: SSE_HEARTBEAT_INTERVAL_MS, + }, + errorFrame: OPENAI_RESPONSES_ERROR_FRAME, + correlationId, + }); + } + + return finishAdmission(await handleChat(resolved, null, resolvedBody)); + } catch (error) { + admission.lease?.release(); + throw error; } - return await handleChat(resolved, null, resolvedBody); } -export const POST = withInjectionGuard(postHandler); +export const POST = postHandler; diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index 7f9b1011aa..29f642211e 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -19,7 +19,11 @@ import * as log from "@/sse/utils/logger"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { v1SearchSchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { + formatValidationMessage, + isValidationFailure, + validateBody, +} from "@/shared/validation/helpers"; import { recordCost } from "@/domain/costRules"; import { computeCacheKey, @@ -120,7 +124,7 @@ async function postHandler(request: Request, context: unknown) { const validation = validateBody(v1SearchSchema, rawBody); if (isValidationFailure(validation)) { - return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message); + return errorResponse(HTTP_STATUS.BAD_REQUEST, formatValidationMessage(validation.error)); } const body = validation.data; diff --git a/src/app/healthz/route.ts b/src/app/healthz/route.ts index 4ee01c32d2..5ac54700fd 100644 --- a/src/app/healthz/route.ts +++ b/src/app/healthz/route.ts @@ -1,4 +1,5 @@ import { getServerLifecyclePhase } from "@/lib/serverLifecycle"; +import { observeHealthzEventLoopLag } from "@/lib/healthzLag"; export const dynamic = "force-dynamic"; @@ -23,6 +24,7 @@ function createHealthResponse(method: "GET" | "HEAD"): Response { } export function GET(): Response { + observeHealthzEventLoopLag(); return createHealthResponse("GET"); } diff --git a/src/app/livez/route.ts b/src/app/livez/route.ts new file mode 100644 index 0000000000..d82f2dbee9 --- /dev/null +++ b/src/app/livez/route.ts @@ -0,0 +1,27 @@ +/** + * Process-alive probe. Distinct from /healthz (lifecycle readiness). + * Does not inspect the database, catalog, or providers. Still runs on the + * main Node event loop — busy ≠ dead; prefer TCP liveness under stall. + */ +export const dynamic = "force-dynamic"; + +const LIVE_BODY = "ok\n"; + +function createLiveResponse(method: "GET" | "HEAD"): Response { + return new Response(method === "HEAD" ? null : LIVE_BODY, { + status: 200, + headers: { + "Cache-Control": "no-store", + "Content-Length": String(LIVE_BODY.length), + "Content-Type": "text/plain; charset=utf-8", + }, + }); +} + +export function GET(): Response { + return createLiveResponse("GET"); +} + +export function HEAD(): Response { + return createLiveResponse("HEAD"); +} diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index 89a925236d..1fae2cb08d 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -26,7 +26,17 @@ import { getLatestQuotaSnapshotsForConnection, } from "@/lib/db/quotaSnapshots"; import { recordProviderQuotaResetEventIfChanged } from "@/lib/db/quotaResetEvents"; -import { getCodexQuotaWindowFilterForModel } from "@omniroute/open-sse/config/codexQuotaScopes.ts"; +import { + CODEX_SPARK_QUOTA_SESSION, + CODEX_SPARK_QUOTA_WEEKLY, + getCodexQuotaWindowFilterForModel, +} from "@omniroute/open-sse/config/codexQuotaScopes.ts"; +import { + createCodexAccountPool, + getCodexChildQuotaHydration, + resolveCodexAccount, + type CodexPersistedQuotaState, +} from "@omniroute/open-sse/services/codexAccount/index.ts"; import { getAntigravityQuotaFamily } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -34,6 +44,13 @@ import { getAntigravityQuotaFamily } from "@omniroute/open-sse/services/antigrav interface QuotaInfo { remainingPercentage: number; resetAt: string | null; + // #10095 — upstream explicitly told us it did NOT report this window's + // fraction (e.g. a fresh Antigravity account or a newly-launched + // -tiered model id Google hasn't wired quota telemetry for yet). + // `undefined`/`true` means the value is a real, upstream-reported + // percentage; `false` means "unknown", so callers must not treat the + // defaulted-to-0 `remainingPercentage` as genuine exhaustion. + fractionReported?: boolean; } interface QuotaCacheEntry { @@ -103,7 +120,10 @@ const MAX_CONCURRENT_REFRESHES = 5; function isExhausted(quotas: Record): boolean { const entries = Object.values(quotas); if (entries.length === 0) return false; - return entries.every((q) => q.remainingPercentage <= 0); + // #10095 — a window whose fraction was never reported by upstream must + // never single-handedly flip the whole connection to exhausted; treat it + // as available (mirrors the guard in genericQuotaFetcher.ts). + return entries.every((q) => q.fractionReported !== false && q.remainingPercentage <= 0); } /** @@ -227,6 +247,9 @@ function normalizeQuotas(rawQuotas: Record): Record 0 ? Math.round(((q.total - (q.used || 0)) / q.total) * 100) : 0), resetAt: q.resetAt || null, + // #10095 — thread through the "did upstream actually report this + // window's fraction" signal (see UsageQuota in usage/quota.ts). + fractionReported: q.fractionReported === false ? false : undefined, }; } } @@ -295,6 +318,88 @@ function isAntigravityQuotaExhausted( ); } +function remainingPercent(usage: unknown, limit: unknown): number | null { + const used = Number(usage); + const total = Number(limit); + if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return null; + return clampPercent(((total - used) / total) * 100); +} + +function mergeCodexPersistedQuota( + entry: QuotaCacheEntry, + scope: "codex" | "spark", + quotaState: CodexPersistedQuotaState +): void { + const sessionKey = scope === "spark" ? CODEX_SPARK_QUOTA_SESSION : "session"; + const weeklyKey = scope === "spark" ? CODEX_SPARK_QUOTA_WEEKLY : "weekly"; + const sessionRemaining = remainingPercent(quotaState.usage5h, quotaState.limit5h); + const weeklyRemaining = remainingPercent(quotaState.usage7d, quotaState.limit7d); + if (sessionRemaining !== null) { + entry.quotas[sessionKey] = { + remainingPercentage: sessionRemaining, + resetAt: quotaState.resetAt5h ?? null, + }; + } + if (weeklyRemaining !== null) { + entry.quotas[weeklyKey] = { + remainingPercentage: weeklyRemaining, + resetAt: quotaState.resetAt7d ?? null, + }; + } +} + +/** Overlay one Codex child's persisted quota facts into the existing request cache. */ +export function hydrateCodexQuotaCacheForRequest( + connection: { + id: string; + provider: string; + providerSpecificData?: Readonly> | null; + }, + requestedModel: string | null +): void { + if (connection.provider !== "codex" || !requestedModel?.trim()) return; + const pool = createCodexAccountPool({ + id: connection.id, + provider: connection.provider, + providerSpecificData: connection.providerSpecificData ?? {}, + }); + const account = resolveCodexAccount(pool, requestedModel); + if (account.kind !== "child") return; + const hydration = getCodexChildQuotaHydration(account); + if (!hydration.quotaState) return; + + const { cache } = getState(); + const entry = cache.get(connection.id) || + hydrateQuotaCacheFromSnapshots(connection.id) || { + connectionId: connection.id, + provider: connection.provider, + quotas: {}, + fetchedAt: Date.now(), + exhausted: false, + nextResetAt: null, + }; + mergeCodexPersistedQuota(entry, hydration.scope, hydration.quotaState); + let exhaustedResetAt: string | null = null; + if (hydration.exhaustedWindow) { + const windowName = + hydration.scope === "spark" + ? hydration.exhaustedWindow === "5h" + ? CODEX_SPARK_QUOTA_SESSION + : CODEX_SPARK_QUOTA_WEEKLY + : hydration.exhaustedWindow === "5h" + ? "session" + : "weekly"; + const window = entry.quotas[windowName]; + if (window) { + entry.quotas[windowName] = { ...window, remainingPercentage: 0 }; + exhaustedResetAt = window.resetAt; + } + } + entry.exhausted = isExhausted(entry.quotas); + if (exhaustedResetAt) entry.nextResetAt = exhaustedResetAt; + cache.set(connection.id, entry); +} + function isCodexQuotaExhausted( connectionId: string, entry: QuotaCacheEntry, @@ -549,11 +654,14 @@ export function getQuotaWindowStatus( usedPercentage, resetAt, // If reset time has already passed, avoid stale cached percentages blocking selection. - reachedThreshold: windowExpired - ? false - : remainingPercentage <= 0 - ? true - : usedPercentage >= thresholdPercent, + // #10095 — a window whose fraction upstream never reported is "unknown", + // not "0% remaining"; never let it reach the exhaustion threshold. + reachedThreshold: + windowExpired || window.fractionReported === false + ? false + : remainingPercentage <= 0 + ? true + : usedPercentage >= thresholdPercent, }; } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 802e133ff3..85bb06ddb8 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1165,6 +1165,8 @@ "consoleLogs": "سجلات وحدة التحكم", "logsTimeline": "Timeline", "logsTimelineSubtitle": "جدول زمني للطلبات المرئية", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "التوجيه العام", "mitmProxy": "بروكسي MITM", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "فتح", "close": "إغلاق" }, - "noResults": "لا توجد نتائج", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "لا توجد نتائج" }, "webhooks": { "title": "خطافات الويب", @@ -1739,8 +1739,8 @@ "quotaShare": "مشاركة الحصة", "discovery": "الاستكشاف", "freeProviderRankings": "تصنيفات المزودين المجانيين", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "الفئات المجانية", "gamification": "التلعيب", "leaderboard": "لوحة الصدارة", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "اختر كيفية توزيع الطلبات على نماذجك؛ تتوفر 13 استراتيجية", "wizardStep4Title": "المراجعة والحفظ", "wizardStep4Desc": "راجع التكوين وفعّل المجموعة", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "تشغيل", "emailVisibilityStateOff": "إيقاف", "reorderHandle": "اسحب لإعادة الترتيب", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "لقد تم إهمال هذا المزود", "riskNotice": { "title": "قبل المتابعة", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "مزوّد له محاذير استخدام — انقر لعرض التفاصيل", "oauth": "يستخدم هذا المزوّد جلسة المنتج الرسمية أو OAuth، وهي غير مصرّح بها للاستخدام مع الوكيل أو الموجّه. لا نوصي بالاستخدام المكثف للوكلاء المستقلين (مثل OpenCloud والتدفقات الطويلة متعددة الخطوات والدُفعات الكبيرة)، فقد يقيّد مزوّد المنبع الحساب أو يحظره. استخدمه على مسؤوليتك.", "webCookie": "يصادق هذا المزوّد عبر ملفات تعريف ارتباط جلسة الويب. قد تُبطل خدمة المنبع الجلسة في أي وقت، ما يتطلب تسجيل الدخول مجددًا. لا يُنصح به للعمليات الطويلة غير المراقبة. استخدمه على مسؤوليتك.", @@ -5107,9 +5111,9 @@ "cancel": "إلغاء" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "معطل", "enableProvider": "تمكين المزود", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "تخطي {count} نموذج موجود", "autoSync": "المزامنة التلقائية", "autoSyncShort": "المزامنة", + "autoFetchModels": "جلب النماذج من المصدر تلقائيًا", + "autoFetchModelsTooltip": "استرجاع وتخزين نماذج المصدر عند الحاجة", + "autoFetchModelsEnabled": "تم تمكين جلب النموذج العلوي تلقائيًا", + "autoFetchModelsDisabled": "تم تعطيل جلب النموذج العلوي تلقائيًا", + "autoFetchModelsToggleFailed": "فشل في تبديل جلب النموذج العلوي تلقائيًا", + "autoFetchModelsPartialFailure": "تم تحديث بعض الاتصالات، لكن نموذج المصدر التلقائي لم يتغير في كل مكان", + "overridesUpstreamModel": "يتجاوز المصدر", + "overridesUpstreamModelHint": "إعداداتك تتجاوز هذا النموذج العلوي", + "resetToUpstreamDefaults": "استعادة الإعدادات الافتراضية للمصدر", + "resetToUpstreamDefaultsSuccess": "تم استعادة إعدادات النموذج الافتراضية من المصدر", + "resetToUpstreamDefaultsFailed": "فشل في استعادة إعدادات النموذج الافتراضية من المصدر", "autoSyncTooltip": "تحديث قائمة النماذج كل 24 ساعة (يمكن ضبطه عبر MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "تم تمكين المزامنة التلقائية — سيتم تحديث النماذج بشكل دوري", "autoSyncDisabled": "تم تعطيل المزامنة التلقائية", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "إعادة كتابة استدعاءات أداة web_fetch الأصلية إلى /v1/web/fetch الخاصة بـ OmniRoute.", "interceptionLoadError": "فشل تحميل إعدادات الاعتراض: {error}", "interceptionSaveError": "فشل حفظ إعدادات الاعتراض: {error}", - "ccAliasSectionTitle": "Expose في كود كلود (claude/…)", - "ccAliasSectionHint": "قم بالإعلان عن نماذج هذا المزود تحت معرفات المرآة claude/<provider>/<model> حتى يتمكن نموذج اكتشاف بوابة Claude Code من إدراجها. معطلة بشكل افتراضي - تمكين هذا يضاعف إدخالات الكتالوج لجميع العملاء.", - "ccAliasProviderLevelLabel": "موفر افتراضي", - "ccAliasModelOverridesLabel": "تجاوزات لكل نموذج", - "ccAliasModelOverrideAriaLabel": "تجاوز لـ {modelId}", - "ccAliasStateInherit": "وراثة", - "ccAliasStateOn": "تشغيل", - "ccAliasStateOff": "إيقاف", - "ccAliasAddModelPlaceholder": "معرف النموذج (مثل gpt-4o)", - "ccAliasAddModelButton": "إضافة تجاوز", - "ccAliasLoadError": "فشل في تحميل إعدادات discovery-alias: {error}", - "ccAliasSaveError": "فشل في حفظ إعداد alias الاكتشاف: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "ترويسات المنبع الإضافية", "compatUpstreamHeadersHint": "إعداد عالي الصلاحيات — يعامل معاملة بيانات اعتماد API الخاصة بالمزود، لذا لا ينبغي استخدامه إلا من مسؤولين موثوقين. تُدمج الترويسات بعد أن يضيف OmniRoute المصادقة. إذا استخدمت ترويسة مخصصة الاسم نفسه لترويسة موجودة (مثل Authorization)، فستستبدل قيمتك الترويسة المنشأة تلقائيًا بالكامل، بما فيها رمز Bearer. قد يؤدي الإعداد الخاطئ إلى خطأ 401 أو تعطّل مصادقة المنبع. أضف ترويسة واحدة في كل صف. تُحفظ القيمة عند فقدان التركيز أو إغلاق اللوحة.", "compatUpstreamHeaderName": "اسم الترويسة", @@ -6194,7 +6209,7 @@ "galadriel": "ربط Galadriel بمفتاح API.", "predibase": "رصيد تجريبي مجاني بقيمة 25 دولارًا (صلاحية لمدة 30 يومًا)", "chenzk": "بوابة متوافقة مع OpenAI مع كتالوج نماذج مباشر على chenzk.top.", - "freepik": "توليد الصور باستخدام واجهة برمجة تطبيقات Mystic من Freepik.", + "magnific": "توليد الصور باستخدام واجهة برمجة تطبيقات Mystic من Freepik.", "freetheai": "بوابة مجانية متوافقة مع OpenAI مع دعم نماذج التمرير المباشر (passthrough).", "g4f-gemini": "وكيل عكسي مجاني بدون مفتاح من g4f.space إلى Gemini، محدود بـ 5 طلبات في الدقيقة.", "g4f-groq": "وكيل عكسي مجاني بدون مفتاح من g4f.space إلى Groq، محدود بـ 5 طلبات في الدقيقة.", @@ -6209,6 +6224,7 @@ "claude": "ربط Claude Code باستخدام تدفق OAuth الحالي.", "cline": "ربط Cline باستخدام تدفق OAuth الحالي.", "cursor": "ربط Cursor IDE باستخدام تدفق OAuth الحالي.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "ربط GitHub Copilot باستخدام تدفق OAuth الحالي.", "gitlab-duo": "تطبيق OAuth بنطاقات ai_features + read_user. قم بتكوين GITLAB_DUO_OAUTH_CLIENT_ID واختياريًا GITLAB_DUO_OAUTH_CLIENT_SECRET على مثيل OmniRoute هذا.", "kilocode": "ربط Kilo Code باستخدام تدفق OAuth الحالي.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "صديق مفتوح المصدر", "cheaperInferenceSupporterTooltip": "‏Cheaper Inference تدعم OmniRoute كصديق للمصادر المفتوحة", "kimiPartnerLinkNote": "رابط شريك — يدعم OmniRoute دون أي تكلفة إضافية عليك", + "codexQuotaPools": "مجموعات حصص Codex", + "codexPoolAvailable": "متاح", + "codexPoolPartiallyLimited": "محدود جزئيًا", + "codexPoolFullyLimited": "محدود بالكامل", + "codexPoolLimited": "{count} محدود", + "codexPoolQuotaExhausted": "نفدت الحصة", + "codexPoolCoolingDown": "في فترة تهدئة", + "codexPoolUsed": "مُستخدم", + "codexPoolUntil": "حتى {value}", "anonymousFallbackTitle": "التراجع المجهول", "anonymousFallbackDesc": "عند استنفاد جميع الاتصالات المكونة (الحصة، الاعتمادات، أو انتهاء الصلاحية)، استخدم مؤقتًا المستوى بدون مفتاح لهذا المزود. قم بإيقاف التشغيل لتخطي هذا المزود بدلاً من إرسال طلبات مجهولة — يُوصى بذلك عندما يرفض المستوى بدون مفتاح هذه الطلبات (401).", "anonymousFallbackEnabled": "تم تمكين النسخة الاحتياطية المجهولة لـ {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "إعدادات نقطة نهاية النموذج المحفوظ", "searchByModelAria": "البحث حسب الطراز", "selectSupportedEndpoint": "اختر نقطة نهاية مدعومة واحدة على الأقل", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsDisabled": "تم تعطيل جلب النموذج العلوي تلقائيًا", - "autoFetchModels": "جلب النماذج من المصدر تلقائيًا", - "autoFetchModelsEnabled": "تم تمكين جلب النموذج العلوي تلقائيًا", - "autoFetchModelsTooltip": "استرجاع وتخزين نماذج المصدر عند الحاجة", - "autoFetchModelsToggleFailed": "فشل في تبديل جلب النموذج العلوي تلقائيًا", - "overridesUpstreamModelHint": "إعداداتك تتجاوز هذا النموذج العلوي", - "overridesUpstreamModel": "يتجاوز المصدر", - "autoFetchModelsPartialFailure": "تم تحديث بعض الاتصالات، لكن نموذج المصدر التلقائي لم يتغير في كل مكان", - "resetToUpstreamDefaults": "استعادة الإعدادات الافتراضية للمصدر", - "resetToUpstreamDefaultsSuccess": "تم استعادة إعدادات النموذج الافتراضية من المصدر", - "resetToUpstreamDefaultsFailed": "فشل في استعادة إعدادات النموذج الافتراضية من المصدر" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "الإعدادات", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "قم بوضع علامة على اتصالات الموفر على أنها معطلة بشكل دائم إذا أعادت إشارات حظر طرفية محددة (على سبيل المثال، HTTP 403 \"التحقق من حسابك\"). يؤدي هذا إلى إزالتها من دوران التحرير والسرد.", "autoDisableThreshold": "عتبة الحظر", "autoDisableThresholdDesc": "إشارات الحظر المتتالية مطلوبة قبل التعطيل الدائم.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "الكلمات المفتاحية المحظورة", "customBannedSignalsDesc": "كلمات مفتاحية إضافية تؤدي إلى اكتشاف حظر الحساب الدائم. تنطبق الكلمات المفتاحية المدمجة دائمًا.", "customBannedSignalsPlaceholder": "على سبيل المثال: api key revoked", @@ -7189,6 +7203,7 @@ "configured": "مُهيأ", "none": "بلا", "modelOverrideValuePlaceholder": "قيمة رقمية", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "إضافة مفتاح وقيمة", "noModelOverrides": "لم يتم تكوين أي تجاوزات لهذا النموذج.", "modelOverrideLoadFailed": "فشل تحميل تجاوزات النموذج", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK مقتضب (文言)", "description": "أسلوب صيني كلاسيكي فائق الاقتضاب (متاح للغة الصينية فقط)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "تنتقل مجموعات التناوب الدائري (Round-robin) والمجموعات العشوائية إلى اتصال مختلف في كل طلب بدلاً من تثبيت محادثة كاملة باتصال واحد بواسطة هاش الرسالة الأولى. اتركها معطلة للحفاظ على إصابات prompt-cache للمحادثات متعددة الأدوار. عمليات التجاوز الخاصة بكل مجموعة لها الأسبقية.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "تنقيح بيانات الاعتماد", "credentialRedactionDesc": "تنقيح مفاتيح API والرموز المميزة والأسرار من السياق المرسل إلى الموفرين ومن الاستجابات.", "enableCredentialRedaction": "تمكين تنقيح بيانات الاعتماد", @@ -8600,6 +8623,27 @@ }, "enableTitle": "تمكين المحرك", "enableDescription": "يعمل في نهاية المكدس (بعد أن يقوم RTK/Caveman بتنظيف النص، ويقوم OmniGlyph بتحويل المتبقي إلى صور) ويعمل أيضًا بشكل مستقل عبر وضع omniglyph. هذه نسخة معاينة وتظل معطلة افتراضيًا حتى تكتمل عملية التحقق الشاملة من البداية للنهاية.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "تم الحفظ.", "saveFailed": "تعذر الحفظ.", "enableAria": "تمكين محرك OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "أقصى", "grokAutoTopUpMonth": "شهر", "grokAdditionalCredits": "أرصدة إضافية", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "المسجل", "proxyTab": "بروكسي", "budgetManagement": "إدارة الميزانية", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "الرمز الأول", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 43bf476660..6bf2e21e55 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Konsol qeydləri", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Vizual tələb zaman cədvəli", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Qlobal marşrutlaşdırma", "mitmProxy": "MITM Proksi", "oneProxy": "1 Proksi", @@ -1291,9 +1293,7 @@ "open": "açıq", "close": "bağla" }, - "noResults": "Heç bir nəticə yoxdur", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Heç bir nəticə yoxdur" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "Kvota payı", "discovery": "Kəşf", "freeProviderRankings": "Pulsuz provayder reytinqləri", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Pulsuz səviyyələr", "gamification": "Oyunlaşdırma", "leaderboard": "Liderlər cədvəli", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 14 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "This provider has been deprecated", "riskNotice": { "title": "Davam etməzdən əvvəl", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "İstifadə xəbərdarlıqları olan provayder — ətraflı məlumat üçün klikləyin", "oauth": "Bu provayder proksi/router istifadəsi üçün icazə verilməyən rəsmi məhsul sessiyanızdan/OAuth-dan istifadə edir. İntensiv avtonom agent istifadəsini (OpenCloud tərzi, uzun çoxmərhələli axınlar, böyük paketlər) tövsiyə etmirik — upstream xidmət hesabı məhdudlaşdırmaqla və ya bloklamaqla reaksiya verə bilər. Riski öz üzərinizə götürərək istifadə edin.", "webCookie": "Bu provayder veb sessiya kukiləriniz vasitəsilə autentifikasiya edir. Upstream xidmət istənilən vaxt sessiyanı ləğv edə bilər və bu da yenidən daxil olmağınızı tələb edər. Uzunmüddətli nəzarətsiz əməliyyatlar üçün tövsiyə edilmir. Riski öz üzərinizə götürərək istifadə edin.", @@ -5107,9 +5111,9 @@ "cancel": "Ləğv et" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabled", "enableProvider": "Enable provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Skipping {count} existing models", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "Avtomatik olaraq yuxarı axın modellərini əldə et", + "autoFetchModelsTooltip": "Tələb olunduqda yuxarıdakı modelləri əldə et və keşlə.", + "autoFetchModelsEnabled": "Yuxarı axın modelinin avtomatik yüklənməsi aktivdir", + "autoFetchModelsDisabled": "Yuxarı axın modelinin avtomatik əldə edilməsi deaktivdir", + "autoFetchModelsToggleFailed": "Yuxarı axın modelinin avtomatik əldə edilməsini dəyişdirmək mümkün olmadı", + "autoFetchModelsPartialFailure": "Bəzi bağlantılar yeniləndi, lakin yuxarı axın modelinin avtomatik alınması hər yerdə dəyişdirilmədi", + "overridesUpstreamModel": "Yuxarıdan üstəgəl edir", + "overridesUpstreamModelHint": "Sizin parametrləriniz bu yuxarı axın modelini üstələyir", + "resetToUpstreamDefaults": "Yuxarı axın standartlarını bərpa et", + "resetToUpstreamDefaultsSuccess": "Yuxarı axın modelinin standart parametrləri bərpa edildi", + "resetToUpstreamDefaultsFailed": "Yuxarı axın modelinin standartlarını bərpa etmək mümkün olmadı", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Yerli web_fetch alət çağırışlarını OmniRoute-un /v1/web/fetch ünvanına yenidən yazın.", "interceptionLoadError": "Ələ keçirmə parametrlərini yükləmək mümkün olmadı: {error}", "interceptionSaveError": "Ələ keçirmə parametrlərini yadda saxlamaq mümkün olmadı: {error}", - "ccAliasSectionTitle": "Claude Kodunda (claude/…) açıq et", - "ccAliasSectionHint": "Bu təminatçının modellərini claude/<provider>/<model> güzgü ID-ləri altında reklam edin ki, Claude Code-un qapı modeli kəşfi onları siyahıya ala bilsin. Varsayılan olaraq deaktivdir — bunu aktivləşdirmək bütün müştərilər üçün kataloq girişlərini ikiqat artırır.", - "ccAliasProviderLevelLabel": "Təchizatçı standart", - "ccAliasModelOverridesLabel": "Model üzrə üst-üstə düşmələr", - "ccAliasModelOverrideAriaLabel": "{modelId} üçün üst-üstə düşmə", - "ccAliasStateInherit": "İrsiyyət", - "ccAliasStateOn": "Üstündə", - "ccAliasStateOff": "Söndürüldü", - "ccAliasAddModelPlaceholder": "Model id (məsələn, gpt-4o)", - "ccAliasAddModelButton": "Override əlavə et", - "ccAliasLoadError": "Kəşf-alias parametrlərini yükləmək mümkün olmadı: {error}", - "ccAliasSaveError": "discovery-alias parametrlərini saxlamaq mümkün olmadı: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Galadriel-i API açarı ilə qoşun.", "predibase": "$25 pulsuz sınaq krediti (30 günlük etibarlılıq müddəti)", "chenzk": "chenzk.top ünvanında canlı model kataloqu olan OpenAI ilə uyğun gələn şlüz.", - "freepik": "Freepik-in Mystic API-si ilə şəkillər yaradın.", + "magnific": "Freepik-in Mystic API-si ilə şəkillər yaradın.", "freetheai": "Passthrough model dəstəyi olan pulsuz OpenAI ilə uyğun gələn şlüz.", "g4f-gemini": "Gemini-yə pulsuz, açarsız g4f.space tərs proksisi, dəqiqədə 5 sorğu ilə məhdudlaşdırılıb.", "g4f-groq": "Groq-a pulsuz, açarsız g4f.space tərs proksisi, dəqiqədə 5 sorğu ilə məhdudlaşdırılıb.", @@ -6209,6 +6224,7 @@ "claude": "Claude Code-u mövcud OAuth axını ilə qoşun.", "cline": "Cline-ı mövcud OAuth axını ilə qoşun.", "cursor": "Cursor IDE-ni mövcud OAuth axını ilə qoşun.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "GitHub Copilot-u mövcud OAuth axını ilə qoşun.", "gitlab-duo": "ai_features + read_user əhatə dairələri olan OAuth tətbiqi. Bu OmniRoute instansiyasında GITLAB_DUO_OAUTH_CLIENT_ID və istəyə bağlı olaraq GITLAB_DUO_OAUTH_CLIENT_SECRET konfiqurasiya edin.", "kilocode": "Kilo Code-u mövcud OAuth axını ilə qoşun.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Açıq Mənbə Dostu", "cheaperInferenceSupporterTooltip": "Cheaper Inference OmniRoute-u Açıq Mənbə Dostu kimi dəstəkləyir", "kimiPartnerLinkNote": "Tərəfdaş linki — sizə heç bir əlavə xərc olmadan OmniRoute-u dəstəkləyir", + "codexQuotaPools": "Codex kvota hovuzları", + "codexPoolAvailable": "Əlçatandır", + "codexPoolPartiallyLimited": "Qismən məhduddur", + "codexPoolFullyLimited": "Tam məhduddur", + "codexPoolLimited": "{count} məhduddur", + "codexPoolQuotaExhausted": "Kvota tükənib", + "codexPoolCoolingDown": "Gözləmə müddətindədir", + "codexPoolUsed": "istifadə edilib", + "codexPoolUntil": "{value} tarixinədək", "anonymousFallbackTitle": "Anonim ehtiyat", "anonymousFallbackDesc": "Bütün konfiqurasiya olunmuş bağlantılar tükəndikdə (kvota, kreditlər və ya müddət), müvəqqəti olaraq bu təminatçının açarsız səviyyəsini istifadə edin. Anonim sorğular göndərmək əvəzinə bu təminatçını atlamaq üçün söndürün — açarsız səviyyə onları rədd etdikdə (401) tövsiyə olunur.", "anonymousFallbackEnabled": "{provider} üçün anonim ehtiyat aktivdir", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Saxlanmış model son nöqtəsi parametrləri", "searchByModelAria": "Model üzrə axtarış edin", "selectSupportedEndpoint": "Ən azı bir dəstəklənən son nöqtəni seçin", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Avtomatik olaraq yuxarı axın modellərini əldə et", - "autoFetchModelsDisabled": "Yuxarı axın modelinin avtomatik əldə edilməsi deaktivdir", - "autoFetchModelsTooltip": "Tələb olunduqda yuxarıdakı modelləri əldə et və keşlə.", - "autoFetchModelsEnabled": "Yuxarı axın modelinin avtomatik yüklənməsi aktivdir", - "autoFetchModelsToggleFailed": "Yuxarı axın modelinin avtomatik əldə edilməsini dəyişdirmək mümkün olmadı", - "overridesUpstreamModelHint": "Sizin parametrləriniz bu yuxarı axın modelini üstələyir", - "overridesUpstreamModel": "Yuxarıdan üstəgəl edir", - "autoFetchModelsPartialFailure": "Bəzi bağlantılar yeniləndi, lakin yuxarı axın modelinin avtomatik alınması hər yerdə dəyişdirilmədi", - "resetToUpstreamDefaults": "Yuxarı axın standartlarını bərpa et", - "resetToUpstreamDefaultsSuccess": "Yuxarı axın modelinin standart parametrləri bərpa edildi", - "resetToUpstreamDefaultsFailed": "Yuxarı axın modelinin standartlarını bərpa etmək mümkün olmadı" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Settings", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Qadağan olunmuş açar sözlər", "customBannedSignalsDesc": "Hesabın daimi bloklanmasının aşkarlanmasını işə salan əlavə açar sözlər. Daxili açar sözlər həmişə tətbiq olunur.", "customBannedSignalsPlaceholder": "məs., api key revoked", @@ -7189,6 +7203,7 @@ "configured": "konfiqurasiya edilib", "none": "Heç biri", "modelOverrideValuePlaceholder": "Ədədi dəyər", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Açar-dəyər əlavə et", "noModelOverrides": "Bu model üçün heç bir yenidən təyinetmə konfiqurasiya edilməyib.", "modelOverrideLoadFailed": "Model yenidən təyinetmələrini yükləmək mümkün olmadı", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Yığcam CJK (文言)", "description": "Klassik Çin ultra-yığcam üslubu (yalnız Çin dili üçün əlçatandır)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Round-robin və təsadüfi kombinasiyalar, bütün söhbəti ilk mesajın heşinə görə bir bağlantıya bağlamaq əvəzinə, hər sorğuda fərqli bir bağlantıya keçid edir. Çoxmərhələli söhbətlər üçün prompt-cache hitlərini qorumaq üçün bunu söndürülmüş saxlayın. Hər kombinasiya üçün üstünlüklər prioritet təşkil edir.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Kimlik məlumatlarının gizlədilməsi", "credentialRedactionDesc": "Provayderlərə göndərilən kontekstdən və cavablardan API açarlarını, tokenləri və gizli məlumatları gizlədin.", "enableCredentialRedaction": "Kimlik məlumatlarının gizlədilməsini aktivləşdirin", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Mühərriki aktivləşdir", "enableDescription": "Yığında ən son işləyir (RTK/Caveman mətni təmizlədikdən sonra OmniGlyph qalan hissəni şəkillərə çevirir) və həmçinin omniglyph rejimi vasitəsilə müstəqil işləyir. Bu, ilkin baxışdır və başdan-başa yoxlama tamamlanana qədər standart olaraq söndürülmüş qalır.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Saxlanıldı.", "saveFailed": "Saxlamaq mümkün olmadı.", "enableAria": "OmniGlyph mühərrikini aktivləşdir", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "maksimum", "grokAutoTopUpMonth": "ay", "grokAdditionalCredits": "Əlavə Kreditlər", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "İlk Token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index f48ddf2bd1..a8b6e486b3 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Визуален времеви график на заявките", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "отвори", "close": "затвори" }, - "noResults": "Няма резултати", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Няма резултати" }, "webhooks": { "title": "Уеб кукички", @@ -1739,8 +1739,8 @@ "quotaShare": "Споделяне на квота", "discovery": "Откриване", "freeProviderRankings": "Класации на безплатни доставчици", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Безплатни нива", "gamification": "Геймификация", "leaderboard": "Класация", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Този доставчик е отхвърлен", "riskNotice": { "title": "Преди да продължите", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Доставчик с предупреждения за употреба — щракнете за подробности", "oauth": "Този доставчик използва вашата официална продуктова сесия/OAuth, която не е оторизирана за използване като прокси/рутер. Не препоръчваме интензивно използване на автономни агенти (в стил OpenCloud, дълги многостъпкови процеси, големи партиди) — upstream услугата може да реагира чрез ограничаване или блокиране на акаунта. Използвайте на свой собствен риск.", "webCookie": "Този доставчик се удостоверява чрез бисквитките на вашата уеб сесия. Upstream услугата може да анулира сесията по всяко време, което ще изисква да се влезете отново. Не се препоръчва за дълги операции без надзор. Използвайте на свой собствен риск.", @@ -5107,9 +5111,9 @@ "cancel": "Отказ" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Забранено", "enableProvider": "Активиране на доставчика", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Пропускане на {count} съществуващи модела", "autoSync": "Автоматично синхронизиране", "autoSyncShort": "Синхронизиране", + "autoFetchModels": "Автоматично извличане на upstream модели", + "autoFetchModelsTooltip": "Изтеглете и кеширайте upstream модели, когато е необходимо", + "autoFetchModelsEnabled": "Автоматично извличане на upstream модела е активирано", + "autoFetchModelsDisabled": "Автоматично извличане на upstream модела е деактивирано", + "autoFetchModelsToggleFailed": "Неуспешно превключване на автоматично извличане на upstream модела", + "autoFetchModelsPartialFailure": "Някои връзки са актуализирани, но автоматичното извличане на upstream модела не беше променено навсякъде", + "overridesUpstreamModel": "Презаписва upstream", + "overridesUpstreamModelHint": "Вашите настройки надвиват тази основна модел.", + "resetToUpstreamDefaults": "Възстановяване на настройки по подразбиране на upstream", + "resetToUpstreamDefaultsSuccess": "Възстановени настройки по подразбиране на upstream модела", + "resetToUpstreamDefaultsFailed": "Неуспешно възстановяване на подразбиращите се настройки на upstream модела", "autoSyncTooltip": "Автоматично опресняване на списъка с модели на всеки 24 часа (може да се конфигурира чрез MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Автоматичното синхронизиране е активирано — моделите ще се опресняват периодично", "autoSyncDisabled": "Автоматичното синхронизиране е деактивирано", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Пренаписване на повикванията на вградения инструмент web_fetch към /v1/web/fetch на OmniRoute.", "interceptionLoadError": "Неуспешно зареждане на настройките за прихващане: {error}", "interceptionSaveError": "Неуспешно запазване на настройките за прихващане: {error}", - "ccAliasSectionTitle": "Изложи в Claude Code (claude/…)", - "ccAliasSectionHint": "Рекламирайте моделите на този доставчик под claude/<provider>/<model> mirror ids, за да може откритията на моделите на Claude Code да ги изброява. По подразбиране е изключено — активирането му удвоява записите в каталога за всички клиенти.", - "ccAliasProviderLevelLabel": "Дефолтен доставчик", - "ccAliasModelOverridesLabel": "Преодолявания на моделите", - "ccAliasModelOverrideAriaLabel": "Презапис за {modelId}", - "ccAliasStateInherit": "Наследи", - "ccAliasStateOn": "Включено", - "ccAliasStateOff": "Изключено", - "ccAliasAddModelPlaceholder": "Идентификатор на модела (напр. gpt-4o)", - "ccAliasAddModelButton": "Добави заместване", - "ccAliasLoadError": "Неуспешно зареждане на настройки за discovery-alias: {error}", - "ccAliasSaveError": "Неуспешно запазване на настройката discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Свържете Galadriel с API ключ.", "predibase": "$25 безплатни кредити за пробен период (валидност 30 дни)", "chenzk": "Съвместим с OpenAI шлюз с каталог на моделите на живо на chenzk.top.", - "freepik": "Генерирайте изображения с Mystic API на Freepik.", + "magnific": "Генерирайте изображения с Mystic API на Freepik.", "freetheai": "Безплатен съвместим с OpenAI шлюз с поддръжка на passthrough модели.", "g4f-gemini": "Безплатно обратно прокси без ключ от g4f.space към Gemini, ограничено до 5 заявки в минута.", "g4f-groq": "Безплатно обратно прокси без ключ от g4f.space към Groq, ограничено до 5 заявки в минута.", @@ -6209,6 +6224,7 @@ "claude": "Свържете Claude Code със съществуващия OAuth поток.", "cline": "Свържете Cline със съществуващия OAuth поток.", "cursor": "Свържете Cursor IDE със съществуващия OAuth поток.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Свържете GitHub Copilot със съществуващия OAuth поток.", "gitlab-duo": "OAuth приложение с обхвати ai_features + read_user. Конфигурирайте GITLAB_DUO_OAUTH_CLIENT_ID и по избор GITLAB_DUO_OAUTH_CLIENT_SECRET на тази инстанция на OmniRoute.", "kilocode": "Свържете Kilo Code със съществуващия OAuth поток.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Приятел на отворения код", "cheaperInferenceSupporterTooltip": "Cheaper Inference подкрепя OmniRoute като приятел на отворения код", "kimiPartnerLinkNote": "Партньорска връзка — поддържа OmniRoute без допълнителни разходи за вас", + "codexQuotaPools": "Пулове с квоти на Codex", + "codexPoolAvailable": "Наличен", + "codexPoolPartiallyLimited": "Частично ограничен", + "codexPoolFullyLimited": "Напълно ограничен", + "codexPoolLimited": "{count} ограничени", + "codexPoolQuotaExhausted": "Квотата е изчерпана", + "codexPoolCoolingDown": "В период на изчакване", + "codexPoolUsed": "използвано", + "codexPoolUntil": "До {value}", "anonymousFallbackTitle": "Анонимен резервен вариант", "anonymousFallbackDesc": "Когато всички конфигурирани връзки са изчерпани (квота, кредити или изтичане), временно използвайте безключовия слой на този доставчик. Изключете, за да пропуснете този доставчик вместо да изпращате анонимни заявки — препоръчително, когато безключовият слой ги отхвърля (401).", "anonymousFallbackEnabled": "Анонимен резервен вариант е активиран за {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Настройки на крайна точка на запазен модел", "searchByModelAria": "Търсене по модел", "selectSupportedEndpoint": "Изберете поне една поддържана крайна точка", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Автоматично извличане на upstream модели", - "autoFetchModelsEnabled": "Автоматично извличане на upstream модела е активирано", - "autoFetchModelsTooltip": "Изтеглете и кеширайте upstream модели, когато е необходимо", - "overridesUpstreamModel": "Презаписва upstream", - "autoFetchModelsToggleFailed": "Неуспешно превключване на автоматично извличане на upstream модела", - "autoFetchModelsPartialFailure": "Някои връзки са актуализирани, но автоматичното извличане на upstream модела не беше променено навсякъде", - "autoFetchModelsDisabled": "Автоматично извличане на upstream модела е деактивирано", - "resetToUpstreamDefaults": "Възстановяване на настройки по подразбиране на upstream", - "resetToUpstreamDefaultsSuccess": "Възстановени настройки по подразбиране на upstream модела", - "resetToUpstreamDefaultsFailed": "Неуспешно възстановяване на подразбиращите се настройки на upstream модела", - "overridesUpstreamModelHint": "Вашите настройки надвиват тази основна модел." + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Настройки", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Забранени ключови думи", "customBannedSignalsDesc": "Допълнителни ключови думи, които задействат откриване за постоянен бан на акаунта. Вградените ключови думи се прилагат винаги.", "customBannedSignalsPlaceholder": "напр. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "конфигуриран", "none": "Няма", "modelOverrideValuePlaceholder": "Числова стойност", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Добавяне на ключ-стойност", "noModelOverrides": "Няма конфигурирани предефинирания за този модел.", "modelOverrideLoadFailed": "Неуспешно зареждане на предефиниранията на модела", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Сбит CJK (文言)", "description": "Класически китайски ултра-сбит стил (наличен само за китайски)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Комбинациите от тип Round-robin и random се ротират към различна връзка при всяка заявка, вместо да обвързват целия разговор към една връзка чрез хеша на първото съобщение. Оставете изключено, за да запазите съвпаденията в кеша на подканите (prompt-cache) за многостъпкови чатове. Персонализираните настройки за всяка комбинация имат предимство.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Скриване на идентификационни данни", "credentialRedactionDesc": "Скриване на API ключове, токени и тайни от контекста, изпратен към доставчиците, и от отговорите.", "enableCredentialRedaction": "Активиране на скриването на идентификационни данни", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Активиране на енджина", "enableDescription": "Изпълнява се последен в стека (след като RTK/Caveman изчисти текста, OmniGlyph конвертира остатъка в изображения) и също така работи самостоятелно чрез режим omniglyph. Това е предварителна версия и остава изключена по подразбиране, докато не приключи цялостната валидация.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Запазено.", "saveFailed": "Неуспешно запазване.", "enableAria": "Активиране на енджина OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "макс", "grokAutoTopUpMonth": "месец", "grokAdditionalCredits": "Допълнителни кредити", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Дървосекач", "proxyTab": "Прокси", "budgetManagement": "Управление на бюджета", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Първи токен", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 6a1f14d740..aadc026d06 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "ভিজ্যুয়াল রিকোয়েস্ট টাইমলাইন", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "খুলুন", "close": "বন্ধ করুন" }, - "noResults": "কোন ফলাফল নেই", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "কোন ফলাফল নেই" }, "webhooks": { "title": "ওয়েবহুক", @@ -1739,8 +1739,8 @@ "quotaShare": "কোটা শেয়ার", "discovery": "ডিসকভারি", "freeProviderRankings": "ফ্রি প্রোভাইডার র‍্যাঙ্কিং", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "ফ্রি টিয়ার", "gamification": "গেমিফিকেশন", "leaderboard": "লিডারবোর্ড", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "এই প্রদানকারীকে অবমূল্যায়ন করা হয়েছে", "riskNotice": { "title": "এগিয়ে যাওয়ার আগে", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "ব্যবহারের সতর্কতা সহ প্রদানকারী — বিস্তারিত জানতে ক্লিক করুন", "oauth": "এই প্রদানকারীটি আপনার অফিসিয়াল প্রোডাক্ট সেশন/OAuth ব্যবহার করে, যা প্রক্সি/রাউটার ব্যবহারের জন্য অনুমোদিত নয়। আমরা নিবিড় স্বায়ত্তশাসিত এজেন্ট ব্যবহার (OpenCloud-স্টাইল, দীর্ঘ বহু-ধাপের ফ্লো, বড় ব্যাচ) সুপারিশ করি না — আপস্ট্রিম অ্যাকাউন্টটি সীমাবদ্ধ বা নিষিদ্ধ করে প্রতিক্রিয়া জানাতে পারে। নিজের ঝুঁকিতে ব্যবহার করুন।", "webCookie": "এই প্রদানকারীটি আপনার ওয়েব সেশন কুকিজের মাধ্যমে প্রমাণীকরণ করে। আপস্ট্রিম পরিষেবাটি যেকোনো সময় সেশনটি বাতিল করতে পারে, যার ফলে আপনাকে আবার লগ ইন করতে হবে। দীর্ঘ সময় ধরে অযত্নে রেখে কাজ চালানোর জন্য প্রস্তাবিত নয়। নিজের ঝুঁকিতে ব্যবহার করুন।", @@ -5107,9 +5111,9 @@ "cancel": "বাতিল করুন" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabled", "enableProvider": "Enable provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Skipping {count} existing models", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "আপস্ট্রিম মডেলগুলি স্বয়ংক্রিয়ভাবে আনুন", + "autoFetchModelsTooltip": "প্রয়োজন হলে আপস্ট্রিম মডেলগুলি ফেচ এবং ক্যাশ করুন", + "autoFetchModelsEnabled": "আপস্ট্রিম মডেল স্বয়ংক্রিয়-ফেচ সক্ষম করা হয়েছে", + "autoFetchModelsDisabled": "আপস্ট্রিম মডেল অটো-ফেচ নিষ্ক্রিয় করা হয়েছে", + "autoFetchModelsToggleFailed": "আপস্ট্রিম মডেল অটো-ফেচ টগল করতে ব্যর্থ হয়েছে", + "autoFetchModelsPartialFailure": "কিছু সংযোগ আপডেট হয়েছে, কিন্তু আপস্ট্রিম মডেলের স্বয়ংক্রিয়-ফেচ সব জায়গায় পরিবর্তিত হয়নি", + "overridesUpstreamModel": "আপস্ট্রিম ওভাররাইডস", + "overridesUpstreamModelHint": "আপনার সেটিংস এই আপস্ট্রিম মডেলকে অতিক্রম করে", + "resetToUpstreamDefaults": "আপস্ট্রিম ডিফল্টগুলি পুনরুদ্ধার করুন", + "resetToUpstreamDefaultsSuccess": "আপস্ট্রিম মডেল ডিফল্টগুলি পুনরুদ্ধার করা হয়েছে", + "resetToUpstreamDefaultsFailed": "আপস্ট্রিম মডেল ডিফল্টগুলি পুনরুদ্ধার করতে ব্যর্থ হয়েছে", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "নেটিভ web_fetch টুল কলগুলিকে OmniRoute-এর /v1/web/fetch-এ রিরাইট করুন।", "interceptionLoadError": "ইন্টারসেপশন সেটিংস লোড করতে ব্যর্থ হয়েছে: {error}", "interceptionSaveError": "ইন্টারসেপশন সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে: {error}", - "ccAliasSectionTitle": "Claude কোডে প্রকাশ করুন (claude/…)", - "ccAliasSectionHint": "এই প্রদানকারীর মডেলগুলি claude/<provider>/<model> মিরর আইডির অধীনে বিজ্ঞাপন দিন যাতে Claude Code-এর গেটওয়ে মডেল আবিষ্কার সেগুলি তালিকাভুক্ত করতে পারে। ডিফল্টভাবে বন্ধ — এটি সক্ষম করা হলে সমস্ত ক্লায়েন্টের জন্য ক্যাটালগের এন্ট্রি দ্বিগুণ হয়।", - "ccAliasProviderLevelLabel": "প্রদানকারী ডিফল্ট", - "ccAliasModelOverridesLabel": "প্রতি-মডেল ওভাররাইডস", - "ccAliasModelOverrideAriaLabel": "{modelId} এর জন্য ওভাররাইড", - "ccAliasStateInherit": "উত্তরাধিকারী", - "ccAliasStateOn": "চালু", - "ccAliasStateOff": "বন্ধ", - "ccAliasAddModelPlaceholder": "মডেল আইডি (যেমন gpt-4o)", - "ccAliasAddModelButton": "অভাররাইড যোগ করুন", - "ccAliasLoadError": "ডিসকভারি-অ্যালিয়াস সেটিংস লোড করতে ব্যর্থ: {error}", - "ccAliasSaveError": "ডিসকভারি-অ্যালিয়াস সেটিং সংরক্ষণ করতে ব্যর্থ: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "একটি API কী দিয়ে Galadriel সংযুক্ত করুন।", "predibase": "$25 ফ্রি ট্রায়াল ক্রেডিট (30 দিনের মেয়াদ)", "chenzk": "chenzk.top-এ লাইভ মডেল ক্যাটালগ সহ OpenAI-সামঞ্জস্যপূর্ণ গেটওয়ে।", - "freepik": "Freepik-এর Mystic API দিয়ে ছবি তৈরি করুন।", + "magnific": "Freepik-এর Mystic API দিয়ে ছবি তৈরি করুন।", "freetheai": "passthrough মডেল সমর্থন সহ বিনামূল্যের OpenAI-সামঞ্জস্যপূর্ণ গেটওয়ে।", "g4f-gemini": "Gemini-এর জন্য বিনামূল্যের কী-বিহীন g4f.space রিভার্স প্রক্সি, প্রতি মিনিটে 5টি অনুরোধে সীমাবদ্ধ।", "g4f-groq": "Groq-এর জন্য বিনামূল্যের কী-বিহীন g4f.space রিভার্স প্রক্সি, প্রতি মিনিটে 5টি অনুরোধে সীমাবদ্ধ।", @@ -6209,6 +6224,7 @@ "claude": "বিদ্যমান OAuth ফ্লো দিয়ে Claude Code সংযুক্ত করুন।", "cline": "বিদ্যমান OAuth ফ্লো দিয়ে Cline সংযুক্ত করুন।", "cursor": "বিদ্যমান OAuth ফ্লো দিয়ে Cursor IDE সংযুক্ত করুন।", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "বিদ্যমান OAuth ফ্লো দিয়ে GitHub Copilot সংযুক্ত করুন।", "gitlab-duo": "ai_features + read_user স্কোপ সহ OAuth অ্যাপ্লিকেশন। এই OmniRoute ইনস্ট্যান্সে GITLAB_DUO_OAUTH_CLIENT_ID এবং ঐচ্ছিকভাবে GITLAB_DUO_OAUTH_CLIENT_SECRET কনফিগার করুন।", "kilocode": "বিদ্যমান OAuth ফ্লো দিয়ে Kilo Code সংযুক্ত করুন।", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "ওপেন সোর্স বন্ধু", "cheaperInferenceSupporterTooltip": "Cheaper Inference একজন ওপেন সোর্স বন্ধু হিসেবে OmniRoute-কে সমর্থন করে", "kimiPartnerLinkNote": "পার্টনার লিঙ্ক — আপনার কোনো অতিরিক্ত খরচ ছাড়াই OmniRoute-কে সমর্থন করে", + "codexQuotaPools": "Codex কোটা পুল", + "codexPoolAvailable": "উপলভ্য", + "codexPoolPartiallyLimited": "আংশিকভাবে সীমিত", + "codexPoolFullyLimited": "সম্পূর্ণ সীমিত", + "codexPoolLimited": "{count}টি সীমিত", + "codexPoolQuotaExhausted": "কোটা শেষ", + "codexPoolCoolingDown": "কুলডাউনে আছে", + "codexPoolUsed": "ব্যবহৃত", + "codexPoolUntil": "{value} পর্যন্ত", "anonymousFallbackTitle": "অজ্ঞাত ফালব্যাক", "anonymousFallbackDesc": "যখন সমস্ত কনফিগার করা সংযোগ শেষ হয়ে যায় (কোটা, ক্রেডিট, বা মেয়াদ শেষ), এই প্রদানকারীর কীবিহীন স্তরটি অস্থায়ীভাবে ব্যবহার করুন। অজ্ঞাত অনুরোধ পাঠানোর পরিবর্তে এই প্রদানকারীটি বাদ দিতে বন্ধ করুন — যখন কীবিহীন স্তর সেগুলি প্রত্যাখ্যান করে (401) তখন এটি সুপারিশ করা হয়।", "anonymousFallbackEnabled": "{provider} এর জন্য অজ্ঞাত ফFallback সক্রিয় করা হয়েছে", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "সংরক্ষিত মডেল এন্ডপয়েন্ট সেটিংস", "searchByModelAria": "মডেল দ্বারা অনুসন্ধান করুন", "selectSupportedEndpoint": "কমপক্ষে একটি সমর্থিত এন্ডপয়েন্ট নির্বাচন করুন", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsTooltip": "প্রয়োজন হলে আপস্ট্রিম মডেলগুলি ফেচ এবং ক্যাশ করুন", - "autoFetchModelsEnabled": "আপস্ট্রিম মডেল স্বয়ংক্রিয়-ফেচ সক্ষম করা হয়েছে", - "autoFetchModelsDisabled": "আপস্ট্রিম মডেল অটো-ফেচ নিষ্ক্রিয় করা হয়েছে", - "autoFetchModels": "আপস্ট্রিম মডেলগুলি স্বয়ংক্রিয়ভাবে আনুন", - "overridesUpstreamModel": "আপস্ট্রিম ওভাররাইডস", - "autoFetchModelsToggleFailed": "আপস্ট্রিম মডেল অটো-ফেচ টগল করতে ব্যর্থ হয়েছে", - "autoFetchModelsPartialFailure": "কিছু সংযোগ আপডেট হয়েছে, কিন্তু আপস্ট্রিম মডেলের স্বয়ংক্রিয়-ফেচ সব জায়গায় পরিবর্তিত হয়নি", - "overridesUpstreamModelHint": "আপনার সেটিংস এই আপস্ট্রিম মডেলকে অতিক্রম করে", - "resetToUpstreamDefaults": "আপস্ট্রিম ডিফল্টগুলি পুনরুদ্ধার করুন", - "resetToUpstreamDefaultsFailed": "আপস্ট্রিম মডেল ডিফল্টগুলি পুনরুদ্ধার করতে ব্যর্থ হয়েছে", - "resetToUpstreamDefaultsSuccess": "আপস্ট্রিম মডেল ডিফল্টগুলি পুনরুদ্ধার করা হয়েছে" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Settings", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "নিষিদ্ধ কীওয়ার্ড", "customBannedSignalsDesc": "অতিরিক্ত কীওয়ার্ড যা স্থায়ী অ্যাকাউন্ট ব্যান সনাক্তকরণ ট্রিগার করে। বিল্ট-ইন কীওয়ার্ড সর্বদা প্রযোজ্য।", "customBannedSignalsPlaceholder": "যেমন: api key revoked", @@ -7189,6 +7203,7 @@ "configured": "কনফিগার করা হয়েছে", "none": "কোনোটিই নয়", "modelOverrideValuePlaceholder": "সংখ্যাসূচক মান", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "কী ভ্যালু যোগ করুন", "noModelOverrides": "এই মডেলের জন্য কোনো ওভাররাইড কনফিগার করা হয়নি।", "modelOverrideLoadFailed": "মডেল ওভাররাইড লোড করতে ব্যর্থ হয়েছে", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "সংক্ষিপ্ত CJK (文言)", "description": "ক্লাসিক্যাল-চাইনিজ অতি-সংক্ষিপ্ত শৈলী (শুধুমাত্র চাইনিজ ভাষার জন্য উপলব্ধ)।" @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "রাউন্ড-রবিন এবং র‍্যান্ডম কম্বোগুলি প্রথম বার্তার হ্যাশ দ্বারা একটি সম্পূর্ণ কথোপকথনকে একটি সংযোগে পিন করার পরিবর্তে প্রতিটি অনুরোধে একটি ভিন্ন সংযোগে রোটেট করে। মাল্টি-টার্ন চ্যাটের জন্য প্রম্পট-ক্যাশ হিট সংরক্ষণ করতে এটি বন্ধ রাখুন। প্রতি-কম্বো ওভাররাইড অগ্রাধিকার পাবে।", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "ক্রেডেনশিয়াল রিডাকশন", "credentialRedactionDesc": "প্রোভাইডারদের কাছে পাঠানো কনটেক্সট এবং প্রতিক্রিয়া থেকে API কী, টোকেন এবং সিক্রেট রিডাক্ট করুন।", "enableCredentialRedaction": "ক্রেডেনশিয়াল রিডাকশন সক্রিয় করুন", @@ -8600,6 +8623,27 @@ }, "enableTitle": "ইঞ্জিনটি সক্রিয় করুন", "enableDescription": "স্ট্যাকের সবার শেষে চলে (RTK/Caveman টেক্সট পরিষ্কার করার পর, OmniGlyph বাকি অংশকে ইমেজে রূপান্তর করে) এবং omniglyph মোডের মাধ্যমে এককভাবেও চলে। এটি একটি প্রিভিউ এবং এন্ড-টু-এন্ড যাচাইকরণ সম্পন্ন না হওয়া পর্যন্ত ডিফল্টরূপে বন্ধ থাকবে।", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "সংরক্ষিত হয়েছে।", "saveFailed": "সংরক্ষণ করা যায়নি।", "enableAria": "OmniGlyph ইঞ্জিনটি সক্রিয় করুন", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "সর্বাধিক", "grokAutoTopUpMonth": "মাস", "grokAdditionalCredits": "অতিরিক্ত ক্রেডিটস", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "প্রথম টোকেন", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index f5cd6c68bf..5e95c8f357 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Vizualizace časové osy požadavků", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "otevřít", "close": "zavřít" }, - "noResults": "Žádné výsledky", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Žádné výsledky" }, "webhooks": { "title": "Webhooky", @@ -1739,8 +1739,8 @@ "quotaShare": "Podíl kvóty", "discovery": "Průzkum", "freeProviderRankings": "Žebříčky bezplatných poskytovatelů", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Bezplatné tarify", "gamification": "Gamifikace", "leaderboard": "Žebříček", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Podpora tohoto poskytovatele byla ukončena", "riskNotice": { "title": "Než budete pokračovat", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Poskytovatel s upozorněními k použití — klikněte pro podrobnosti", "oauth": "Tento poskytovatel používá vaši oficiální relaci/OAuth produktu, což není autorizováno pro použití jako proxy/router. Nedoporučujeme intenzivní používání autonomních agentů (styl OpenCloud, dlouhé vícekrokové toky, velké dávky) — upstream může reagovat omezením nebo zablokováním účtu. Používejte na vlastní riziko.", "webCookie": "Tento poskytovatel se autentizuje pomocí souborů cookie vaší webové relace. Služba upstream může relaci kdykoli zneplatnit, což bude vyžadovat opětovné přihlášení. Nedoporučuje se pro dlouhé bezobslužné operace. Používejte na vlastní riziko.", @@ -5107,9 +5111,9 @@ "cancel": "Zrušit" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Zakázáno", "enableProvider": "Povolit poskytovatele", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Přeskakování {count} existujících modelů", "autoSync": "Automatická synchronizace", "autoSyncShort": "Synchronizace", + "autoFetchModels": "Automaticky načíst modely z upstreamu", + "autoFetchModelsTooltip": "Načíst a uložit upstream modely, když je to potřeba", + "autoFetchModelsEnabled": "Automatické načítání modelu upstream je povoleno", + "autoFetchModelsDisabled": "Automatické načítání modelu upstream je zakázáno", + "autoFetchModelsToggleFailed": "Nepodařilo se přepnout automatické načítání modelu upstream", + "autoFetchModelsPartialFailure": "Některé připojení byly aktualizovány, ale automatické načítání modelu upstream nebylo změněno všude", + "overridesUpstreamModel": "Přepisuje upstream", + "overridesUpstreamModelHint": "Vaše nastavení přepisují tento upstream model", + "resetToUpstreamDefaults": "Obnovit výchozí hodnoty upstream", + "resetToUpstreamDefaultsSuccess": "Obnoveny výchozí modely upstream", + "resetToUpstreamDefaultsFailed": "Nepodařilo se obnovit výchozí hodnoty modelu upstream", "autoSyncTooltip": "Automaticky obnovuje seznam modelů každých 24 hodin (lze nastavit přes MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Automatická synchronizace povolena – modely se budou pravidelně obnovovat", "autoSyncDisabled": "Automatická synchronizace zakázána", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Přepisovat nativní volání nástroje web_fetch na /v1/web/fetch v OmniRoute.", "interceptionLoadError": "Nepodařilo se načíst nastavení zachytávání: {error}", "interceptionSaveError": "Nepodařilo se uložit nastavení zachytávání: {error}", - "ccAliasSectionTitle": "Expose v Claude Code (claude/…)", - "ccAliasSectionHint": "Inzerujte modely tohoto poskytovatele pod claude/<provider>/<model> zrcadlovými ID, aby mohl objevovací model brány Claude Code je zobrazit. Ve výchozím nastavení vypnuto — povolení tohoto zdvojnásobí záznamy v katalogu pro všechny klienty.", - "ccAliasProviderLevelLabel": "Výchozí poskytovatel", - "ccAliasModelOverridesLabel": "Přepsání na úrovni modelu", - "ccAliasModelOverrideAriaLabel": "Přepsání pro {modelId}", - "ccAliasStateInherit": "Dědit", - "ccAliasStateOn": "Zapnuto", - "ccAliasStateOff": "Vypnuto", - "ccAliasAddModelPlaceholder": "ID modelu (např. gpt-4o)", - "ccAliasAddModelButton": "Přidat přepsání", - "ccAliasLoadError": "Nepodařilo se načíst nastavení discovery-alias: {error}", - "ccAliasSaveError": "Nepodařilo se uložit nastavení discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream hlavičky", "compatUpstreamHeadersHint": "Nastavení s vysokými oprávněními — stejná úroveň důvěryhodnosti jako při úpravách přihlašovacích údajů API poskytovatele; měli by jej používat pouze důvěryhodní administrátoři. Sloučeno poté, co OmniRoute přidá ověření z klíče API poskytovatele. Pokud vlastní záhlaví používá stejný název jako existující (např. Authorization), vaše hodnota zcela nahradí automaticky vygenerované záhlaví (včetně tokenu Bearer) — upstream vidí pouze to, co jste zadali, nikoli klíč z nastavení. Nesprávná nastavení může způsobit chybu 401 nebo nefunkční upstream ověření. Jeden řádek na jedno záhlaví (např. extra ověření pro některé brány). Pro náhled najděte na hodnotu nebo ji označte. Uloží se při odklonu, kliknutí mimo nebo zavření tohoto panelu.", "compatUpstreamHeaderName": "Název hlavičky", @@ -6194,7 +6209,7 @@ "galadriel": "Připojte Galadriel pomocí API klíče.", "predibase": "Bezplatný zkušební kredit 25 $ (platnost 30 dní)", "chenzk": "Brána kompatibilní s OpenAI s živým katalogem modelů na chenzk.top.", - "freepik": "Generujte obrázky pomocí Mystic API od Freepik.", + "magnific": "Generujte obrázky pomocí Mystic API od Freepik.", "freetheai": "Bezplatná brána kompatibilní s OpenAI s podporou passthrough modelů.", "g4f-gemini": "Bezplatná reverzní proxy g4f.space bez klíče pro Gemini, omezená na 5 požadavků za minutu.", "g4f-groq": "Bezplatná reverzní proxy g4f.space bez klíče pro Groq, omezená na 5 požadavků za minutu.", @@ -6209,6 +6224,7 @@ "claude": "Připojte Claude Code pomocí stávajícího toku OAuth.", "cline": "Připojte Cline pomocí stávajícího toku OAuth.", "cursor": "Připojte Cursor IDE pomocí stávajícího toku OAuth.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Připojte GitHub Copilot pomocí stávajícího toku OAuth.", "gitlab-duo": "Aplikace OAuth s rozsahy (scopes) ai_features + read_user. Nakonfigurujte GITLAB_DUO_OAUTH_CLIENT_ID a volitelně GITLAB_DUO_OAUTH_CLIENT_SECRET na této instanci OmniRoute.", "kilocode": "Připojte Kilo Code pomocí stávajícího toku OAuth.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Přítel open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference podporuje OmniRoute jako přítel open source", "kimiPartnerLinkNote": "Partnerský odkaz — podporuje OmniRoute bez jakýchkoli dalších nákladů pro vás", + "codexQuotaPools": "Fondy kvót Codex", + "codexPoolAvailable": "Dostupný", + "codexPoolPartiallyLimited": "Částečně omezený", + "codexPoolFullyLimited": "Plně omezený", + "codexPoolLimited": "Omezeno: {count}", + "codexPoolQuotaExhausted": "Kvóta vyčerpána", + "codexPoolCoolingDown": "Probíhá čekací lhůta", + "codexPoolUsed": "využito", + "codexPoolUntil": "Do {value}", "anonymousFallbackTitle": "Anonymní záložní řešení", "anonymousFallbackDesc": "Když jsou všechny nakonfigurované připojení vyčerpány (kvóta, kredity nebo expirace), dočasně použijte bezklíčovou úroveň tohoto poskytovatele. Vypněte, abyste tohoto poskytovatele přeskočili místo odesílání anonymních požadavků — doporučeno, když bezklíčová úroveň je odmítá (401).", "anonymousFallbackEnabled": "Anonymní záložní možnost povolena pro {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Nastavení koncového bodu uloženého modelu", "searchByModelAria": "Hledat podle modelu", "selectSupportedEndpoint": "Vyberte alespoň jeden podporovaný koncový bod", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Automaticky načíst modely z upstreamu", - "autoFetchModelsTooltip": "Načíst a uložit upstream modely, když je to potřeba", - "autoFetchModelsDisabled": "Automatické načítání modelu upstream je zakázáno", - "autoFetchModelsEnabled": "Automatické načítání modelu upstream je povoleno", - "overridesUpstreamModel": "Přepisuje upstream", - "autoFetchModelsToggleFailed": "Nepodařilo se přepnout automatické načítání modelu upstream", - "overridesUpstreamModelHint": "Vaše nastavení přepisují tento upstream model", - "autoFetchModelsPartialFailure": "Některé připojení byly aktualizovány, ale automatické načítání modelu upstream nebylo změněno všude", - "resetToUpstreamDefaultsSuccess": "Obnoveny výchozí modely upstream", - "resetToUpstreamDefaultsFailed": "Nepodařilo se obnovit výchozí hodnoty modelu upstream", - "resetToUpstreamDefaults": "Obnovit výchozí hodnoty upstream" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Nastavení", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Trvale označí připojení poskytovatele jako deaktivovaná, pokud vrátí specifické signály zablokování (např. HTTP 403 'verify your account'). Tím je odstraní z rotace komb.", "autoDisableThreshold": "Prahová hodnota zablokování", "autoDisableThresholdDesc": "Počet po sobě jdoucích signálů zablokování před trvalou deaktivací.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Zakázaná klíčová slova", "customBannedSignalsDesc": "Další klíčová slova, která spouštějí detekci trvalého zablokování účtu. Vestavěná klíčová slova platí vždy.", "customBannedSignalsPlaceholder": "např. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "nakonfigurováno", "none": "Žádné", "modelOverrideValuePlaceholder": "Číselná hodnota", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Přidat klíč-hodnotu", "noModelOverrides": "Pro tento model nejsou nakonfigurována žádná přepsání.", "modelOverrideLoadFailed": "Nepodařilo se načíst přepsání modelů", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Stručné CJK (文言)", "description": "Klasický čínský ultra stručný styl (k dispozici pouze pro čínštinu)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Kombinace round-robin a náhodného výběru rotují na jiné připojení při každém požadavku, místo aby připnuly celou konverzaci k jednomu připojení podle hashe první zprávy. Ponechte vypnuté, chcete-li zachovat zásahy v mezipaměti promptů pro vícekrokové chaty. Přepsání pro jednotlivé kombinace mají přednost.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Redigování přihlašovacích údajů", "credentialRedactionDesc": "Redigovat klíče API, tokeny a tajné klíče z kontextu odesílaného poskytovatelům a z odpovědí.", "enableCredentialRedaction": "Povolit redigování přihlašovacích údajů", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Povolit engine", "enableDescription": "Spouští se jako poslední v zásobníku (poté, co RTK/Caveman vyčistí text a OmniGlyph převede zbytek na obrázky) a běží také samostatně v režimu omniglyph. Toto je náhled a ve výchozím nastavení zůstává vypnutý, dokud nebude dokončeno end-to-end ověření.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Uloženo.", "saveFailed": "Nepodařilo se uložit.", "enableAria": "Povolit engine OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "měsíc", "grokAdditionalCredits": "Další kredity", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Zapisovač", "proxyTab": "Proxy", "budgetManagement": "Správa rozpočtu", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "První token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 0bbdd87e7f..fdc9a5fc89 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Visuel anmodnings tidslinje", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "åben", "close": "luk" }, - "noResults": "Ingen resultater", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Ingen resultater" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "Kvoteandel", "discovery": "Opdagelse", "freeProviderRankings": "Rangliste over gratis udbydere", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Gratis niveauer", "gamification": "Gamificering", "leaderboard": "Leaderboard", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Denne udbyder er blevet udfaset", "riskNotice": { "title": "Før du fortsætter", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Udbyder med forbehold for brug — klik for detaljer", "oauth": "Denne udbyder bruger din officielle produktsession/OAuth, som ikke er godkendt til proxy-/routerbrug. Vi anbefaler ikke intensiv brug af autonome agenter (OpenCloud-stil, lange flertrinsforløb, store batches) — upstream-tjenesten kan reagere ved at begrænse eller spærre kontoen. Brug på eget ansvar.", "webCookie": "Denne udbyder godkender via dine websessionscookies. Upstream-tjenesten kan til enhver tid gøre sessionen ugyldig, hvilket kræver, at du logger ind igen. Anbefales ikke til lange uovervågede handlinger. Brug på eget ansvar.", @@ -5107,9 +5111,9 @@ "cancel": "Annuller" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Deaktiveret", "enableProvider": "Aktiver udbyder", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Springer {count} eksisterende modeller over", "autoSync": "Auto-synkronisering", "autoSyncShort": "Synkronisering", + "autoFetchModels": "Auto-hent upstream modeller", + "autoFetchModelsTooltip": "Hent og cache upstream-modeller, når det er nødvendigt", + "autoFetchModelsEnabled": "Opstrømsmodel auto-hentning aktiveret", + "autoFetchModelsDisabled": "Opstrømsmodel auto-hentning deaktiveret", + "autoFetchModelsToggleFailed": "Mislykkedes at skifte upstream model auto-fetch", + "autoFetchModelsPartialFailure": "Nogle forbindelser blev opdateret, men upstream-model auto-fetch blev ikke ændret overalt", + "overridesUpstreamModel": "Overskriver upstream", + "overridesUpstreamModelHint": "Dine indstillinger overskriver denne upstream-model", + "resetToUpstreamDefaults": "Gendan upstream standardindstillinger", + "resetToUpstreamDefaultsSuccess": "Gendannet upstream model standardindstillinger", + "resetToUpstreamDefaultsFailed": "Mislykkedes med at gendanne standardindstillinger for upstream-modellen", "autoSyncTooltip": "Opdater modellisten automatisk hver 24. time (kan konfigureres via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Automatisk synkronisering aktiveret - modellerne opdateres med jævne mellemrum", "autoSyncDisabled": "Automatisk synkronisering deaktiveret", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Omskriv oprindelige web_fetch-værktøjskald til OmniRoutes /v1/web/fetch.", "interceptionLoadError": "Kunne ikke indlæse indstillinger for opsnapning: {error}", "interceptionSaveError": "Kunne ikke gemme indstillinger for opsnapning: {error}", - "ccAliasSectionTitle": "Eksponer i Claude Code (claude/…)", - "ccAliasSectionHint": "Reklamer for denne udbyders modeller under claude/<provider>/<model> spejl-id'er, så Claude Code's gateway modelopdagelse kan liste dem. Slået fra som standard — aktivering af dette fordobler katalogindgange for alle klienter.", - "ccAliasProviderLevelLabel": "Udbyder standard", - "ccAliasModelOverridesLabel": "Per-model overskrivninger", - "ccAliasModelOverrideAriaLabel": "Overskrivning for {modelId}", - "ccAliasStateInherit": "Arv", - "ccAliasStateOn": "Tændt", - "ccAliasStateOff": "Slukket", - "ccAliasAddModelPlaceholder": "Model id (f.eks. gpt-4o)", - "ccAliasAddModelButton": "Tilføj overskrivning", - "ccAliasLoadError": "Kunne ikke indlæse discovery-alias indstillinger: {error}", - "ccAliasSaveError": "Fejl ved gemning af discovery-alias indstilling: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Forbind Galadriel med en API-nøgle.", "predibase": "$25 gratis prøvekredit (30 dages gyldighed)", "chenzk": "OpenAI-kompatibel gateway med et live modelkatalog på chenzk.top.", - "freepik": "Generer billeder med Freepiks Mystic API.", + "magnific": "Generer billeder med Freepiks Mystic API.", "freetheai": "Gratis OpenAI-kompatibel gateway med understøttelse af passthrough-modeller.", "g4f-gemini": "Gratis nøglefri g4f.space reverse proxy til Gemini, begrænset til 5 anmodninger pr. minut.", "g4f-groq": "Gratis nøglefri g4f.space reverse proxy til Groq, begrænset til 5 anmodninger pr. minut.", @@ -6209,6 +6224,7 @@ "claude": "Forbind Claude Code med det eksisterende OAuth-flow.", "cline": "Forbind Cline med det eksisterende OAuth-flow.", "cursor": "Forbind Cursor IDE med det eksisterende OAuth-flow.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Forbind GitHub Copilot med det eksisterende OAuth-flow.", "gitlab-duo": "OAuth-applikation med ai_features + read_user-scopes. Konfigurer GITLAB_DUO_OAUTH_CLIENT_ID og valgfrit GITLAB_DUO_OAUTH_CLIENT_SECRET på denne OmniRoute-instans.", "kilocode": "Forbind Kilo Code med det eksisterende OAuth-flow.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Open source-ven", "cheaperInferenceSupporterTooltip": "Cheaper Inference støtter OmniRoute som open source-ven", "kimiPartnerLinkNote": "Partnerlink — understøtter OmniRoute uden ekstra omkostninger for dig", + "codexQuotaPools": "Codex-kvotepuljer", + "codexPoolAvailable": "Tilgængelig", + "codexPoolPartiallyLimited": "Delvist begrænset", + "codexPoolFullyLimited": "Fuldt begrænset", + "codexPoolLimited": "{count} begrænset", + "codexPoolQuotaExhausted": "Kvoten er opbrugt", + "codexPoolCoolingDown": "I nedkølingsperiode", + "codexPoolUsed": "brugt", + "codexPoolUntil": "Indtil {value}", "anonymousFallbackTitle": "Anonym fallback", "anonymousFallbackDesc": "Når alle konfigurerede forbindelser er udtømt (kvote, kreditter eller udløb), brug midlertidigt denne udbyders nøgleløse niveau. Sluk for at springe denne udbyder over i stedet for at sende anonyme anmodninger - anbefales når det nøgleløse niveau afviser dem (401).", "anonymousFallbackEnabled": "Anonym fallback aktiveret for {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Indstillinger for gemt model endpoint", "searchByModelAria": "Søg efter model", "selectSupportedEndpoint": "Vælg mindst én understøttet endpoint", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsDisabled": "Opstrømsmodel auto-hentning deaktiveret", - "autoFetchModelsTooltip": "Hent og cache upstream-modeller, når det er nødvendigt", - "autoFetchModels": "Auto-hent upstream modeller", - "autoFetchModelsEnabled": "Opstrømsmodel auto-hentning aktiveret", - "autoFetchModelsToggleFailed": "Mislykkedes at skifte upstream model auto-fetch", - "autoFetchModelsPartialFailure": "Nogle forbindelser blev opdateret, men upstream-model auto-fetch blev ikke ændret overalt", - "overridesUpstreamModelHint": "Dine indstillinger overskriver denne upstream-model", - "overridesUpstreamModel": "Overskriver upstream", - "resetToUpstreamDefaultsSuccess": "Gendannet upstream model standardindstillinger", - "resetToUpstreamDefaultsFailed": "Mislykkedes med at gendanne standardindstillinger for upstream-modellen", - "resetToUpstreamDefaults": "Gendan upstream standardindstillinger" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Indstillinger", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Blokerede nøgleord", "customBannedSignalsDesc": "Yderligere nøgleord, der udløser registrering af permanent kontoudelukkelse. Indbyggede nøgleord gælder altid.", "customBannedSignalsPlaceholder": "f.eks. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "konfigureret", "none": "Ingen", "modelOverrideValuePlaceholder": "Numerisk værdi", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Tilføj nøgleværdi", "noModelOverrides": "Ingen tilsidesættelser konfigureret for denne model.", "modelOverrideLoadFailed": "Kunne ikke indlæse modeltilsidesættelser", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Kortfattet CJK (文言)", "description": "Klassisk kinesisk ultra-kortfattet stil (kun tilgængelig for kinesisk)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Round-robin- og tilfældige kombinationer skifter til en anden forbindelse ved hver anmodning i stedet for at fastlåse en hel samtale til én forbindelse via den første meddelelses hash. Lad den være slået fra for at bevare prompt-cache-hits ved samtaler med flere ture. Tilsidesættelser pr. kombination har forrang.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Maskering af legitimationsoplysninger", "credentialRedactionDesc": "Masker API-nøgler, tokens og hemmeligheder fra kontekst sendt til udbydere og fra svar.", "enableCredentialRedaction": "Aktivér maskering af legitimationsoplysninger", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Aktiver motoren", "enableDescription": "Kører sidst i stakken (efter RTK/Caveman renser teksten, konverterer OmniGlyph resten til billeder) og kører også selvstændigt via omniglyph-tilstand. Dette er en forhåndsvisning og forbliver deaktiveret som standard, indtil end-to-end-validering er fuldført.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Gemt.", "saveFailed": "Kunne ikke gemme.", "enableAria": "Aktiver OmniGlyph-motoren", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "maksimum", "grokAutoTopUpMonth": "måned", "grokAdditionalCredits": "Yderligere Credits", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Fuldmagt", "budgetManagement": "Budgetstyring", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Første token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index c2eb87f956..2b4c363146 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Visuelle Anforderungszeitleiste", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "öffnen", "close": "schließen" }, - "noResults": "Keine Ergebnisse", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Keine Ergebnisse" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "Kontingentanteil", "discovery": "Discovery", "freeProviderRankings": "Rangliste kostenloser Anbieter", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Kostenlose Tarife", "gamification": "Gamification", "leaderboard": "Bestenliste", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Dieser Anbieter ist veraltet", "riskNotice": { "title": "Vor dem Fortfahren", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Anbieter mit Nutzungseinschränkungen — für Details klicken", "oauth": "Dieser Anbieter verwendet Ihre offizielle Produktsitzung/OAuth, die nicht für die Proxy-/Router-Nutzung autorisiert ist. Wir empfehlen keine intensive Nutzung durch autonome Agenten (im OpenCloud-Stil, lange mehrstufige Abläufe, große Batches) — der Upstream-Anbieter kann darauf reagieren, indem er das Konto einschränkt oder sperrt. Nutzung auf eigene Gefahr.", "webCookie": "Dieser Anbieter authentifiziert sich über Ihre Web-Sitzungscookies. Der Upstream-Dienst kann die Sitzung jederzeit ungültig machen, sodass Sie sich erneut anmelden müssen. Nicht empfohlen für lange unbeaufsichtigte Vorgänge. Nutzung auf eigene Gefahr.", @@ -5107,9 +5111,9 @@ "cancel": "Abbrechen" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Deaktiviert", "enableProvider": "Anbieter aktivieren", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Überspringe {count} vorhandene Modelle", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "Automatisches Abrufen von Upstream-Modellen", + "autoFetchModelsTooltip": "Abrufen und Zwischenspeichern von Upstream-Modellen bei Bedarf", + "autoFetchModelsEnabled": "Upstream-Modell Auto-Fetch aktiviert", + "autoFetchModelsDisabled": "Auto-Abholung des Upstream-Modells deaktiviert", + "autoFetchModelsToggleFailed": "Fehler beim Umschalten des automatischen Abrufs des Upstream-Modells", + "autoFetchModelsPartialFailure": "Einige Verbindungen wurden aktualisiert, aber das automatische Abrufen des upstream-Modells wurde nicht überall geändert.", + "overridesUpstreamModel": "Überschreibt upstream", + "overridesUpstreamModelHint": "Ihre Einstellungen überschreiben dieses übergeordnete Modell", + "resetToUpstreamDefaults": "Ursprüngliche Standardeinstellungen wiederherstellen", + "resetToUpstreamDefaultsSuccess": "Ursprüngliche Standardwerte des Upstream-Modells wiederhergestellt", + "resetToUpstreamDefaultsFailed": "Wiederherstellung der Standardwerte des upstream-Modells fehlgeschlagen", "autoSyncTooltip": "Modellliste automatisch alle 24 Stunden aktualisieren (konfigurierbar über MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-Sync aktiviert — Modelle werden regelmäßig aktualisiert", "autoSyncDisabled": "Auto-Sync deaktiviert", @@ -5439,17 +5454,17 @@ "interceptionLoadError": "Fehler beim Laden der Interzeptionseinstellungen: {error}", "interceptionSaveError": "Fehler beim Speichern der Interzeptionseinstellungen: {error}", "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", - "ccAliasSectionHint": "Bewerben Sie die Modelle dieses Anbieters unter claude/<provider>/<model> Spiegel-IDs, damit das Gateway-Modellentdeckung von Claude Code sie auflisten kann. Standardmäßig deaktiviert — das Aktivieren verdoppelt die Katalogeinträge für alle Kunden.", - "ccAliasProviderLevelLabel": "Anbieter standardmäßig", - "ccAliasModelOverridesLabel": "Pro-Modell-Überschreibungen", - "ccAliasModelOverrideAriaLabel": "Überschreibung für {modelId}", - "ccAliasStateInherit": "Erben", - "ccAliasStateOn": "Ein", - "ccAliasStateOff": "Aus", - "ccAliasAddModelPlaceholder": "Modell-ID (z. B. gpt-4o)", - "ccAliasAddModelButton": "Überschreibung hinzufügen", - "ccAliasLoadError": "Fehler beim Laden der discovery-alias-Einstellungen: {error}", - "ccAliasSaveError": "Fehler beim Speichern der discovery-alias-Einstellung: {error}", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Verbinden Sie Galadriel mit einem API-Schlüssel.", "predibase": "$25 kostenloses Testguthaben (30 Tage Gültigkeit)", "chenzk": "OpenAI-kompatibles Gateway mit einem Live-Modellkatalog unter chenzk.top.", - "freepik": "Generieren Sie Bilder mit der Mystic-API von Freepik.", + "magnific": "Generieren Sie Bilder mit Magnific Mystic.", "freetheai": "Kostenloses OpenAI-kompatibles Gateway mit Passthrough-Modellunterstützung.", "g4f-gemini": "Kostenloser schlüsselloser g4f.space-Reverse-Proxy zu Gemini, begrenzt auf 5 Anfragen pro Minute.", "g4f-groq": "Kostenloser schlüsselloser g4f.space-Reverse-Proxy zu Groq, begrenzt auf 5 Anfragen pro Minute.", @@ -6209,6 +6224,7 @@ "claude": "Claude Code mit dem bestehenden OAuth-Flow verbinden.", "cline": "Cline mit dem bestehenden OAuth-Flow verbinden.", "cursor": "Cursor IDE mit dem bestehenden OAuth-Flow verbinden.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "GitHub Copilot mit dem bestehenden OAuth-Flow verbinden.", "gitlab-duo": "OAuth-Anwendung mit den Scopes ai_features + read_user. Konfigurieren Sie GITLAB_DUO_OAUTH_CLIENT_ID und optional GITLAB_DUO_OAUTH_CLIENT_SECRET auf dieser OmniRoute-Instanz.", "kilocode": "Kilo Code mit dem bestehenden OAuth-Flow verbinden.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Open-Source-Freund", "cheaperInferenceSupporterTooltip": "Cheaper Inference unterstützt OmniRoute als Open-Source-Freund", "kimiPartnerLinkNote": "Partnerlink — unterstützt OmniRoute ohne zusätzliche Kosten für Sie", + "codexQuotaPools": "Codex-Kontingentpools", + "codexPoolAvailable": "Verfügbar", + "codexPoolPartiallyLimited": "Teilweise eingeschränkt", + "codexPoolFullyLimited": "Vollständig eingeschränkt", + "codexPoolLimited": "{count} eingeschränkt", + "codexPoolQuotaExhausted": "Kontingent aufgebraucht", + "codexPoolCoolingDown": "In Abklingzeit", + "codexPoolUsed": "verwendet", + "codexPoolUntil": "Bis {value}", "anonymousFallbackTitle": "Anonymer Fallback", "anonymousFallbackDesc": "Wenn alle konfigurierten Verbindungen erschöpft sind (Kontingent, Guthaben oder Ablauf), verwenden Sie vorübergehend die schlüssellose Stufe dieses Anbieters. Deaktivieren Sie dies, um diesen Anbieter zu überspringen, anstatt anonyme Anfragen zu senden – empfohlen, wenn die schlüssellose Stufe diese ablehnt (401).", "anonymousFallbackEnabled": "Anonymer Fallback für {provider} aktiviert", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Einstellungen für den gespeicherten Modell-Endpunkt", "searchByModelAria": "Nach Modell suchen", "selectSupportedEndpoint": "Wählen Sie mindestens einen unterstützten Endpunkt aus", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Automatisches Abrufen von Upstream-Modellen", - "autoFetchModelsEnabled": "Upstream-Modell Auto-Fetch aktiviert", - "autoFetchModelsDisabled": "Auto-Abholung des Upstream-Modells deaktiviert", - "autoFetchModelsTooltip": "Abrufen und Zwischenspeichern von Upstream-Modellen bei Bedarf", - "overridesUpstreamModel": "Überschreibt upstream", - "autoFetchModelsToggleFailed": "Fehler beim Umschalten des automatischen Abrufs des Upstream-Modells", - "autoFetchModelsPartialFailure": "Einige Verbindungen wurden aktualisiert, aber das automatische Abrufen des upstream-Modells wurde nicht überall geändert.", - "overridesUpstreamModelHint": "Ihre Einstellungen überschreiben dieses übergeordnete Modell", - "resetToUpstreamDefaultsSuccess": "Ursprüngliche Standardwerte des Upstream-Modells wiederhergestellt", - "resetToUpstreamDefaults": "Ursprüngliche Standardeinstellungen wiederherstellen", - "resetToUpstreamDefaultsFailed": "Wiederherstellung der Standardwerte des upstream-Modells fehlgeschlagen" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Einstellungen", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Gesperrte Keywords", "customBannedSignalsDesc": "Zusätzliche Keywords, die die Erkennung einer dauerhaften Kontosperrung auslösen. Integrierte Keywords gelten immer.", "customBannedSignalsPlaceholder": "z. B. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "konfiguriert", "none": "Keine", "modelOverrideValuePlaceholder": "Numerischer Wert", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Schlüssel-Wert hinzufügen", "noModelOverrides": "Keine Overrides für dieses Modell konfiguriert.", "modelOverrideLoadFailed": "Modell-Overrides konnten nicht geladen werden", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Knappe CJK (文言)", "description": "Klassisch-chinesischer, extrem knapper Stil (nur für Chinesisch verfügbar)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Round-Robin- und Zufallskombinationen wechseln bei jeder Anfrage zu einer anderen Verbindung, anstatt eine gesamte Konversation über den Hash der ersten Nachricht an eine Verbindung zu binden. Deaktiviert lassen, um Prompt-Cache-Treffer für Multi-Turn-Chats zu erhalten. Überschreibungen pro Kombination haben Vorrang.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Schwärzung von Anmeldedaten", "credentialRedactionDesc": "API-Schlüssel, Token und Geheimnisse aus dem an Anbieter gesendeten Kontext und aus Antworten schwärzen.", "enableCredentialRedaction": "Schwärzung von Anmeldedaten aktivieren", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Engine aktivieren", "enableDescription": "Wird als Letztes im Stack ausgeführt (nachdem RTK/Caveman den Text bereinigt hat, konvertiert OmniGlyph den Rest in Bilder) und läuft auch eigenständig im omniglyph-Modus. Dies ist eine Vorschau und bleibt standardmäßig deaktiviert, bis die End-to-End-Validierung abgeschlossen ist.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Gespeichert.", "saveFailed": "Konnte nicht gespeichert werden.", "enableAria": "OmniGlyph-Engine aktivieren", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "Monat", "grokAdditionalCredits": "Zusätzliche Credits", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Stellvertreter", "budgetManagement": "Budgetverwaltung", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Erster Token", @@ -12695,6 +12749,10 @@ "ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE": { "description": "Mehrere Verbindungen für jeden Kompatibilitätsknoten zulassen." }, + "DISABLE_CONTEXT_WINDOW_CHECKS": { + "label": "Kontextfensterprüfungen deaktivieren", + "description": "Lokale Kontextfenster- und Maximaleingabetoken-Prüfung von OmniRoute für direkte Einzelmodell-Anfragen überspringen. Upstream-Anbieter erzwingen weiterhin ihre tatsächlichen Grenzen. Prompt-Komprimierung und Ausgabetoken-Grenzen bleiben aktiv." + }, "RESPONSES_PASSTHROUGH_DROP_COMMENTARY": { "description": "Interne Ausgabeelemente der Kommentarphase aus den Passthrough-Streams der Responses-API entfernen, bevor sie an Clients weitergeleitet werden. Deaktivieren Sie dieses Flag, um rohe Upstream-Kommentare zu erhalten." }, @@ -13192,7 +13250,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Angebote", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13335,6 +13393,7 @@ } }, "featureFlagCapabilityFilterEnabledDescription": "Lehnen Sie Anfragen ab, bevor sie versendet werden, wenn das Zielmodell über die erforderlichen Funktionen (Vision, Werkzeuge, strukturierte Ausgabe, Kontextfenster) nicht verfügt. Schützt direkte Einzelanbieteranfragen, die den Kombo-Schicht-Kompatibilitätsfilter umgehen.", + "featureFlagDisableContextWindowChecksDescription": "Lokale Kontextfenster- und Maximaleingabetoken-Prüfung von OmniRoute für direkte Einzelmodell-Anfragen überspringen. Upstream-Anbieter erzwingen weiterhin ihre tatsächlichen Grenzen. Prompt-Komprimierung und Ausgabetoken-Grenzen bleiben aktiv.", "publicSystem": { "notFound": { "title": "Seite nicht gefunden", @@ -13732,36 +13791,36 @@ "trialDays": "{days, plural, one {# Tag} other {# Tage}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index f8683269c4..92b0e725b4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1800,6 +1800,11 @@ "updateStarted": "Update started...", "reloadingPageAutomatically": "Reloading page automatically...", "providerTopology": "Provider Topology", + "recentRequests": "Recent Requests", + "recentRequestsEmpty": "No requests yet.", + "recentRequestsModel": "Model", + "recentRequestsTokens": "In / Out", + "recentRequestsWhen": "When", "downloadDmg": "Download DMG (macOS)", "downloadDmgDescription": "A new version of the OmniRoute desktop app is available. Please download and install the macOS DMG installer to update (current: v{version}).", "downloadExe": "Download EXE (Windows)", @@ -3606,6 +3611,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 14 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -6205,7 +6214,7 @@ "galadriel": "Connect Galadriel with an API key.", "predibase": "$25 free trial credits (30-day validity)", "chenzk": "OpenAI-compatible gateway with a live model catalog at chenzk.top.", - "freepik": "Generate images with Freepik's Mystic API.", + "magnific": "Generate images with Magnific Mystic.", "freetheai": "Free OpenAI-compatible gateway with passthrough model support.", "g4f-gemini": "Free no-key g4f.space reverse proxy to Gemini, limited to 5 requests per minute.", "g4f-groq": "Free no-key g4f.space reverse proxy to Groq, limited to 5 requests per minute.", @@ -6220,6 +6229,7 @@ "claude": "Connect Claude Code with the existing OAuth flow.", "cline": "Connect Cline with the existing OAuth flow.", "cursor": "Connect Cursor IDE with the existing OAuth flow.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Connect GitHub Copilot with the existing OAuth flow.", "gitlab-duo": "OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance.", "kilocode": "Connect Kilo Code with the existing OAuth flow.", @@ -6282,6 +6292,15 @@ "cheaperInferenceSupporterBadge": "Open Source Friend", "cheaperInferenceSupporterTooltip": "Cheaper Inference backs OmniRoute as an Open Source Friend", "kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you", + "codexQuotaPools": "Codex quota pools", + "codexPoolAvailable": "Available", + "codexPoolPartiallyLimited": "Partially limited", + "codexPoolFullyLimited": "Fully limited", + "codexPoolLimited": "{count} limited", + "codexPoolQuotaExhausted": "Quota exhausted", + "codexPoolCoolingDown": "Cooling down", + "codexPoolUsed": "used", + "codexPoolUntil": "Until {value}", "anonymousFallbackTitle": "Anonymous fallback", "anonymousFallbackDesc": "When all configured connections are exhausted (quota, credits, or expiry), temporarily use this provider's keyless tier. Turn off to skip this provider instead of sending anonymous requests — recommended when the keyless tier rejects them (401).", "anonymousFallbackEnabled": "Anonymous fallback enabled for {provider}", @@ -7189,6 +7208,7 @@ "configured": "configured", "none": "None", "modelOverrideValuePlaceholder": "Numeric value", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Add key value", "noModelOverrides": "No overrides configured for this model.", "modelOverrideLoadFailed": "Failed to load model overrides", @@ -9098,6 +9118,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "month", "grokAdditionalCredits": "Additional Credits", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12724,6 +12754,10 @@ "ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE": { "description": "Allow multiple connections for each compatibility node." }, + "DISABLE_CONTEXT_WINDOW_CHECKS": { + "label": "Disable Context Window Checks", + "description": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active." + }, "RESPONSES_PASSTHROUGH_DROP_COMMENTARY": { "description": "Remove internal commentary-phase output items from Responses API passthrough streams before forwarding them to clients. Disable this flag to receive raw upstream commentary." }, @@ -13364,6 +13398,7 @@ } }, "featureFlagCapabilityFilterEnabledDescription": "Reject requests before dispatch when the target model lacks required capabilities (vision, tools, structured output, context window). Protects direct single-provider requests that bypass the combo-layer compatibility filter.", + "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", "publicSystem": { "notFound": { "title": "Page not found", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 7a9635ca4d..a6b357e222 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Línea de tiempo de solicitudes visuales", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "abrir", "close": "cerrar" }, - "noResults": "Sin resultados", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Sin resultados" }, "webhooks": { "title": "Ganchos web", @@ -1739,8 +1739,8 @@ "quotaShare": "Quota Share", "discovery": "Discovery", "freeProviderRankings": "Free Provider Rankings", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Free Tiers", "gamification": "Gamification", "leaderboard": "Leaderboard", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Este proveedor ha quedado obsoleto.", "riskNotice": { "title": "Before continuing", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Provider with usage caveats — click for details", "oauth": "This provider uses your official product session/OAuth, which is not authorized for proxy/router use. We don't recommend intensive autonomous agent usage (OpenCloud-style, long multi-step flows, large batches) — the upstream may react by restricting or banning the account. Use at your own risk.", "webCookie": "This provider authenticates through your web session cookies. The upstream service may invalidate the session at any time, requiring you to log in again. Not recommended for long unattended operations. Use at your own risk.", @@ -5107,11 +5111,11 @@ "cancel": "Cancel" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, - "disabled": "Discapacitado", + "disabled": "Deshabilitado", "enableProvider": "Habilitar proveedor", "disableProvider": "Deshabilitar proveedor", "testResults": "Resultados de la prueba", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Omitiendo {count} modelos existentes", "autoSync": "Sincronización automática", "autoSyncShort": "Sincronizar", + "autoFetchModels": "Obtención automática de modelos upstream", + "autoFetchModelsTooltip": "Obtener y almacenar en caché los modelos de upstream cuando sea necesario", + "autoFetchModelsEnabled": "Modelo de upstream auto-fetch habilitado", + "autoFetchModelsDisabled": "La recuperación automática del modelo upstream está desactivada", + "autoFetchModelsToggleFailed": "Error al alternar la auto-recuperación del modelo upstream", + "autoFetchModelsPartialFailure": "Algunas conexiones se actualizaron, pero el auto-fetch del modelo upstream no se cambió en todas partes", + "overridesUpstreamModel": "Sobrescribe el upstream", + "overridesUpstreamModelHint": "Tus configuraciones anulan este modelo de upstream", + "resetToUpstreamDefaults": "Restaurar valores predeterminados del upstream", + "resetToUpstreamDefaultsSuccess": "Restaurados los valores predeterminados del modelo upstream", + "resetToUpstreamDefaultsFailed": "No se pudo restaurar los valores predeterminados del modelo upstream", "autoSyncTooltip": "Actualiza automáticamente la lista de modelos cada 24 horas (configurable vía MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Sincronización automática activada — los modelos se actualizarán periódicamente", "autoSyncDisabled": "Sincronización automática desactivada", @@ -5335,7 +5350,7 @@ "builtInModels": "Built-in models", "builtInModelsHint": "Registry models for this provider. Use the pencil to set compatibility options.", "pageAutoRefresh": "La página se actualizará automáticamente...", - "statusDisabled": "discapacitado", + "statusDisabled": "deshabilitado", "statusConnected": "conectado", "statusRuntimeIssue": "problema de tiempo de ejecución", "statusAuthFailed": "autenticación fallida", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Rewrite native web_fetch tool calls to OmniRoute's /v1/web/fetch.", "interceptionLoadError": "Failed to load interception settings: {error}", "interceptionSaveError": "Failed to save interception settings: {error}", - "ccAliasSectionTitle": "Exponer en Claude Code (claude/…)", - "ccAliasSectionHint": "Anunciar los modelos de este proveedor bajo claude/<provider>/<model> IDs de espejo para que el descubrimiento de modelos de la puerta de enlace de Claude Code pueda listarlos. Desactivado por defecto; habilitar esto duplica las entradas del catálogo para todos los clientes.", - "ccAliasProviderLevelLabel": "Proveedor predeterminado", - "ccAliasModelOverridesLabel": "Sobrescrituras por modelo", - "ccAliasModelOverrideAriaLabel": "Sobrescribir para {modelId}", - "ccAliasStateInherit": "Heredar", - "ccAliasStateOn": "Encendido", - "ccAliasStateOff": "Apagar", - "ccAliasAddModelPlaceholder": "ID del modelo (p. ej. gpt-4o)", - "ccAliasAddModelButton": "Agregar anulación", - "ccAliasLoadError": "Error al cargar la configuración de discovery-alias: {error}", - "ccAliasSaveError": "Error al guardar la configuración de alias de descubrimiento: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Connect Galadriel with an API key.", "predibase": "$25 free trial credits (30-day validity)", "chenzk": "OpenAI-compatible gateway with a live model catalog at chenzk.top.", - "freepik": "Generate images with Freepik's Mystic API.", + "magnific": "Generate images with Freepik's Mystic API.", "freetheai": "Free OpenAI-compatible gateway with passthrough model support.", "g4f-gemini": "Free no-key g4f.space reverse proxy to Gemini, limited to 5 requests per minute.", "g4f-groq": "Free no-key g4f.space reverse proxy to Groq, limited to 5 requests per minute.", @@ -6209,6 +6224,7 @@ "claude": "Connect Claude Code with the existing OAuth flow.", "cline": "Connect Cline with the existing OAuth flow.", "cursor": "Connect Cursor IDE with the existing OAuth flow.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Connect GitHub Copilot with the existing OAuth flow.", "gitlab-duo": "OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance.", "kilocode": "Connect Kilo Code with the existing OAuth flow.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Amigo del código abierto", "cheaperInferenceSupporterTooltip": "Cheaper Inference apoya a OmniRoute como amigo del código abierto", "kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you", + "codexQuotaPools": "Grupos de cuotas de Codex", + "codexPoolAvailable": "Disponible", + "codexPoolPartiallyLimited": "Limitado parcialmente", + "codexPoolFullyLimited": "Limitado por completo", + "codexPoolLimited": "{count} limitados", + "codexPoolQuotaExhausted": "Cuota agotada", + "codexPoolCoolingDown": "En espera", + "codexPoolUsed": "usado", + "codexPoolUntil": "Hasta {value}", "anonymousFallbackTitle": "Recaudación anónima", "anonymousFallbackDesc": "Cuando todas las conexiones configuradas están agotadas (cuota, créditos o expiración), utiliza temporalmente el nivel sin clave de este proveedor. Desactiva para omitir este proveedor en lugar de enviar solicitudes anónimas — recomendado cuando el nivel sin clave las rechaza (401).", "anonymousFallbackEnabled": "Fallback anónimo habilitado para {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Configuración del punto final del modelo guardado", "searchByModelAria": "Buscar por modelo", "selectSupportedEndpoint": "Seleccione al menos un endpoint compatible", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsEnabled": "Modelo de upstream auto-fetch habilitado", - "autoFetchModels": "Obtención automática de modelos upstream", - "autoFetchModelsTooltip": "Obtener y almacenar en caché los modelos de upstream cuando sea necesario", - "autoFetchModelsDisabled": "La recuperación automática del modelo upstream está desactivada", - "autoFetchModelsToggleFailed": "Error al alternar la auto-recuperación del modelo upstream", - "overridesUpstreamModel": "Sobrescribe el upstream", - "autoFetchModelsPartialFailure": "Algunas conexiones se actualizaron, pero el auto-fetch del modelo upstream no se cambió en todas partes", - "overridesUpstreamModelHint": "Tus configuraciones anulan este modelo de upstream", - "resetToUpstreamDefaults": "Restaurar valores predeterminados del upstream", - "resetToUpstreamDefaultsFailed": "No se pudo restaurar los valores predeterminados del modelo upstream", - "resetToUpstreamDefaultsSuccess": "Restaurados los valores predeterminados del modelo upstream" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Configuración", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Banned Keywords", "customBannedSignalsDesc": "Additional keywords that trigger permanent account ban detection. Built-in keywords always apply.", "customBannedSignalsPlaceholder": "e.g. api key revoked", @@ -6724,7 +6738,7 @@ "global": "Mundial", "rule": "regla", "enabled": "Habilitado", - "disabled": "Discapacitado", + "disabled": "Deshabilitado", "nodeCount": "Nodos: {count}", "needsCoreCount": "{count} necesita núcleo local", "lastSynced": "Última sincronización: {time}", @@ -6995,7 +7009,7 @@ "systemActor": "sistema", "ipAccessControl": "Control de acceso IP", "ipAccessControlDesc": "Bloquear o permitir direcciones IP específicas", - "ipModeDisabled": "Discapacitado", + "ipModeDisabled": "Deshabilitado", "ipModeBlacklist": "Lista negra", "ipModeWhitelist": "Lista blanca", "ipModeWhitelistPriority": "Prioridad WL", @@ -7189,6 +7203,7 @@ "configured": "configured", "none": "None", "modelOverrideValuePlaceholder": "Numeric value", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Add key value", "noModelOverrides": "No overrides configured for this model.", "modelOverrideLoadFailed": "Failed to load model overrides", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Terse CJK (文言)", "description": "Classical-Chinese ultra-terse style (available only for Chinese)." @@ -7919,7 +7938,7 @@ "triggerLabel": "gatillo", "effectLabel": "Efecto", "statusEnabled": "Habilitado", - "statusDisabled": "Discapacitado", + "statusDisabled": "Deshabilitado", "resilienceRequestQueueScope": "Por cola de solicitudes", "resilienceRequestQueueTrigger": "Antes de enviar al upstream", "resilienceRequestQueueEffect": "Pone en cola las solicitudes, limita la simultaneidad y espacia las llamadas", @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Round-robin and random combos rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Leave off to preserve prompt-cache hits for multi-turn chats. Per-combo overrides take precedence.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Credential Redaction", "credentialRedactionDesc": "Redact API keys, tokens, and secrets from context sent to providers and from responses.", "enableCredentialRedaction": "Enable credential redaction", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Enable the engine", "enableDescription": "Runs last in the stack (after RTK/Caveman cleans the text, OmniGlyph converts the remainder to images) and also runs standalone through omniglyph mode. This is a preview and remains off by default until end-to-end validation is complete.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Saved.", "saveFailed": "Could not save.", "enableAria": "Enable the OmniGlyph engine", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "máx", "grokAutoTopUpMonth": "mes", "grokAdditionalCredits": "Créditos Adicionales", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "registrador", "proxyTab": "apoderado", "budgetManagement": "Gestión Presupuestaria", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "First Token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index f75eafbeb4..35f12114ef 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "زمان‌بندی درخواست بصری", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "باز کردن", "close": "بستن" }, - "noResults": "هیچ نتیجه‌ای یافت نشد", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "هیچ نتیجه‌ای یافت نشد" }, "webhooks": { "title": "وب هوک ها", @@ -1739,8 +1739,8 @@ "quotaShare": "اشتراک سهمیه", "discovery": "اکتشاف", "freeProviderRankings": "رتبه‌بندی ارائه‌دهندگان رایگان", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "طرح‌های رایگان", "gamification": "بازی‌وارسازی", "leaderboard": "جدول رده‌بندی", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "این ارائه دهنده منسوخ شده است", "riskNotice": { "title": "قبل از ادامه", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "ارائه‌دهنده با هشدارهای استفاده — برای جزئیات کلیک کنید", "oauth": "این ارائه‌دهنده از نشست/OAuth رسمی محصول شما استفاده می‌کند که برای استفاده از پروکسی/روتر مجاز نیست. ما استفاده فشرده از عامل‌های خودکار (به سبک OpenCloud، جریان‌های چندمرحله‌ای طولانی، دسته‌های بزرگ) را توصیه نمی‌کنیم — ممکن است سرویس بالادستی با محدود کردن یا مسدود کردن حساب واکنش نشان دهد. با مسئولیت خودتان استفاده کنید.", "webCookie": "این ارائه‌دهنده از طریق کوکی‌های نشست وب شما احراز هویت می‌کند. سرویس بالادستی ممکن است در هر زمان نشست را باطل کند و شما را ملزم به ورود مجدد نماید. برای عملیات طولانی بدون نظارت توصیه نمی‌شود. با مسئولیت خودتان استفاده کنید.", @@ -5107,9 +5111,9 @@ "cancel": "لغو" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabled", "enableProvider": "Enable provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Skipping {count} existing models", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "مدل‌های بالادستی را به‌طور خودکار دریافت کنید", + "autoFetchModelsTooltip": "مدل‌های بالادستی را در صورت نیاز دریافت و کش کنید", + "autoFetchModelsEnabled": "مدل بالادستی بارگذاری خودکار فعال است", + "autoFetchModelsDisabled": "مدل upstream بارگذاری خودکار غیرفعال است", + "autoFetchModelsToggleFailed": "عدم موفقیت در تغییر حالت بارگیری خودکار مدل upstream", + "autoFetchModelsPartialFailure": "برخی اتصالات به‌روزرسانی شدند، اما مدل بالادستی auto-fetch در همه جا تغییر نکرده است", + "overridesUpstreamModel": "بازنویسی upstream", + "overridesUpstreamModelHint": "تنظیمات شما این مدل بالادستی را نادیده می‌گیرند", + "resetToUpstreamDefaults": "بازگرداندن تنظیمات پیش‌فرض upstream", + "resetToUpstreamDefaultsSuccess": "تنظیمات پیش‌فرض مدل بالادستی بازیابی شد", + "resetToUpstreamDefaultsFailed": "بازگردانی پیش‌فرض‌های مدل upstream ناموفق بود", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "بازنویسی فراخوانی‌های ابزار بومی web_fetch به /v1/web/fetch در OmniRoute.", "interceptionLoadError": "بارگیری تنظیمات رهگیری ناموفق بود: {error}", "interceptionSaveError": "ذخیره تنظیمات رهگیری ناموفق بود: {error}", - "ccAliasSectionTitle": "در کد کلاود (claude/…) نمایان کنید", - "ccAliasSectionHint": "مدل‌های این ارائه‌دهنده را تحت شناسه‌های آینه claude/<provider>/<model> تبلیغ کنید تا کشف مدل‌های دروازه کد کلود آن‌ها را فهرست کند. به‌طور پیش‌فرض غیرفعال است — فعال‌سازی این گزینه تعداد ورودی‌های کاتالوگ را برای تمام مشتریان دو برابر می‌کند.", - "ccAliasProviderLevelLabel": "ارائه‌دهنده پیش‌فرض", - "ccAliasModelOverridesLabel": "بازنویسی‌های هر مدل", - "ccAliasModelOverrideAriaLabel": "بازنویسی برای {modelId}", - "ccAliasStateInherit": "به ارث بردن", - "ccAliasStateOn": "روشن", - "ccAliasStateOff": "خاموش", - "ccAliasAddModelPlaceholder": "شناسه مدل (به عنوان مثال gpt-4o)", - "ccAliasAddModelButton": "اضافه کردن نادیده‌گیری", - "ccAliasLoadError": "بارگذاری تنظیمات discovery-alias با شکست مواجه شد: {error}", - "ccAliasSaveError": "ذخیره تنظیمات discovery-alias با شکست مواجه شد: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "اتصال به Galadriel با یک کلید API.", "predibase": "$25 اعتبار آزمایشی رایگان (اعتبار ۳۰ روزه)", "chenzk": "درگاه سازگار با OpenAI با یک کاتالوگ مدل زنده در chenzk.top.", - "freepik": "تولید تصاویر با Mystic API مربوط به Freepik.", + "magnific": "تولید تصاویر با Mystic API مربوط به Freepik.", "freetheai": "درگاه رایگان سازگار با OpenAI با پشتیبانی از مدل passthrough.", "g4f-gemini": "پروکسی معکوس رایگان و بدون کلید g4f.space به Gemini، محدود به 5 درخواست در دقیقه.", "g4f-groq": "پروکسی معکوس رایگان و بدون کلید g4f.space به Groq، محدود به 5 درخواست در دقیقه.", @@ -6209,6 +6224,7 @@ "claude": "اتصال به Claude Code با جریان OAuth موجود.", "cline": "اتصال به Cline با جریان OAuth موجود.", "cursor": "اتصال به Cursor IDE با جریان OAuth موجود.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "اتصال به GitHub Copilot با جریان OAuth موجود.", "gitlab-duo": "برنامه OAuth با اسکوپ‌های ai_features + read_user. متغیرهای GITLAB_DUO_OAUTH_CLIENT_ID و به صورت اختیاری GITLAB_DUO_OAUTH_CLIENT_SECRET را روی این نمونه OmniRoute پیکربندی کنید.", "kilocode": "اتصال به Kilo Code با جریان OAuth موجود.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "دوست متن‌باز", "cheaperInferenceSupporterTooltip": "‏Cheaper Inference از OmniRoute به‌عنوان دوست متن‌باز حمایت می‌کند", "kimiPartnerLinkNote": "لینک همکاری — پشتیبانی از OmniRoute بدون هزینه اضافی برای شما", + "codexQuotaPools": "مخزن‌های سهمیه Codex", + "codexPoolAvailable": "در دسترس", + "codexPoolPartiallyLimited": "تا حدی محدود", + "codexPoolFullyLimited": "کاملاً محدود", + "codexPoolLimited": "{count} مورد محدود", + "codexPoolQuotaExhausted": "سهمیه تمام شده است", + "codexPoolCoolingDown": "در دوره انتظار", + "codexPoolUsed": "مصرف‌شده", + "codexPoolUntil": "تا {value}", "anonymousFallbackTitle": "پشتیبانی ناشناس", "anonymousFallbackDesc": "زمانی که تمام اتصالات پیکربندی‌شده تمام شده‌اند (سهمیه، اعتبار یا انقضا)، به‌طور موقت از سطح بدون کلید این ارائه‌دهنده استفاده کنید. برای رد کردن این ارائه‌دهنده به‌جای ارسال درخواست‌های ناشناس خاموش کنید — این کار زمانی توصیه می‌شود که سطح بدون کلید آن‌ها را رد کند (401).", "anonymousFallbackEnabled": "پشتیبانی ناشناس برای {provider} فعال شد", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "تنظیمات نقطه پایانی مدل ذخیره شده", "searchByModelAria": "جستجو بر اساس مدل", "selectSupportedEndpoint": "حداقل یک نقطه پایانی پشتیبانی شده را انتخاب کنید", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "مدل‌های بالادستی را به‌طور خودکار دریافت کنید", - "autoFetchModelsTooltip": "مدل‌های بالادستی را در صورت نیاز دریافت و کش کنید", - "autoFetchModelsDisabled": "مدل upstream بارگذاری خودکار غیرفعال است", - "autoFetchModelsEnabled": "مدل بالادستی بارگذاری خودکار فعال است", - "autoFetchModelsToggleFailed": "عدم موفقیت در تغییر حالت بارگیری خودکار مدل upstream", - "overridesUpstreamModel": "بازنویسی upstream", - "autoFetchModelsPartialFailure": "برخی اتصالات به‌روزرسانی شدند، اما مدل بالادستی auto-fetch در همه جا تغییر نکرده است", - "resetToUpstreamDefaults": "بازگرداندن تنظیمات پیش‌فرض upstream", - "overridesUpstreamModelHint": "تنظیمات شما این مدل بالادستی را نادیده می‌گیرند", - "resetToUpstreamDefaultsSuccess": "تنظیمات پیش‌فرض مدل بالادستی بازیابی شد", - "resetToUpstreamDefaultsFailed": "بازگردانی پیش‌فرض‌های مدل upstream ناموفق بود" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Settings", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "کلمات کلیدی ممنوع", "customBannedSignalsDesc": "کلمات کلیدی اضافی که باعث تشخیص مسدودسازی دائمی حساب می‌شوند. کلمات کلیدی داخلی همیشه اعمال می‌شوند.", "customBannedSignalsPlaceholder": "مثلاً api key revoked", @@ -7189,6 +7203,7 @@ "configured": "پیکربندی‌شده", "none": "هیچ‌کدام", "modelOverrideValuePlaceholder": "مقدار عددی", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "افزودن کلید-مقدار", "noModelOverrides": "هیچ بازنویسی‌ای برای این مدل پیکربندی نشده است.", "modelOverrideLoadFailed": "بارگذاری بازنویسی‌های مدل ناموفق بود", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK موجز (文言)", "description": "سبک فوق‌موجز چینی کلاسیک (فقط برای زبان چینی در دسترس است)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "ترکیب‌های نوبت‌گردشی و تصادفی در هر درخواست به یک اتصال متفاوت منتقل می‌شوند، به جای اینکه کل گفتگو را بر اساس هش اولین پیام به یک اتصال پین کنند. برای حفظ هیت‌های prompt-cache در چت‌های چند نوبته، این گزینه را غیرفعال بگذارید. اولویت با بازنویسی‌های اختصاصی هر ترکیب است.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "سانسور اطلاعات اعتبارنامه‌ای", "credentialRedactionDesc": "سانسور کردن کلیدهای API، توکن‌ها و اسرار از بافت ارسال شده به ارائه‌دهندگان و از پاسخ‌ها.", "enableCredentialRedaction": "فعال‌سازی سانسور اطلاعات اعتبارنامه‌ای", @@ -8600,6 +8623,27 @@ }, "enableTitle": "فعال‌سازی موتور", "enableDescription": "در انتهای پشته اجرا می‌شود (پس از اینکه RTK/Caveman متن را پاکسازی کرد، OmniGlyph باقی‌مانده را به تصویر تبدیل می‌کند) و همچنین به‌صورت مستقل از طریق حالت omniglyph اجرا می‌شود. این یک پیش‌نمایش است و تا زمان تکمیل اعتبارسنجی سرتاسری به‌طور پیش‌فرض خاموش می‌ماند.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "ذخیره شد.", "saveFailed": "ذخیره نشد.", "enableAria": "فعال‌سازی موتور OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "حداکثر", "grokAutoTopUpMonth": "ماه", "grokAdditionalCredits": "اعتبارات اضافی", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "اولین توکن", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 3f2cf48e9f..fd15e9dfaa 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Visuaalinen pyyntöaikajana", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "avaa", "close": "sulje" }, - "noResults": "Ei tuloksia", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Ei tuloksia" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "Kiintiöosuus", "discovery": "Löytäminen", "freeProviderRankings": "Ilmaisten tarjoajien sijoitukset", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Ilmaiset tasot", "gamification": "Pelillistäminen", "leaderboard": "Tulostaulukko", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Tämä palveluntarjoaja on poistettu käytöstä", "riskNotice": { "title": "Ennen jatkamista", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Tarjoaja, jolla on käyttöä koskevia huomautuksia — napsauta nähdäksesi lisätiedot", "oauth": "Tämä tarjoaja käyttää virallista tuote-istuntoasi/OAuthia, jota ei ole valtuutettu välityspalvelin-/reititinkäyttöön. Emme suosittele intensiivistä autonomisten agenttien käyttöä (OpenCloud-tyyliset, pitkät monivaiheiset työnkulut, suuret erät) — ylävirta saattaa reagoida rajoittamalla tiliä tai estämällä sen. Käyttö omalla vastuulla.", "webCookie": "Tämä tarjoaja todennetaan verkkosessiosi evästeiden kautta. Ylävirran palvelu voi mitätöidä istunnon milloin tahansa, jolloin sinun on kirjauduttava uudelleen sisään. Ei suositella pitkiin valvomattomiin toimintoihin. Käyttö omalla vastuulla.", @@ -5107,9 +5111,9 @@ "cancel": "Peruuta" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Ei käytössä", "enableProvider": "Ota palveluntarjoaja käyttöön", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Ohitetaan {count} olemassa olevaa mallia", "autoSync": "Automaattinen synkronointi", "autoSyncShort": "Synkronointi", + "autoFetchModels": "Hae automaattisesti upstream-malleja", + "autoFetchModelsTooltip": "Hae ja vältä ylösvirtaisten mallien välimuisti tarvittaessa", + "autoFetchModelsEnabled": "Ylävirran mallin automaattinen haku käytössä", + "autoFetchModelsDisabled": "Ylöspäin suuntautuvan mallin automaattinen haku pois käytöstä", + "autoFetchModelsToggleFailed": "Epäonnistui ylösvirran mallin automaattihaku kytkemisessä", + "autoFetchModelsPartialFailure": "Joitakin yhteyksiä päivitettiin, mutta ylävirran mallin automaattista hakua ei muutettu kaikkialla", + "overridesUpstreamModel": "Ylikirjoittaa ylävirran", + "overridesUpstreamModelHint": "Asetuksesi ohittavat tämän ylävirran mallin", + "resetToUpstreamDefaults": "Palauta upstream-oletukset", + "resetToUpstreamDefaultsSuccess": "Palautettiin ylävirran mallin oletukset", + "resetToUpstreamDefaultsFailed": "Palautus upstream-mallin oletusasetuksista epäonnistui", "autoSyncTooltip": "Päivitä malliluettelo automaattisesti 24 tunnin välein (konfiguroitavissa kohdassa MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Automaattinen synkronointi käytössä – mallit päivittyvät säännöllisesti", "autoSyncDisabled": "Automaattinen synkronointi poistettu käytöstä", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Uudelleenkirjoita natiivit web_fetch-työkalukutsut OmniRouten osoitteeseen /v1/web/fetch.", "interceptionLoadError": "Sieppausasetusten lataaminen epäonnistui: {error}", "interceptionSaveError": "Sieppausasetusten tallentaminen epäonnistui: {error}", - "ccAliasSectionTitle": "Avaa Claude-koodissa (claude/…)", - "ccAliasSectionHint": "Mainosta tämän tarjoajan malleja claude/<provider>/<model> peilidien alla, jotta Claude Coden porttimallin löytö voi listata ne. Oletusarvoisesti pois päältä — tämän aktivointi kaksinkertaistaa luettelon merkinnät kaikille asiakkaille.", - "ccAliasProviderLevelLabel": "Palveluntarjoajan oletus", - "ccAliasModelOverridesLabel": "Per-mallin ylitykset", - "ccAliasModelOverrideAriaLabel": "Ylikirjoitus {modelId} varten", - "ccAliasStateInherit": "Peri", - "ccAliasStateOn": "Päällä", - "ccAliasStateOff": "Pois", - "ccAliasAddModelPlaceholder": "Mallin tunnus (esim. gpt-4o)", - "ccAliasAddModelButton": "Lisää ohitus", - "ccAliasLoadError": "Epäonnistui lataamaan discovery-alias-asetuksia: {error}", - "ccAliasSaveError": "Asetuksen discovery-alias tallentaminen epäonnistui: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Yhdistä Galadriel API-avaimella.", "predibase": "$25 ilmaista kokeilusaldoa (voimassa 30 päivää)", "chenzk": "OpenAI-yhteensopiva yhdyskäytävä reaaliaikaisella malliluettelolla osoitteessa chenzk.top.", - "freepik": "Luo kuvia Freepikin Mystic API:lla.", + "magnific": "Luo kuvia Freepikin Mystic API:lla.", "freetheai": "Ilmainen OpenAI-yhteensopiva yhdyskäytävä läpivientimallien tuella.", "g4f-gemini": "Ilmainen avaimeton g4f.space-käänteisvälityspalvelin Geminiin, rajoitettu 5 pyyntöön minuutissa.", "g4f-groq": "Ilmainen avaimeton g4f.space-käänteisvälityspalvelin Groqiin, rajoitettu 5 pyyntöön minuutissa.", @@ -6209,6 +6224,7 @@ "claude": "Yhdistä Claude Code olemassa olevalla OAuth-työnkululla.", "cline": "Yhdistä Cline olemassa olevalla OAuth-työnkululla.", "cursor": "Yhdistä Cursor IDE olemassa olevalla OAuth-työnkululla.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Yhdistä GitHub Copilot olemassa olevalla OAuth-työnkululla.", "gitlab-duo": "OAuth-sovellus ai_features + read_user -käyttöoikeuksilla. Määritä GITLAB_DUO_OAUTH_CLIENT_ID ja valinnaisesti GITLAB_DUO_OAUTH_CLIENT_SECRET tälle OmniRoute-instanssille.", "kilocode": "Yhdistä Kilo Code olemassa olevalla OAuth-työnkululla.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Avoimen lähdekoodin ystävä", "cheaperInferenceSupporterTooltip": "Cheaper Inference tukee OmniRoutea avoimen lähdekoodin ystävänä", "kimiPartnerLinkNote": "Kumppanilinkki — tukee OmniRoutea ilman lisäkustannuksia sinulle", + "codexQuotaPools": "Codex-kiintiöpoolit", + "codexPoolAvailable": "Käytettävissä", + "codexPoolPartiallyLimited": "Osittain rajoitettu", + "codexPoolFullyLimited": "Täysin rajoitettu", + "codexPoolLimited": "{count} rajoitettua", + "codexPoolQuotaExhausted": "Kiintiö käytetty loppuun", + "codexPoolCoolingDown": "Jäähdytysjaksolla", + "codexPoolUsed": "käytetty", + "codexPoolUntil": "{value} asti", "anonymousFallbackTitle": "Anonyymi varajärjestelmä", "anonymousFallbackDesc": "Kun kaikki määritetyt yhteydet on käytetty loppuun (kiintiö, krediitit tai vanhentuminen), käytä väliaikaisesti tämän tarjoajan avaimettomaa tasoa. Poista käytöstä tämän tarjoajan ohittamiseksi sen sijaan, että lähetät nimettömiä pyyntöjä — suositellaan, kun avaimeton taso hylkää ne (401).", "anonymousFallbackEnabled": "Anonyymi varajärjestelmä käytössä {provider} varten", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Tallennetun mallin päätepisteen asetukset", "searchByModelAria": "Hae mallin mukaan", "selectSupportedEndpoint": "Valitse vähintään yksi tuettu päätepiste", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Hae automaattisesti upstream-malleja", - "autoFetchModelsEnabled": "Ylävirran mallin automaattinen haku käytössä", - "autoFetchModelsTooltip": "Hae ja vältä ylösvirtaisten mallien välimuisti tarvittaessa", - "autoFetchModelsDisabled": "Ylöspäin suuntautuvan mallin automaattinen haku pois käytöstä", - "overridesUpstreamModel": "Ylikirjoittaa ylävirran", - "autoFetchModelsToggleFailed": "Epäonnistui ylösvirran mallin automaattihaku kytkemisessä", - "autoFetchModelsPartialFailure": "Joitakin yhteyksiä päivitettiin, mutta ylävirran mallin automaattista hakua ei muutettu kaikkialla", - "resetToUpstreamDefaults": "Palauta upstream-oletukset", - "resetToUpstreamDefaultsSuccess": "Palautettiin ylävirran mallin oletukset", - "resetToUpstreamDefaultsFailed": "Palautus upstream-mallin oletusasetuksista epäonnistui", - "overridesUpstreamModelHint": "Asetuksesi ohittavat tämän ylävirran mallin" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Asetukset", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Kielletyt avainsanat", "customBannedSignalsDesc": "Muut avainsanat, jotka käynnistävät tilin pysyvän eston tunnistuksen. Sisäänrakennetut avainsanat ovat aina käytössä.", "customBannedSignalsPlaceholder": "esim. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "määritetty", "none": "Ei mitään", "modelOverrideValuePlaceholder": "Numeerinen arvo", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Lisää avain-arvo", "noModelOverrides": "Tälle mallille ei ole määritetty ohituksia.", "modelOverrideLoadFailed": "Mallin ohitusten lataaminen epäonnistui", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Tiivis CJK (文言)", "description": "Klassisen kiinan ultra-tiivis tyyli (saatavilla vain kiinaksi)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Round-robin- ja satunnaisyhdistelmät vaihtavat eri yhteyteen jokaisella pyynnöllä sen sijaan, että koko keskustelu kiinnitettäisiin yhteen yhteyteen ensimmäisen viestin tiivisteen (hash) perusteella. Jätä pois käytöstä säilyttääksesi prompt-välimuistin osumat monivaiheisissa keskusteluissa. Yhdistelmäkohtaiset ohitukset ovat etusijalla.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Tunnistetietojen peittäminen", "credentialRedactionDesc": "Peitä API-avaimet, tokenit ja salaisuudet tarjoajille lähetettävästä kontekstista sekä vastauksista.", "enableCredentialRedaction": "Ota tunnistetietojen peittäminen käyttöön", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Ota moottori käyttöön", "enableDescription": "Suoritetaan pinossa viimeisenä (sen jälkeen kun RTK/Caveman puhdistaa tekstin ja OmniGlyph muuntaa loput kuviksi) ja suoritetaan myös itsenäisesti omniglyph-tilan kautta. Tämä on esikatseluversio ja pysyy oletusarvoisesti poissa käytöstä, kunnes päästä päähän -validointi on valmis.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Tallennettu.", "saveFailed": "Tallennus epäonnistui.", "enableAria": "Ota OmniGlyph-moottori käyttöön", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "kuukausi", "grokAdditionalCredits": "Lisäluotit", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Kirjaaja", "proxyTab": "Välityspalvelin", "budgetManagement": "Budjetin hallinta", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Ensimmäinen tokeni", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 5e930cae8e..64fc699674 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Chronologie visuelle des requêtes", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1282,8 +1284,6 @@ "resilienceConnectionsSubtitle": "Cooldown, disjoncteur, état de blocage", "settingsModalityBridge": "Pont de Modalité", "settingsModalityBridgeSubtitle": "Fallback image/audio → texte pour les modèles uniquement textuels", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations", "commandPalette": { "title": "Palette de commandes", "searchPlaceholder": "Rechercher dans les pages, paramètres et outils…", @@ -1739,8 +1739,8 @@ "quotaShare": "Partage de quota", "discovery": "Découverte", "freeProviderRankings": "Classement des fournisseurs gratuits", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Niveaux gratuits", "gamification": "Gamification", "leaderboard": "Classement", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Ce fournisseur est obsolète", "riskNotice": { "title": "Avant de continuer", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Fournisseur avec des restrictions d'utilisation — cliquez pour plus de détails", "oauth": "Ce fournisseur utilise votre session produit officielle/OAuth, ce qui n'est pas autorisé pour une utilisation via proxy/routeur. Nous ne recommandons pas une utilisation intensive par des agents autonomes (style OpenCloud, flux longs à étapes multiples, lots volumineux) — le service amont pourrait réagir en restreignant ou en bannissant le compte. À utiliser à vos risques et périls.", "webCookie": "Ce fournisseur s'authentifie via les cookies de votre session web. Le service amont peut invalider la session à tout moment, vous obligeant à vous reconnecter. Non recommandé pour les opérations longues sans surveillance. À utiliser à vos risques et périls.", @@ -5107,9 +5111,9 @@ "cancel": "Annuler" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Désactivé", "enableProvider": "Activer le fournisseur", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Ignorance de {count} modèles existants", "autoSync": "Synchronisation automatique", "autoSyncShort": "Synchroniser", + "autoFetchModels": "Récupérer automatiquement les modèles en amont", + "autoFetchModelsTooltip": "Récupérer et mettre en cache les modèles en amont si nécessaire", + "autoFetchModelsEnabled": "Récupération automatique du modèle en amont activée", + "autoFetchModelsDisabled": "Récupération automatique du modèle en amont désactivée", + "autoFetchModelsToggleFailed": "Échec de l'activation de la récupération automatique du modèle en amont", + "autoFetchModelsPartialFailure": "Certaines connexions ont été mises à jour, mais l'auto-récupération du modèle en amont n'a pas été modifiée partout", + "overridesUpstreamModel": "Remplace les modifications en amont", + "overridesUpstreamModelHint": "Vos paramètres remplacent ce modèle en amont", + "resetToUpstreamDefaults": "Restaurer les valeurs par défaut en amont", + "resetToUpstreamDefaultsSuccess": "Modèles par défaut de l'amont restaurés", + "resetToUpstreamDefaultsFailed": "Échec de la restauration des paramètres par défaut du modèle en amont", "autoSyncTooltip": "Actualise automatiquement la liste des modèles toutes les 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Synchronisation automatique activée — les modèles seront actualisés périodiquement", "autoSyncDisabled": "Synchronisation automatique désactivée", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Réécrire les appels d'outils natifs web_fetch vers le point de terminaison /v1/web/fetch d'OmniRoute.", "interceptionLoadError": "Échec du chargement des paramètres d'interception : {error}", "interceptionSaveError": "Échec de l'enregistrement des paramètres d'interception : {error}", - "ccAliasSectionTitle": "Exposer dans Claude Code (claude/…)", - "ccAliasSectionHint": "Afficher les modèles de ce fournisseur sous forme d'identifiants miroir claude/<provider>/<model> afin que la découverte de modèles de la passerelle Claude Code puisse les répertorier. Cette option est désactivée par défaut ; son activation double les entrées du catalogue pour tous les clients.", - "ccAliasProviderLevelLabel": "Valeur par défaut du fournisseur", - "ccAliasModelOverridesLabel": "Surcharges par modèle", - "ccAliasModelOverrideAriaLabel": "Surcharge pour {modelId}", - "ccAliasStateInherit": "Hériter", - "ccAliasStateOn": "Activé", - "ccAliasStateOff": "Désactivé", - "ccAliasAddModelPlaceholder": "ID du modèle (par ex. gpt-4o)", - "ccAliasAddModelButton": "Ajouter une surcharge", - "ccAliasLoadError": "Échec du chargement des paramètres d'alias de découverte : {error}", - "ccAliasSaveError": "Échec de l'enregistrement du paramètre d'alias de découverte : {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Connecter Galadriel avec une clé API.", "predibase": "25 $ de crédits d'essai gratuit (validité de 30 jours)", "chenzk": "Passerelle compatible OpenAI avec un catalogue de modèles en direct sur chenzk.top.", - "freepik": "Générer des images avec l'API Mystic de Freepik.", + "magnific": "Générer des images avec l'API Mystic de Freepik.", "freetheai": "Passerelle gratuite compatible OpenAI avec prise en charge des modèles en passthrough.", "g4f-gemini": "Reverse proxy g4f.space gratuit sans clé vers Gemini, limité à 5 requêtes par minute.", "g4f-groq": "Reverse proxy g4f.space gratuit sans clé vers Groq, limité à 5 requêtes par minute.", @@ -6209,6 +6224,7 @@ "claude": "Connecter Claude Code avec le flux OAuth existant.", "cline": "Connecter Cline avec le flux OAuth existant.", "cursor": "Connecter Cursor IDE avec le flux OAuth existant.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Connecter GitHub Copilot avec le flux OAuth existant.", "gitlab-duo": "Application OAuth avec les portées ai_features + read_user. Configurez GITLAB_DUO_OAUTH_CLIENT_ID et éventuellement GITLAB_DUO_OAUTH_CLIENT_SECRET sur cette instance OmniRoute.", "kilocode": "Connecter Kilo Code avec le flux OAuth existant.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Ami open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference soutient OmniRoute en tant qu'ami open source", "kimiPartnerLinkNote": "Lien partenaire — soutient OmniRoute sans frais supplémentaires pour vous", + "codexQuotaPools": "Pools de quotas Codex", + "codexPoolAvailable": "Disponible", + "codexPoolPartiallyLimited": "Partiellement limité", + "codexPoolFullyLimited": "Entièrement limité", + "codexPoolLimited": "{count} limités", + "codexPoolQuotaExhausted": "Quota épuisé", + "codexPoolCoolingDown": "En période d'attente", + "codexPoolUsed": "utilisé", + "codexPoolUntil": "Jusqu'à {value}", "anonymousFallbackTitle": "Fallback anonyme", "anonymousFallbackDesc": "Lorsque toutes les connexions configurées sont épuisées (quota, crédits ou expiration), utilisez temporairement le niveau sans clé de ce fournisseur. Désactivez cette option pour ignorer ce fournisseur au lieu d'envoyer des requêtes anonymes — recommandé lorsque le niveau sans clé les rejette (401).", "anonymousFallbackEnabled": "Fallback anonyme activé pour {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Saved modèles endpoint paramètres", "searchByModelAria": "Rechercher un modèle", "selectSupportedEndpoint": "Sélectionnez au moins un endpoint pris en charge", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Récupérer automatiquement les modèles en amont", - "autoFetchModelsEnabled": "Récupération automatique du modèle en amont activée", - "autoFetchModelsDisabled": "Récupération automatique du modèle en amont désactivée", - "autoFetchModelsTooltip": "Récupérer et mettre en cache les modèles en amont si nécessaire", - "autoFetchModelsToggleFailed": "Échec de l'activation de la récupération automatique du modèle en amont", - "overridesUpstreamModel": "Remplace les modifications en amont", - "overridesUpstreamModelHint": "Vos paramètres remplacent ce modèle en amont", - "resetToUpstreamDefaults": "Restaurer les valeurs par défaut en amont", - "resetToUpstreamDefaultsSuccess": "Modèles par défaut de l'amont restaurés", - "autoFetchModelsPartialFailure": "Certaines connexions ont été mises à jour, mais l'auto-récupération du modèle en amont n'a pas été modifiée partout", - "resetToUpstreamDefaultsFailed": "Échec de la restauration des paramètres par défaut du modèle en amont" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Paramètres", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Mots-clés bannis", "customBannedSignalsDesc": "Mots-clés supplémentaires qui déclenchent la détection de bannissement permanent du compte. Les mots-clés intégrés s'appliquent toujours.", "customBannedSignalsPlaceholder": "ex. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "configuré", "none": "Aucun", "modelOverrideValuePlaceholder": "Valeur numérique", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Ajouter une clé-valeur", "noModelOverrides": "Aucune surcharge configurée pour ce modèle.", "modelOverrideLoadFailed": "Échec du chargement des surcharges de modèle", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK concis (文言)", "description": "Style ultra-concis en chinois classique (disponible uniquement pour le chinois)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Les combinaisons round-robin et aléatoires basculent vers une connexion différente à chaque requête au lieu d'associer toute une conversation à une seule connexion via le hachage du premier message. Laissez désactivé pour préserver les correspondances du cache de prompts pour les discussions multi-tours. Les remplacements par combinaison sont prioritaires.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Masquage des identifiants", "credentialRedactionDesc": "Masquer les clés d'API, les jetons et les secrets du contexte envoyé aux fournisseurs et des réponses.", "enableCredentialRedaction": "Activer le masquage des identifiants", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Activer le moteur", "enableDescription": "S'exécute en dernier dans la pile (après que RTK/Caveman a nettoyé le texte, OmniGlyph convertit le reste en images) et s'exécute également de manière autonome via le mode omniglyph. Il s'agit d'une version préliminaire qui reste désactivée par défaut jusqu'à ce que la validation de bout en bout soit terminée.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Enregistré.", "saveFailed": "Impossible d'enregistrer.", "enableAria": "Activer le moteur OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "mois", "grokAdditionalCredits": "Crédits supplémentaires", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Enregistreur", "proxyTab": "Procuration", "budgetManagement": "Gestion budgétaire", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Premier token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index db05acce8e..5e21f097c1 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "વિઝ્યુઅલ વિનંતી સમયરેખા", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "ખોલો", "close": "બંધ કરો" }, - "noResults": "કોઈ પરિણામો નથી", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "કોઈ પરિણામો નથી" }, "webhooks": { "title": "વેબહુક્સ", @@ -1739,8 +1739,8 @@ "quotaShare": "ક્વોટા શેર", "discovery": "ડિસ્કવરી", "freeProviderRankings": "ફ્રી પ્રોવાઇડર રેન્કિંગ્સ", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "ફ્રી ટાયર્સ", "gamification": "ગેમિફિકેશન", "leaderboard": "લીડરબોર્ડ", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "આ પ્રદાતા નાપસંદ કરવામાં આવી છે", "riskNotice": { "title": "આગળ વધતા પહેલા", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "વપરાશની ચેતવણીઓ સાથેનો પ્રદાતા — વિગતો માટે ક્લિક કરો", "oauth": "આ પ્રદાતા તમારા સત્તાવાર પ્રોડક્ટ સત્ર/OAuth નો ઉપયોગ કરે છે, જે પ્રોક્સી/રાઉટર ઉપયોગ માટે અધિકૃત નથી. અમે સઘન સ્વાયત્ત એજન્ટ વપરાશ (OpenCloud-શૈલી, લાંબા બહુ-પગલાંના પ્રવાહો, મોટા બેચ) ની ભલામણ કરતા નથી — અપસ્ટ્રીમ એકાઉન્ટને પ્રતિબંધિત અથવા બૅન કરીને પ્રતિક્રિયા આપી શકે છે. તમારા પોતાના જોખમે ઉપયોગ કરો.", "webCookie": "આ પ્રદાતા તમારા વેબ સત્ર કૂકીઝ દ્વારા પ્રમાણિત કરે છે. અપસ્ટ્રીમ સેવા કોઈપણ સમયે સત્રને અમાન્ય કરી શકે છે, જેના કારણે તમારે ફરીથી લૉગ ઇન કરવું પડશે. લાંબા અડચણ વગરના ઓપરેશન્સ માટે ભલામણ કરેલ નથી. તમારા પોતાના જોખમે ઉપયોગ કરો.", @@ -5107,9 +5111,9 @@ "cancel": "રદ કરો" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabled", "enableProvider": "Enable provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Skipping {count} existing models", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "આપોઆપ અપસ્ટ્રીમ મોડલ્સ લાવો", + "autoFetchModelsTooltip": "જરૂર પડ્યે અપસ્ટ્રીમ મોડલ્સને લાવવા અને કેશ કરવા", + "autoFetchModelsEnabled": "અપસ્ટ્રીમ મોડલ આપોઆપ મેળવવું સક્રિય છે", + "autoFetchModelsDisabled": "અપસ્ટ્રીમ મોડલ આપોઆપ મેળવનાર બંધ છે", + "autoFetchModelsToggleFailed": "અપસ્ટ્રીમ મોડલ ઓટો-ફેચ ટોગલ કરવામાં નિષ્ફળ થયું", + "autoFetchModelsPartialFailure": "કેટલાક કનેક્શન અપડેટ થયા, પરંતુ ઉપરવાળા મોડેલનું ઓટો-ફેચ દરેક જગ્યાએ બદલાયું નથી", + "overridesUpstreamModel": "અપસ્ટ્રીમને ઓવરરાઈડ કરે છે", + "overridesUpstreamModelHint": "તમારા સેટિંગ્સ આ અપસ્ટ્રીમ મોડેલને ઓવરરાઈડ કરે છે", + "resetToUpstreamDefaults": "અપસ્ટ્રીમ ડિફોલ્ટ્સ પુનઃસ્થાપિત કરો", + "resetToUpstreamDefaultsSuccess": "ઉપરવાળી મોડલ ડિફોલ્ટ્સ પુનઃસ્થાપિત કરવામાં આવ્યા", + "resetToUpstreamDefaultsFailed": "અપસ્ટ્રીમ મોડલ ડિફોલ્ટ્સ પુનઃસ્થાપિત કરવામાં નિષ્ફળ થયું", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "મૂળ web_fetch ટૂલ કૉલ્સને OmniRoute ના /v1/web/fetch પર ફરીથી લખો.", "interceptionLoadError": "ઇન્ટરસેપ્શન સેટિંગ્સ લોડ કરવામાં નિષ્ફળ: {error}", "interceptionSaveError": "ઇન્ટરસેપ્શન સેટિંગ્સ સાચવવામાં નિષ્ફળ: {error}", - "ccAliasSectionTitle": "Claude કોડમાં પ્રદર્શિત કરો (claude/…)", - "ccAliasSectionHint": "આ પ્રદાતા ના મોડલ્સને claude/<provider>/<model> મિરર આઈડીઓ હેઠળ જાહેરાત આપો જેથી Claude Code ના ગેટવે મોડલ શોધી શકે. ડિફોલ્ટ દ્વારા બંધ - આને સક્રિય કરવાથી તમામ ક્લાયન્ટો માટે કેટલોગ એન્ટ્રીઓ ડબલ થાય છે.", - "ccAliasProviderLevelLabel": "પ્રદાતા ડિફોલ્ટ", - "ccAliasModelOverridesLabel": "પ્રતિ-મોડલ ઓવરરાઇડ્સ", - "ccAliasModelOverrideAriaLabel": "{modelId} માટે ઓવરરાઈડ", - "ccAliasStateInherit": "વંશજ", - "ccAliasStateOn": "પર", - "ccAliasStateOff": "બંધ", - "ccAliasAddModelPlaceholder": "મોડલ આઈડી (ઉદાહરણ તરીકે gpt-4o)", - "ccAliasAddModelButton": "ઓવરરાઈડ ઉમેરો", - "ccAliasLoadError": "ડિસ્કવરી-એલિયસ સેટિંગ્સ લોડ કરવામાં નિષ્ફળ: {error}", - "ccAliasSaveError": "ડિસ્કવરી-એલિયસ સેટિંગ સાચવવામાં નિષ્ફળ: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "API કી વડે Galadriel ને કનેક્ટ કરો.", "predibase": "$25 મફત ટ્રાયલ ક્રેડિટ્સ (30-દિવસની માન્યતા)", "chenzk": "chenzk.top પર લાઇવ મોડલ કેટલોગ સાથે OpenAI-સુસંગત ગેટવે.", - "freepik": "Freepik ના Mystic API વડે છબીઓ જનરેટ કરો.", + "magnific": "Freepik ના Mystic API વડે છબીઓ જનરેટ કરો.", "freetheai": "પાસથ્રુ મોડલ સપોર્ટ સાથે મફત OpenAI-સુસંગત ગેટવે.", "g4f-gemini": "Gemini માટે મફત નો-કી g4f.space રિવર્સ પ્રોક્સી, પ્રતિ મિનિટ 5 વિનંતીઓ સુધી મર્યાદિત.", "g4f-groq": "Groq માટે મફત નો-કી g4f.space રિવર્સ પ્રોક્સી, પ્રતિ મિનિટ 5 વિનંતીઓ સુધી મર્યાદિત.", @@ -6209,6 +6224,7 @@ "claude": "હાલના OAuth ફ્લો વડે Claude Code ને કનેક્ટ કરો.", "cline": "હાલના OAuth ફ્લો વડે Cline ને કનેક્ટ કરો.", "cursor": "હાલના OAuth ફ્લો વડે Cursor IDE ને કનેક્ટ કરો.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "હાલના OAuth ફ્લો વડે GitHub Copilot ને કનેક્ટ કરો.", "gitlab-duo": "ai_features + read_user સ્કોપ્સ સાથેની OAuth એપ્લિકેશન. આ OmniRoute ઇન્સ્ટન્સ પર GITLAB_DUO_OAUTH_CLIENT_ID અને વૈકલ્પિક રીતે GITLAB_DUO_OAUTH_CLIENT_SECRET કન્ફિગર કરો.", "kilocode": "હાલના OAuth ફ્લો વડે Kilo Code ને કનેક્ટ કરો.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "ઓપન સોર્સ મિત્ર", "cheaperInferenceSupporterTooltip": "Cheaper Inference ઓપન સોર્સ મિત્ર તરીકે OmniRoute ને સમર્થન આપે છે", "kimiPartnerLinkNote": "પાર્ટનર લિંક — તમારા માટે કોઈ વધારાના ખર્ચ વિના OmniRoute ને સપોર્ટ કરે છે", + "codexQuotaPools": "Codex ક્વોટા પૂલ", + "codexPoolAvailable": "ઉપલબ્ધ", + "codexPoolPartiallyLimited": "આંશિક રીતે મર્યાદિત", + "codexPoolFullyLimited": "સંપૂર્ણ રીતે મર્યાદિત", + "codexPoolLimited": "{count} મર્યાદિત", + "codexPoolQuotaExhausted": "ક્વોટા સમાપ્ત", + "codexPoolCoolingDown": "વિરામ અવધિમાં", + "codexPoolUsed": "વપરાયેલ", + "codexPoolUntil": "{value} સુધી", "anonymousFallbackTitle": "ગૂઢ ફોલબેક", "anonymousFallbackDesc": "જ્યારે તમામ કન્ફિગર કરેલ કનેક્શનનો ઉપયોગ થઈ જાય છે (ક્વોટા, ક્રેડિટ, અથવા સમાપ્તી), ત્યારે આ પ્રદાતા ની કીલેસ ટિયરનો તાત્કાલિક ઉપયોગ કરો. અનામિક વિનંતીઓ મોકલવા માટે આ પ્રદાતાને છોડી દેવા માટે બંધ કરો - જ્યારે કીલેસ ટિયર તેમને નકારી દે ત્યારે ભલામણ કરવામાં આવે છે (401).", "anonymousFallbackEnabled": "{provider} માટે અજ્ઞાત ફોલબેક સક્રિય છે", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "સાચવેલ મોડેલ અંતિમ બિંદુની સેટિંગ્સ", "searchByModelAria": "મોડલ દ્વારા શોધો", "selectSupportedEndpoint": "કમથી કમ એક સમર્થિત અંતિમ બિંદુ પસંદ કરો", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "આપોઆપ અપસ્ટ્રીમ મોડલ્સ લાવો", - "autoFetchModelsEnabled": "અપસ્ટ્રીમ મોડલ આપોઆપ મેળવવું સક્રિય છે", - "autoFetchModelsDisabled": "અપસ્ટ્રીમ મોડલ આપોઆપ મેળવનાર બંધ છે", - "autoFetchModelsTooltip": "જરૂર પડ્યે અપસ્ટ્રીમ મોડલ્સને લાવવા અને કેશ કરવા", - "autoFetchModelsToggleFailed": "અપસ્ટ્રીમ મોડલ ઓટો-ફેચ ટોગલ કરવામાં નિષ્ફળ થયું", - "overridesUpstreamModel": "અપસ્ટ્રીમને ઓવરરાઈડ કરે છે", - "autoFetchModelsPartialFailure": "કેટલાક કનેક્શન અપડેટ થયા, પરંતુ ઉપરવાળા મોડેલનું ઓટો-ફેચ દરેક જગ્યાએ બદલાયું નથી", - "overridesUpstreamModelHint": "તમારા સેટિંગ્સ આ અપસ્ટ્રીમ મોડેલને ઓવરરાઈડ કરે છે", - "resetToUpstreamDefaultsSuccess": "ઉપરવાળી મોડલ ડિફોલ્ટ્સ પુનઃસ્થાપિત કરવામાં આવ્યા", - "resetToUpstreamDefaults": "અપસ્ટ્રીમ ડિફોલ્ટ્સ પુનઃસ્થાપિત કરો", - "resetToUpstreamDefaultsFailed": "અપસ્ટ્રીમ મોડલ ડિફોલ્ટ્સ પુનઃસ્થાપિત કરવામાં નિષ્ફળ થયું" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Settings", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "પ્રતિબંધિત કીવર્ડ્સ", "customBannedSignalsDesc": "વધારાના કીવર્ડ્સ જે કાયમી એકાઉન્ટ પ્રતિબંધ શોધને ટ્રિગર કરે છે. બિલ્ટ-ઇન કીવર્ડ્સ હંમેશા લાગુ પડે છે.", "customBannedSignalsPlaceholder": "દા.ત. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "કન્ફિગર કરેલ", "none": "કોઈ નહીં", "modelOverrideValuePlaceholder": "સંખ્યાત્મક મૂલ્ય", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "કી વેલ્યુ ઉમેરો", "noModelOverrides": "આ મોડેલ માટે કોઈ ઓવરરાઇડ્સ કન્ફિગર કરેલ નથી.", "modelOverrideLoadFailed": "મોડેલ ઓવરરાઇડ્સ લોડ કરવામાં નિષ્ફળ", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "સંક્ષિપ્ત CJK (文言)", "description": "ક્લાસિકલ-ચાઇનીઝ અલ્ટ્રા-સંક્ષિપ્ત શૈલી (ફક્ત ચાઇનીઝ માટે ઉપલબ્ધ)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "રાઉન્ડ-રોબિન અને રેન્ડમ કોમ્બોઝ પ્રથમ-સંદેશ હેશ દ્વારા સમગ્ર વાતચીતને એક કનેક્શન પર પિન કરવાને બદલે દરેક વિનંતી પર અલગ કનેક્શન પર ફરે છે. મલ્ટિ-ટર્ન ચેટ્સ માટે પ્રોમ્પ્ટ-કેશ હિટ્સ સાચવવા માટે આને બંધ રાખો. પ્રતિ-કોમ્બો ઓવરરાઇડ્સ અગ્રતા લે છે.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "ઓળખપત્ર રેડેક્શન", "credentialRedactionDesc": "પ્રદાતાઓ તરફ મોકલવામાં આવેલા સંદર્ભમાંથી અને પ્રતિસાદોમાંથી API કી, ટોકન્સ અને સિક્રેટ્સને રેડેક્ટ કરો.", "enableCredentialRedaction": "ઓળખપત્ર રેડેક્શન સક્ષમ કરો", @@ -8600,6 +8623,27 @@ }, "enableTitle": "એન્જિન સક્ષમ કરો", "enableDescription": "સ્ટેકમાં છેલ્લે ચાલે છે (RTK/Caveman લખાણ સાફ કરે તે પછી, OmniGlyph બાકીના ભાગને છબીઓમાં રૂપાંતરિત કરે છે) અને omniglyph મોડ દ્વારા સ્વતંત્ર રીતે પણ ચાલે છે. આ એક પૂર્વાવલોકન છે અને એન્ડ-ટુ-એન્ડ માન્યતા પૂર્ણ ન થાય ત્યાં સુધી ડિફૉલ્ટ રૂપે બંધ રહે છે.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "સાચવ્યું.", "saveFailed": "સાચવી શકાયું નથી.", "enableAria": "OmniGlyph એન્જિન સક્ષમ કરો", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "મહત્તમ", "grokAutoTopUpMonth": "મહિનો", "grokAdditionalCredits": "વધુ ક્રેડિટ", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "પ્રથમ ટોકન", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 32066ceb33..90db68373a 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "ציר זמן של בקשות חזותיות", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "פתח", "close": "סגור" }, - "noResults": "אין תוצאות", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "אין תוצאות" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "שיתוף מכסה", "discovery": "גילוי", "freeProviderRankings": "דירוג ספקים חינמיים", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "מסלולים חינמיים", "gamification": "משחוק", "leaderboard": "לוח מובילים", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "ספק זה הוצא משימוש", "riskNotice": { "title": "לפני שממשיכים", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "ספק עם סייגי שימוש — לחץ לפרטים", "oauth": "ספק זה משתמש בסשן המוצר הרשמי/OAuth שלך, שאינו מורשה לשימוש בפרוקסי/נתב. איננו ממליצים על שימוש אינטנסיבי בסוכנים אוטונומיים (בסגנון OpenCloud, תהליכים ארוכים מרובי שלבים, אצוות גדולות) — ספק ה-upstream עלול להגיב בהגבלת החשבון או בחסימתו. השימוש הוא על אחריותך בלבד.", "webCookie": "ספק זה מבצע אימות באמצעות עוגיות סשן הדפדפן שלך. שירות ה-upstream עלול לבטל את תוקף הסשן בכל עת, מה שידרוש ממך להתחבר מחדש. לא מומלץ לפעולות ארוכות ללא השגחה. השימוש הוא על אחריותך בלבד.", @@ -5107,9 +5111,9 @@ "cancel": "ביטול" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "מושבת", "enableProvider": "הפעל ספק", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "מדלג על {count} דגמים קיימים", "autoSync": "סנכרון אוטומטי", "autoSyncShort": "סנכרון", + "autoFetchModels": "משוך אוטומטית מודלים מהמקור", + "autoFetchModelsTooltip": "שחזר ושמור במטמון מודלים עליונים כשצריך", + "autoFetchModelsEnabled": "מודל upstream אוטומטי להורדה מופעל", + "autoFetchModelsDisabled": "איסוף אוטומטי של מודל עליון מושבת", + "autoFetchModelsToggleFailed": "נכשל בהחלפת מצב האיסוף האוטומטי של המודל העליון", + "autoFetchModelsPartialFailure": "כמה חיבורים עודכנו, אך מודל העל לא שונה בכל מקום", + "overridesUpstreamModel": "מעלים על עליון", + "overridesUpstreamModelHint": "ההגדרות שלך עוקפות את המודל העליון הזה", + "resetToUpstreamDefaults": "שחזר את ברירות המחדל של ה-upstream", + "resetToUpstreamDefaultsSuccess": "שוחזרו ברירות המחדל של המודל העליון", + "resetToUpstreamDefaultsFailed": "נכשל בשחזור ברירות המחדל של המודל העליון", "autoSyncTooltip": "רענן אוטומטית את רשימת הדגמים כל 24 שעות (ניתן להגדרה באמצעות MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "סנכרון אוטומטי מופעל - הדגמים יתרעננו מעת לעת", "autoSyncDisabled": "הסנכרון האוטומטי מושבת", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "שכתוב קריאות כלי web_fetch מובנות ל-/v1/web/fetch של OmniRoute.", "interceptionLoadError": "טעינת הגדרות היירוט נכשלה: {error}", "interceptionSaveError": "שמירת הגדרות היירוט נכשלה: {error}", - "ccAliasSectionTitle": "חשוף בקוד קלוד (claude/…)", - "ccAliasSectionHint": "פרסם את המודלים של ספק זה תחת claude/<provider>/<model> מזהי מראה כך שגילוי המודלים של שער קוד קלוד יוכל לרשום אותם. כבוי כברירת מחדל — הפעלת זה מכפילה את רשומות הקטלוג עבור כל הלקוחות.", - "ccAliasProviderLevelLabel": "ברירת מחדל של ספק", - "ccAliasModelOverridesLabel": "הגדרות לפי מודל", - "ccAliasModelOverrideAriaLabel": "החלפה עבור {modelId}", - "ccAliasStateInherit": "ירש", - "ccAliasStateOn": "על", - "ccAliasStateOff": "כבוי", - "ccAliasAddModelPlaceholder": "מזהה מודל (למשל, gpt-4o)", - "ccAliasAddModelButton": "הוסף ע override", - "ccAliasLoadError": "לא הצלחנו לטעון את הגדרות discovery-alias: {error}", - "ccAliasSaveError": "שגיאה בשמירת הגדרת discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "חבר את Galadriel באמצעות מפתח API.", "predibase": "קרדיט ניסיון חינם בסך $25 (תוקף ל-30 יום)", "chenzk": "שער תואם OpenAI עם קטלוג מודלים חי ב-chenzk.top.", - "freepik": "צור תמונות באמצעות ה-API של Mystic מבית Freepik.", + "magnific": "צור תמונות באמצעות ה-API של Mystic מבית Freepik.", "freetheai": "שער חינמי תואם OpenAI עם תמיכה במודלים בשיטת passthrough.", "g4f-gemini": "פרוקסי הפוך חינמי ללא מפתח מ-g4f.space ל-Gemini, מוגבל ל-5 בקשות לדקה.", "g4f-groq": "פרוקסי הפוך חינמי ללא מפתח מ-g4f.space ל-Groq, מוגבל ל-5 בקשות לדקה.", @@ -6209,6 +6224,7 @@ "claude": "חבר את Claude Code באמצעות תהליך ה-OAuth הקיים.", "cline": "חבר את Cline באמצעות תהליך ה-OAuth הקיים.", "cursor": "חבר את Cursor IDE באמצעות תהליך ה-OAuth הקיים.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "חבר את GitHub Copilot באמצעות תהליך ה-OAuth הקיים.", "gitlab-duo": "יישום OAuth עם הרשאות (scopes) של ai_features + read_user. הגדר את GITLAB_DUO_OAUTH_CLIENT_ID ואופציונלית את GITLAB_DUO_OAUTH_CLIENT_SECRET במופע OmniRoute זה.", "kilocode": "חבר את Kilo Code באמצעות תהליך ה-OAuth הקיים.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "ידיד קוד פתוח", "cheaperInferenceSupporterTooltip": "‏Cheaper Inference תומכת ב-OmniRoute כידידת קוד פתוח", "kimiPartnerLinkNote": "קישור שותף — תומך ב-OmniRoute ללא עלות נוספת עבורך", + "codexQuotaPools": "מאגרי מכסות Codex", + "codexPoolAvailable": "זמין", + "codexPoolPartiallyLimited": "מוגבל חלקית", + "codexPoolFullyLimited": "מוגבל לחלוטין", + "codexPoolLimited": "{count} מוגבלים", + "codexPoolQuotaExhausted": "המכסה נוצלה", + "codexPoolCoolingDown": "בתקופת המתנה", + "codexPoolUsed": "בשימוש", + "codexPoolUntil": "עד {value}", "anonymousFallbackTitle": "נפילה אנונימית", "anonymousFallbackDesc": "כאשר כל החיבורים המוגדרים נוצלו (מכסה, אשראי או תאריך תפוגה), השתמש זמנית בשכבת ללא מפתח של ספק זה. כבה כדי לדלג על ספק זה במקום לשלוח בקשות אנונימיות - מומלץ כאשר שכבת ללא מפתח דוחה אותן (401).", "anonymousFallbackEnabled": "גיבוי אנונימי מופעל עבור {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "הגדרות נקודת הקצה של המודל השמור", "searchByModelAria": "חפש לפי דגם", "selectSupportedEndpoint": "בחר לפחות נקודת קצה אחת נתמכת", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsDisabled": "איסוף אוטומטי של מודל עליון מושבת", - "autoFetchModelsEnabled": "מודל upstream אוטומטי להורדה מופעל", - "autoFetchModels": "משוך אוטומטית מודלים מהמקור", - "autoFetchModelsTooltip": "שחזר ושמור במטמון מודלים עליונים כשצריך", - "autoFetchModelsToggleFailed": "נכשל בהחלפת מצב האיסוף האוטומטי של המודל העליון", - "autoFetchModelsPartialFailure": "כמה חיבורים עודכנו, אך מודל העל לא שונה בכל מקום", - "overridesUpstreamModel": "מעלים על עליון", - "overridesUpstreamModelHint": "ההגדרות שלך עוקפות את המודל העליון הזה", - "resetToUpstreamDefaults": "שחזר את ברירות המחדל של ה-upstream", - "resetToUpstreamDefaultsFailed": "נכשל בשחזור ברירות המחדל של המודל העליון", - "resetToUpstreamDefaultsSuccess": "שוחזרו ברירות המחדל של המודל העליון" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "הגדרות", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "מילות מפתח חסומות", "customBannedSignalsDesc": "מילות מפתח נוספות שמפעילות זיהוי לחסימה קבועה של החשבון. מילות מפתח מובנות חלות תמיד.", "customBannedSignalsPlaceholder": "לדוגמה: api key revoked", @@ -7189,6 +7203,7 @@ "configured": "מוגדר", "none": "ללא", "modelOverrideValuePlaceholder": "ערך מספרי", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "הוסף מפתח-ערך", "noModelOverrides": "לא הוגדרו דריסות עבור מודל זה.", "modelOverrideLoadFailed": "טעינת דריסות המודל נכשלה", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK תמציתי (文言)", "description": "סגנון סיני קלאסי אולטרה-תמציתי (זמין עבור סינית בלבד)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "שילובי Round-robin ושילובים אקראיים עוברים לחיבור שונה בכל בקשה במקום להצמיד שיחה שלמה לחיבור יחיד לפי ה-hash של ההודעה הראשונה. השאר כבוי כדי לשמר פגיעות ב-prompt-cache עבור שיחות מרובות סבבים. דריסות ברמת השילוב מקבלות קדימות.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "הסתרת פרטי אימות", "credentialRedactionDesc": "הסתרת מפתחות API, טוקנים וסודות מההקשר הנשלח לספקים ומהתגובות.", "enableCredentialRedaction": "הפעלת הסתרת פרטי אימות", @@ -8600,6 +8623,27 @@ }, "enableTitle": "הפעל את המנוע", "enableDescription": "רץ אחרון במחסנית (לאחר ש-RTK/Caveman מנקה את הטקסט, OmniGlyph ממיר את השאר לתמונות) ורץ גם באופן עצמאי דרך מצב omniglyph. זוהי תצוגה מקדימה והיא נשארת כבויה כברירת מחדל עד להשלמת אימות מקצה לקצה.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "נשמר.", "saveFailed": "לא ניתן היה לשמור.", "enableAria": "הפעל את מנוע OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "מקסימום", "grokAutoTopUpMonth": "חודש", "grokAdditionalCredits": "קרדיטים נוספים", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "לוגר", "proxyTab": "פרוקסי", "budgetManagement": "ניהול תקציב", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "טוקן ראשון", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 4ee268acf7..9e353a269b 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -27,7 +27,7 @@ "copy": "प्रतिलिपि", "copied": "नकल की गई!", "enabled": "सक्षम", - "disabled": "विकलांग", + "disabled": "अक्षम", "active": "सक्रिय", "inactive": "निष्क्रिय", "noData": "कोई डेटा उपलब्ध नहीं है", @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "दृश्य अनुरोध समयरेखा", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "खोलें", "close": "बंद करें" }, - "noResults": "कोई परिणाम नहीं", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "कोई परिणाम नहीं" }, "webhooks": { "title": "वेबहुक", @@ -1739,8 +1739,8 @@ "quotaShare": "कोटा शेयर", "discovery": "खोज", "freeProviderRankings": "मुफ़्त प्रदाता रैंकिंग", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "मुफ़्त टियर", "gamification": "गेमीफिकेशन", "leaderboard": "लीडरबोर्ड", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "इस प्रदाता को अस्वीकृत कर दिया गया है", "riskNotice": { "title": "जारी रखने से पहले", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "उपयोग संबंधी चेतावनियों वाला प्रदाता — विवरण के लिए क्लिक करें", "oauth": "यह प्रदाता आपके आधिकारिक उत्पाद सत्र/OAuth का उपयोग करता है, जो प्रॉक्सी/राउटर उपयोग के लिए अधिकृत नहीं है। हम गहन स्वायत्त एजेंट उपयोग (OpenCloud-शैली, लंबे बहु-चरणीय प्रवाह, बड़े बैच) की अनुशंसा नहीं करते हैं — अपस्ट्रीम खाते को प्रतिबंधित या ब्लॉक करके प्रतिक्रिया दे सकता है। अपने जोखिम पर उपयोग करें।", "webCookie": "यह प्रदाता आपके वेब सत्र कुकीज़ के माध्यम से प्रमाणित करता है। अपस्ट्रीम सेवा किसी भी समय सत्र को अमान्य कर सकती है, जिससे आपको फिर से लॉग इन करने की आवश्यकता होगी। लंबे समय तक बिना निगरानी वाले संचालन के लिए अनुशंसित नहीं है। अपने जोखिम पर उपयोग करें।", @@ -5107,11 +5111,11 @@ "cancel": "रद्द करें" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, - "disabled": "विकलांग", + "disabled": "अक्षम", "enableProvider": "प्रदाता सक्षम करें", "disableProvider": "प्रदाता को अक्षम करें", "testResults": "परीक्षण के परिणाम", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "{count} मौजूदा मॉडल छोड़े जा रहे हैं", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "स्वचालित रूप से अपस्ट्रीम मॉडल लाएं", + "autoFetchModelsTooltip": "आवश्यक होने पर अपस्ट्रीम मॉडल लाएं और कैश करें", + "autoFetchModelsEnabled": "उपधारा मॉडल स्वचालित-लाने की सुविधा सक्षम है", + "autoFetchModelsDisabled": "उपधारा मॉडल ऑटो-फेच अक्षम किया गया", + "autoFetchModelsToggleFailed": "उपस्ट्रीम मॉडल ऑटो-फेच को टॉगल करने में विफल रहा", + "autoFetchModelsPartialFailure": "कुछ कनेक्शन अपडेट किए गए, लेकिन अपस्ट्रीम मॉडल ऑटो-फेच हर जगह नहीं बदला", + "overridesUpstreamModel": "उपस्ट्रीम को ओवरराइड करता है", + "overridesUpstreamModelHint": "आपकी सेटिंग्स इस अपस्ट्रीम मॉडल को ओवरराइड करती हैं", + "resetToUpstreamDefaults": "उपधारा डिफ़ॉल्ट्स को पुनर्स्थापित करें", + "resetToUpstreamDefaultsSuccess": "उपधारा मॉडल डिफ़ॉल्ट्स को पुनर्स्थापित किया गया", + "resetToUpstreamDefaultsFailed": "उपधारा मॉडल डिफ़ॉल्ट्स को पुनर्स्थापित करने में विफल", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "मूल web_fetch टूल कॉल को OmniRoute के /v1/web/fetch पर रीराइट करें।", "interceptionLoadError": "इंटरसेप्शन सेटिंग्स लोड करने में विफल: {error}", "interceptionSaveError": "इंटरसेप्शन सेटिंग्स सहेजने में विफल: {error}", - "ccAliasSectionTitle": "Claude कोड में एक्सपोज़ करें (claude/…)", - "ccAliasSectionHint": "इस प्रदाता के मॉडल को claude/<provider>/<model> मिरर आईडी के तहत विज्ञापित करें ताकि Claude Code का गेटवे मॉडल खोज उन्हें सूचीबद्ध कर सके। डिफ़ॉल्ट रूप से बंद — इसे सक्षम करने से सभी ग्राहकों के लिए कैटलॉग प्रविष्टियाँ दोगुनी हो जाती हैं।", - "ccAliasProviderLevelLabel": "प्रदाता डिफ़ॉल्ट", - "ccAliasModelOverridesLabel": "प्रति-मॉडल ओवरराइड्स", - "ccAliasModelOverrideAriaLabel": "{modelId} के लिए ओवरराइड", - "ccAliasStateInherit": "विरासत", - "ccAliasStateOn": "चालू", - "ccAliasStateOff": "बंद", - "ccAliasAddModelPlaceholder": "मॉडल आईडी (जैसे gpt-4o)", - "ccAliasAddModelButton": "ओवरराइड जोड़ें", - "ccAliasLoadError": "डिस्कवरी-एलियस सेटिंग्स लोड करने में विफल: {error}", - "ccAliasSaveError": "डिस्कवरी-उपनाम सेटिंग को सहेजने में विफल: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "API कुंजी के साथ Galadriel को कनेक्ट करें।", "predibase": "$25 मुफ्त ट्रायल क्रेडिट (30 दिनों की वैधता)", "chenzk": "chenzk.top पर लाइव मॉडल कैटलॉग के साथ OpenAI-संगत गेटवे।", - "freepik": "Freepik के Mystic API के साथ चित्र जनरेट करें।", + "magnific": "Freepik के Mystic API के साथ चित्र जनरेट करें।", "freetheai": "पासथ्रू मॉडल समर्थन के साथ मुफ्त OpenAI-संगत गेटवे।", "g4f-gemini": "Gemini के लिए मुफ्त बिना-कुंजी वाला g4f.space रिवर्स प्रॉक्सी, प्रति मिनट 5 अनुरोधों तक सीमित।", "g4f-groq": "Groq के लिए मुफ्त बिना-कुंजी वाला g4f.space रिवर्स प्रॉक्सी, प्रति मिनट 5 अनुरोधों तक सीमित।", @@ -6209,6 +6224,7 @@ "claude": "मौजूदा OAuth फ़्लो के साथ Claude Code को कनेक्ट करें।", "cline": "मौजूदा OAuth फ़्लो के साथ Cline को कनेक्ट करें।", "cursor": "मौजूदा OAuth फ़्लो के साथ Cursor IDE को कनेक्ट करें।", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "मौजूदा OAuth फ़्लो के साथ GitHub Copilot को कनेक्ट करें।", "gitlab-duo": "ai_features + read_user स्कोप के साथ OAuth एप्लिकेशन। इस OmniRoute इंस्टेंस पर GITLAB_DUO_OAUTH_CLIENT_ID और वैकल्पिक रूप से GITLAB_DUO_OAUTH_CLIENT_SECRET कॉन्फ़िगर करें।", "kilocode": "मौजूदा OAuth फ़्लो के साथ Kilo Code को कनेक्ट करें।", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "ओपन सोर्स मित्र", "cheaperInferenceSupporterTooltip": "Cheaper Inference एक ओपन सोर्स मित्र के रूप में OmniRoute का समर्थन करता है", "kimiPartnerLinkNote": "पार्टनर लिंक — बिना किसी अतिरिक्त लागत के OmniRoute का समर्थन करता है", + "codexQuotaPools": "Codex कोटा पूल", + "codexPoolAvailable": "उपलब्ध", + "codexPoolPartiallyLimited": "आंशिक रूप से सीमित", + "codexPoolFullyLimited": "पूरी तरह सीमित", + "codexPoolLimited": "{count} सीमित", + "codexPoolQuotaExhausted": "कोटा समाप्त", + "codexPoolCoolingDown": "कूलडाउन जारी", + "codexPoolUsed": "उपयोग किया गया", + "codexPoolUntil": "{value} तक", "anonymousFallbackTitle": "गुमनाम बैकअप", "anonymousFallbackDesc": "जब सभी कॉन्फ़िगर की गई कनेक्शन समाप्त हो जाते हैं (कोटा, क्रेडिट, या समाप्ति), तो अस्थायी रूप से इस प्रदाता की कीलेस श्रेणी का उपयोग करें। इस प्रदाता को छोड़ने के लिए बंद करें बजाय गुमनाम अनुरोध भेजने के — जब कीलेस श्रेणी उन्हें अस्वीकार करती है (401) तो यह अनुशंसित है।", "anonymousFallbackEnabled": "{provider} के लिए गुमनाम फॉलबैक सक्षम किया गया", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "सहेजे गए मॉडल एंडपॉइंट सेटिंग्स", "searchByModelAria": "मॉडल द्वारा खोजें", "selectSupportedEndpoint": "कम से कम एक समर्थित एंडपॉइंट चुनें", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "स्वचालित रूप से अपस्ट्रीम मॉडल लाएं", - "autoFetchModelsEnabled": "उपधारा मॉडल स्वचालित-लाने की सुविधा सक्षम है", - "autoFetchModelsDisabled": "उपधारा मॉडल ऑटो-फेच अक्षम किया गया", - "autoFetchModelsTooltip": "आवश्यक होने पर अपस्ट्रीम मॉडल लाएं और कैश करें", - "overridesUpstreamModel": "उपस्ट्रीम को ओवरराइड करता है", - "autoFetchModelsToggleFailed": "उपस्ट्रीम मॉडल ऑटो-फेच को टॉगल करने में विफल रहा", - "overridesUpstreamModelHint": "आपकी सेटिंग्स इस अपस्ट्रीम मॉडल को ओवरराइड करती हैं", - "autoFetchModelsPartialFailure": "कुछ कनेक्शन अपडेट किए गए, लेकिन अपस्ट्रीम मॉडल ऑटो-फेच हर जगह नहीं बदला", - "resetToUpstreamDefaults": "उपधारा डिफ़ॉल्ट्स को पुनर्स्थापित करें", - "resetToUpstreamDefaultsSuccess": "उपधारा मॉडल डिफ़ॉल्ट्स को पुनर्स्थापित किया गया", - "resetToUpstreamDefaultsFailed": "उपधारा मॉडल डिफ़ॉल्ट्स को पुनर्स्थापित करने में विफल" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "सेटिंग्स", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "प्रतिबंधित कीवर्ड", "customBannedSignalsDesc": "अतिरिक्त कीवर्ड जो स्थायी खाता प्रतिबंध पहचान को ट्रिगर करते हैं। अंतर्निहित कीवर्ड हमेशा लागू होते हैं।", "customBannedSignalsPlaceholder": "उदा. api key revoked", @@ -6724,7 +6738,7 @@ "global": "वैश्विक", "rule": "नियम", "enabled": "सक्षम", - "disabled": "विकलांग", + "disabled": "अक्षम", "nodeCount": "नोड्स: {count}", "needsCoreCount": "{count} को स्थानीय कोर की आवश्यकता है", "lastSynced": "अंतिम बार समन्वयित: {time}", @@ -6995,7 +7009,7 @@ "systemActor": "प्रणाली", "ipAccessControl": "आईपी ​​अभिगम नियंत्रण", "ipAccessControlDesc": "विशिष्ट आईपी पते को ब्लॉक करें या अनुमति दें", - "ipModeDisabled": "विकलांग", + "ipModeDisabled": "अक्षम", "ipModeBlacklist": "काली सूची", "ipModeWhitelist": "श्वेतसूची", "ipModeWhitelistPriority": "डब्ल्यूएल प्राथमिकता", @@ -7189,6 +7203,7 @@ "configured": "कॉन्फ़िगर किया गया", "none": "कोई नहीं", "modelOverrideValuePlaceholder": "संख्यात्मक मान", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "कुंजी मान जोड़ें", "noModelOverrides": "इस मॉडल के लिए कोई ओवरराइड कॉन्फ़िगर नहीं किया गया है।", "modelOverrideLoadFailed": "मॉडल ओवरराइड लोड करने में विफल", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "संक्षिप्त CJK (文言)", "description": "शास्त्रीय-चीनी अति-संक्षिप्त शैली (केवल चीनी भाषा के लिए उपलब्ध)।" @@ -7919,7 +7938,7 @@ "triggerLabel": "ट्रिगर", "effectLabel": "प्रभाव", "statusEnabled": "सक्षम", - "statusDisabled": "विकलांग", + "statusDisabled": "अक्षम", "resilienceRequestQueueScope": "प्रति अनुरोध कतार", "resilienceRequestQueueTrigger": "अपस्ट्रीम पर भेजने से पहले", "resilienceRequestQueueEffect": "अनुरोधों को कतारबद्ध करता है, समवर्तीता को सीमित करता है, और कॉलों को रिक्त स्थान देता है", @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "राउंड-रॉबिन और रैंडम कॉम्बो पहले-संदेश हैश द्वारा पूरी बातचीत को एक कनेक्शन पर पिन करने के बजाय हर अनुरोध पर एक अलग कनेक्शन पर रोटेट होते हैं। मल्टी-टर्न चैट के लिए प्रॉम्प्ट-कैश हिट्स को बनाए रखने के लिए इसे बंद रखें। प्रति-कॉम्बो ओवरराइड को प्राथमिकता दी जाती है।", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "क्रेडेंशियल रिडैक्शन", "credentialRedactionDesc": "प्रदाताओं को भेजे गए संदर्भ और प्रतिक्रियाओं से API keys, tokens और secrets को रिडैक्ट करें।", "enableCredentialRedaction": "क्रेडेंशियल रिडैक्शन सक्षम करें", @@ -8600,6 +8623,27 @@ }, "enableTitle": "इंजन सक्षम करें", "enableDescription": "स्टैक में सबसे अंत में चलता है (RTK/Caveman द्वारा टेक्स्ट साफ़ करने के बाद, OmniGlyph शेष को छवियों में परिवर्तित करता है) और omniglyph मोड के माध्यम से स्टैंडअलोन भी चलता है। यह एक पूर्वावलोकन है और एंड-टू-एंड सत्यापन पूरा होने तक डिफ़ॉल्ट रूप से बंद रहता है।", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "सहेजा गया।", "saveFailed": "सहेजा नहीं जा सका।", "enableAria": "OmniGlyph इंजन सक्षम करें", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "अधिकतम", "grokAutoTopUpMonth": "महीना", "grokAdditionalCredits": "अतिरिक्त श्रेय", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "लकड़हारा", "proxyTab": "प्रॉक्सी", "budgetManagement": "बजट प्रबंधन", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "पहला टोकन", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 93bbcff7af..4f8ee2278b 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Vizuális kérelem idővonal", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "megnyitás", "close": "bezárás" }, - "noResults": "Nincs találat", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Nincs találat" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "Kvótamegosztás", "discovery": "Felfedezés", "freeProviderRankings": "Ingyenes szolgáltatók rangsora", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Ingyenes szintek", "gamification": "Játékosítás", "leaderboard": "Ranglista", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Ez a szolgáltató elavult", "riskNotice": { "title": "Mielőtt folytatná", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Használati figyelmeztetésekkel rendelkező szolgáltató — kattintson a részletekért", "oauth": "Ez a szolgáltató a hivatalos termékmunkamenetet/OAuth-ot használja, amely nem engedélyezett proxy/router használatra. Nem javasoljuk az intenzív autonóm ágens használatot (OpenCloud-stílusú, hosszú, többlépéses folyamatok, nagy kötegek) — az upstream szolgáltató a fiók korlátozásával vagy kitiltásával reagálhat. Saját felelősségre használja.", "webCookie": "Ez a szolgáltató a webes munkamenet-sütik segítségével hitelesít. Az upstream szolgáltatás bármikor érvénytelenítheti a munkamenetet, ami újbóli bejelentkezést igényel. Hosszú, felügyelet nélküli műveletekhez nem ajánlott. Saját felelősségre használja.", @@ -5107,9 +5111,9 @@ "cancel": "Mégse" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Letiltva", "enableProvider": "Szolgáltató engedélyezése", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "{count} meglévő modell kihagyása", "autoSync": "Automatikus szinkronizálás", "autoSyncShort": "Szinkronizálás", + "autoFetchModels": "Automatikus frissítés a felfelé irányuló modellekből", + "autoFetchModelsTooltip": "Töltse le és tárolja a feljebb lévő modelleket, amikor szükséges", + "autoFetchModelsEnabled": "Felfelé irányuló modell automatikus lekérése engedélyezve", + "autoFetchModelsDisabled": "Felfelé irányuló modell automatikus lekérése letiltva", + "autoFetchModelsToggleFailed": "Nem sikerült átkapcsolni a feljebb lévő modell automatikus lekérdezését", + "autoFetchModelsPartialFailure": "Néhány kapcsolat frissítve lett, de a felfelé irányuló modell automatikus lekérése nem változott meg mindenhol", + "overridesUpstreamModel": "Felülírja a felfelé irányuló változtatásokat", + "overridesUpstreamModelHint": "A beállításai felülírják ezt a fenti modellt", + "resetToUpstreamDefaults": "Állítsa vissza az alapértelmezett beállításokat", + "resetToUpstreamDefaultsSuccess": "Visszaállítottuk az upstream modell alapértelmezett beállításait", + "resetToUpstreamDefaultsFailed": "Nem sikerült visszaállítani a fenti modell alapértelmezett beállításait", "autoSyncTooltip": "A modelllista automatikus frissítése 24 óránként (konfigurálható: MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Automatikus szinkronizálás engedélyezve – a modellek rendszeresen frissülnek", "autoSyncDisabled": "Az automatikus szinkronizálás letiltva", @@ -5439,17 +5454,17 @@ "interceptionLoadError": "Nem sikerült betölteni az elfogási beállításokat: {error}", "interceptionSaveError": "Nem sikerült menteni az elfogási beállításokat: {error}", "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", - "ccAliasSectionHint": "Hirdesse meg ennek a szolgáltatónak a modelljeit a claude/<provider>/<model> tükörazonosítók alatt, hogy a Claude Code átjáró modell felfedezése listázhassa őket. Alapértelmezés szerint ki van kapcsolva — ennek engedélyezése megduplázza a katalógusbejegyzéseket minden ügyfél számára.", - "ccAliasProviderLevelLabel": "Szolgáltató alapértelmezett", - "ccAliasModelOverridesLabel": "Per-modell felülírások", - "ccAliasModelOverrideAriaLabel": "Felülírás a(z) {modelId} számára", - "ccAliasStateInherit": "Örököl", - "ccAliasStateOn": "Be- és kikapcsolás", - "ccAliasStateOff": "Ki", - "ccAliasAddModelPlaceholder": "Modell azonosító (pl. gpt-4o)", - "ccAliasAddModelButton": "Add Override", - "ccAliasLoadError": "Nem sikerült betölteni a discovery-alias beállításokat: {error}", - "ccAliasSaveError": "Nem sikerült elmenteni a discovery-alias beállítást: {error}", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "A Galadriel összekapcsolása egy API-kulccsal.", "predibase": "25 $ ingyenes próbaverziós kredit (30 napos érvényesség)", "chenzk": "OpenAI-kompatibilis átjáró élő modellkatalógussal a chenzk.top címen.", - "freepik": "Képek generálása a Freepik Mystic API-jával.", + "magnific": "Képek generálása a Freepik Mystic API-jával.", "freetheai": "Ingyenes OpenAI-kompatibilis átjáró átmenő (passthrough) modelltámogatással.", "g4f-gemini": "Ingyenes, kulcs nélküli g4f.space fordított proxy a Geminihez, percenként legfeljebb 5 kéréssel.", "g4f-groq": "Ingyenes, kulcs nélküli g4f.space fordított proxy a Groq-hoz, percenként legfeljebb 5 kéréssel.", @@ -6209,6 +6224,7 @@ "claude": "A Claude Code összekapcsolása a meglévő OAuth-folyamattal.", "cline": "A Cline összekapcsolása a meglévő OAuth-folyamattal.", "cursor": "A Cursor IDE összekapcsolása a meglévő OAuth-folyamattal.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "A GitHub Copilot összekapcsolása a meglévő OAuth-folyamattal.", "gitlab-duo": "OAuth alkalmazás ai_features + read_user hatókörökkel. Konfigurálja a GITLAB_DUO_OAUTH_CLIENT_ID és opcionálisan a GITLAB_DUO_OAUTH_CLIENT_SECRET változókat ezen az OmniRoute példányon.", "kilocode": "A Kilo Code összekapcsolása a meglévő OAuth-folyamattal.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Nyílt forráskódú barát", "cheaperInferenceSupporterTooltip": "A Cheaper Inference nyílt forráskódú barátként támogatja az OmniRoute-ot", "kimiPartnerLinkNote": "Partnerhivatkozás — az Ön számára további költség nélkül támogatja az OmniRoute-ot", + "codexQuotaPools": "Codex-kvótakészletek", + "codexPoolAvailable": "Elérhető", + "codexPoolPartiallyLimited": "Részben korlátozott", + "codexPoolFullyLimited": "Teljesen korlátozott", + "codexPoolLimited": "{count} korlátozott", + "codexPoolQuotaExhausted": "A kvóta kimerült", + "codexPoolCoolingDown": "Várakozási időszakban", + "codexPoolUsed": "felhasználva", + "codexPoolUntil": "Eddig: {value}", "anonymousFallbackTitle": "Névtelen visszaesés", "anonymousFallbackDesc": "Amikor az összes konfigurált kapcsolat kimerült (kvóta, kreditek vagy lejárat), ideiglenesen használja ezt a szolgáltató kulcs nélküli szintjét. Kapcsolja ki, hogy kihagyja ezt a szolgáltatót a névtelen kérések küldése helyett — ajánlott, ha a kulcs nélküli szint elutasítja őket (401).", "anonymousFallbackEnabled": "Névtelen visszaesés engedélyezve a(z) {provider} számára", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Mentett modell végpont beállításai", "searchByModelAria": "Keresés modell szerint", "selectSupportedEndpoint": "Válasszon ki legalább egy támogatott végpontot", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsDisabled": "Felfelé irányuló modell automatikus lekérése letiltva", - "autoFetchModelsEnabled": "Felfelé irányuló modell automatikus lekérése engedélyezve", - "autoFetchModelsTooltip": "Töltse le és tárolja a feljebb lévő modelleket, amikor szükséges", - "autoFetchModels": "Automatikus frissítés a felfelé irányuló modellekből", - "autoFetchModelsToggleFailed": "Nem sikerült átkapcsolni a feljebb lévő modell automatikus lekérdezését", - "autoFetchModelsPartialFailure": "Néhány kapcsolat frissítve lett, de a felfelé irányuló modell automatikus lekérése nem változott meg mindenhol", - "overridesUpstreamModel": "Felülírja a felfelé irányuló változtatásokat", - "overridesUpstreamModelHint": "A beállításai felülírják ezt a fenti modellt", - "resetToUpstreamDefaults": "Állítsa vissza az alapértelmezett beállításokat", - "resetToUpstreamDefaultsSuccess": "Visszaállítottuk az upstream modell alapértelmezett beállításait", - "resetToUpstreamDefaultsFailed": "Nem sikerült visszaállítani a fenti modell alapértelmezett beállításait" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Beállítások elemre", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Tiltott kulcsszavak", "customBannedSignalsDesc": "További kulcsszavak, amelyek végleges fióktiltás észlelését váltják ki. A beépített kulcsszavak mindig érvényesek.", "customBannedSignalsPlaceholder": "pl. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "konfigurálva", "none": "Nincs", "modelOverrideValuePlaceholder": "Numerikus érték", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Kulcs-érték hozzáadása", "noModelOverrides": "Nincsenek felülírások konfigurálva ehhez a modellhez.", "modelOverrideLoadFailed": "Nem sikerült betölteni a modellfelülírásokat", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Tömör CJK (文言)", "description": "Klasszikus kínai ultratömör stílus (csak kínai nyelven érhető el)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "A round-robin és a véletlenszerű kombinációk minden kérésnél új kapcsolatra váltanak, ahelyett, hogy a teljes beszélgetést egyetlen kapcsolathoz rögzítenék az első üzenet hash-e alapján. Hagyja kikapcsolva, hogy megőrizze a prompt-cache találatokat a többfordulós csevegéseknél. A kombinációnkénti felülírások elsőbbséget élveznek.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Hitelesítési adatok kitakarása", "credentialRedactionDesc": "API-kulcsok, tokenek és titkok kitakarása a szolgáltatóknak küldött kontextusból és a válaszokból.", "enableCredentialRedaction": "Hitelesítési adatok kitakarásának engedélyezése", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Motor engedélyezése", "enableDescription": "Utolsóként fut a veremben (miután az RTK/Caveman megtisztítja a szöveget, az OmniGlyph képekké alakítja a maradékot), valamint önállóan is fut a omniglyph módon keresztül. Ez egy előnézet, és alapértelmezés szerint kikapcsolva marad a végpontok közötti ellenőrzés befejezéséig.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Mentve.", "saveFailed": "Nem sikerült menteni.", "enableAria": "Az OmniGlyph motor engedélyezése", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "hónap", "grokAdditionalCredits": "További Kiadások", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Költségvetési menedzsment", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Első token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index bf366dd1e5..bc841a35d6 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Garis waktu permintaan visual", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "buka", "close": "tutup" }, - "noResults": "Tidak ada hasil", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Tidak ada hasil" }, "webhooks": { "title": "Webhook", @@ -1739,8 +1739,8 @@ "quotaShare": "Pangsa Kuota", "discovery": "Penemuan", "freeProviderRankings": "Peringkat Penyedia Gratis", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Tingkat Gratis", "gamification": "Gamifikasi", "leaderboard": "Papan Peringkat", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Penyedia ini sudah tidak digunakan lagi", "riskNotice": { "title": "Sebelum melanjutkan", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Penyedia dengan catatan penggunaan — klik untuk detail", "oauth": "Penyedia ini menggunakan sesi/OAuth produk resmi Anda, yang tidak diizinkan untuk penggunaan proxy/router. Kami tidak menyarankan penggunaan agen otonom yang intensif (gaya OpenCloud, alur multi-langkah yang panjang, batch besar) — upstream dapat bereaksi dengan membatasi atau memblokir akun. Gunakan dengan risiko Anda sendiri.", "webCookie": "Penyedia ini mengautentikasi melalui cookie sesi web Anda. Layanan upstream dapat membatalkan sesi kapan saja, mengharuskan Anda untuk masuk kembali. Tidak disarankan untuk operasi tanpa pengawasan yang lama. Gunakan dengan risiko Anda sendiri.", @@ -5107,9 +5111,9 @@ "cancel": "Batal" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Dengan disabilitas", "enableProvider": "Aktifkan penyedia", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Melewatkan {count} model yang sudah ada", "autoSync": "Sinkronisasi Otomatis", "autoSyncShort": "Sinkronkan", + "autoFetchModels": "Ambil model upstream secara otomatis", + "autoFetchModelsTooltip": "Ambil dan simpan model upstream saat diperlukan", + "autoFetchModelsEnabled": "Model upstream auto-fetch diaktifkan", + "autoFetchModelsDisabled": "Pengambilan otomatis model upstream dinonaktifkan", + "autoFetchModelsToggleFailed": "Gagal untuk mengubah pengambilan otomatis model upstream", + "autoFetchModelsPartialFailure": "Beberapa koneksi diperbarui, tetapi pengambilan otomatis model upstream tidak berubah di semua tempat", + "overridesUpstreamModel": "Mengganti upstream", + "overridesUpstreamModelHint": "Pengaturan Anda menimpa model upstream ini", + "resetToUpstreamDefaults": "Pulihkan pengaturan default upstream", + "resetToUpstreamDefaultsSuccess": "Mengembalikan pengaturan default model upstream", + "resetToUpstreamDefaultsFailed": "Gagal mengembalikan pengaturan model upstream ke default", "autoSyncTooltip": "Segarkan daftar model secara otomatis setiap 24 jam (dapat dikonfigurasi melalui MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Sinkronisasi otomatis diaktifkan — model akan disegarkan secara berkala", "autoSyncDisabled": "Sinkronisasi otomatis dinonaktifkan", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Tulis ulang panggilan alat web_fetch bawaan ke /v1/web/fetch milik OmniRoute.", "interceptionLoadError": "Gagal memuat pengaturan intersepsi: {error}", "interceptionSaveError": "Gagal menyimpan pengaturan intersepsi: {error}", - "ccAliasSectionTitle": "Expose di Claude Code (claude/…)", - "ccAliasSectionHint": "Iklankan model penyedia ini di bawah claude/<provider>/<model> ID cermin agar penemuan model gateway Claude Code dapat mencantumkannya. Mati secara default — mengaktifkan ini menggandakan entri katalog untuk semua klien.", - "ccAliasProviderLevelLabel": "Penyedia default", - "ccAliasModelOverridesLabel": "Penggantian per-model", - "ccAliasModelOverrideAriaLabel": "Override untuk {modelId}", - "ccAliasStateInherit": "Warisi", - "ccAliasStateOn": "Hidup", - "ccAliasStateOff": "Matikan", - "ccAliasAddModelPlaceholder": "Model id (misalnya gpt-4o)", - "ccAliasAddModelButton": "Tambahkan override", - "ccAliasLoadError": "Gagal memuat pengaturan discovery-alias: {error}", - "ccAliasSaveError": "Gagal menyimpan pengaturan discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Hubungkan Galadriel dengan kunci API.", "predibase": "Kredit uji coba gratis $25 (validitas 30 hari)", "chenzk": "Gateway yang kompatibel dengan OpenAI dengan katalog model langsung di chenzk.top.", - "freepik": "Hasilkan gambar dengan Mystic API dari Freepik.", + "magnific": "Hasilkan gambar dengan Mystic API dari Freepik.", "freetheai": "Gateway gratis yang kompatibel dengan OpenAI dengan dukungan model passthrough.", "g4f-gemini": "Proksi terbalik g4f.space tanpa kunci gratis ke Gemini, dibatasi hingga 5 permintaan per menit.", "g4f-groq": "Proksi terbalik g4f.space tanpa kunci gratis ke Groq, dibatasi hingga 5 permintaan per menit.", @@ -6209,6 +6224,7 @@ "claude": "Hubungkan Claude Code dengan alur OAuth yang ada.", "cline": "Hubungkan Cline dengan alur OAuth yang ada.", "cursor": "Hubungkan Cursor IDE dengan alur OAuth yang ada.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Hubungkan GitHub Copilot dengan alur OAuth yang ada.", "gitlab-duo": "Aplikasi OAuth dengan cakupan ai_features + read_user. Konfigurasikan GITLAB_DUO_OAUTH_CLIENT_ID dan secara opsional GITLAB_DUO_OAUTH_CLIENT_SECRET pada instans OmniRoute ini.", "kilocode": "Hubungkan Kilo Code dengan alur OAuth yang ada.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Teman Open Source", "cheaperInferenceSupporterTooltip": "Cheaper Inference mendukung OmniRoute sebagai Teman Open Source", "kimiPartnerLinkNote": "Tautan mitra — mendukung OmniRoute tanpa biaya tambahan bagi Anda", + "codexQuotaPools": "Kumpulan kuota Codex", + "codexPoolAvailable": "Tersedia", + "codexPoolPartiallyLimited": "Dibatasi sebagian", + "codexPoolFullyLimited": "Dibatasi sepenuhnya", + "codexPoolLimited": "{count} dibatasi", + "codexPoolQuotaExhausted": "Kuota habis", + "codexPoolCoolingDown": "Dalam masa tunggu", + "codexPoolUsed": "terpakai", + "codexPoolUntil": "Hingga {value}", "anonymousFallbackTitle": "Fallback anonim", "anonymousFallbackDesc": "Ketika semua koneksi yang dikonfigurasi habis (kuota, kredit, atau masa berlaku), gunakan sementara tingkat tanpa kunci penyedia ini. Matikan untuk melewati penyedia ini alih-alih mengirim permintaan anonim — disarankan ketika tingkat tanpa kunci menolak permintaan tersebut (401).", "anonymousFallbackEnabled": "Fallback anonim diaktifkan untuk {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Pengaturan endpoint model yang disimpan", "searchByModelAria": "Cari berdasarkan model", "selectSupportedEndpoint": "Pilih setidaknya satu endpoint yang didukung", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Ambil model upstream secara otomatis", - "autoFetchModelsEnabled": "Model upstream auto-fetch diaktifkan", - "autoFetchModelsTooltip": "Ambil dan simpan model upstream saat diperlukan", - "autoFetchModelsDisabled": "Pengambilan otomatis model upstream dinonaktifkan", - "autoFetchModelsToggleFailed": "Gagal untuk mengubah pengambilan otomatis model upstream", - "overridesUpstreamModel": "Mengganti upstream", - "autoFetchModelsPartialFailure": "Beberapa koneksi diperbarui, tetapi pengambilan otomatis model upstream tidak berubah di semua tempat", - "resetToUpstreamDefaults": "Pulihkan pengaturan default upstream", - "overridesUpstreamModelHint": "Pengaturan Anda menimpa model upstream ini", - "resetToUpstreamDefaultsSuccess": "Mengembalikan pengaturan default model upstream", - "resetToUpstreamDefaultsFailed": "Gagal mengembalikan pengaturan model upstream ke default" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Pengaturan", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Kata Kunci yang Diblokir", "customBannedSignalsDesc": "Kata kunci tambahan yang memicu deteksi pemblokiran akun permanen. Kata kunci bawaan selalu berlaku.", "customBannedSignalsPlaceholder": "mis. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "dikonfigurasi", "none": "Tidak ada", "modelOverrideValuePlaceholder": "Nilai numerik", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Tambahkan nilai kunci", "noModelOverrides": "Tidak ada override yang dikonfigurasi untuk model ini.", "modelOverrideLoadFailed": "Gagal memuat override model", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK Ringkas (文言)", "description": "Gaya ultra-ringkas Tionghoa Klasik (hanya tersedia untuk bahasa Tionghoa)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Kombo round-robin dan acak berganti ke koneksi yang berbeda pada setiap permintaan alih-alih menyematkan seluruh percakapan ke satu koneksi berdasarkan hash pesan pertama. Biarkan nonaktif untuk mempertahankan hit prompt-cache pada obrolan multi-turn. Penggantian per-kombo lebih diutamakan.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Redaksi Kredensial", "credentialRedactionDesc": "Redaksi kunci API, token, dan rahasia dari konteks yang dikirim ke penyedia dan dari respons.", "enableCredentialRedaction": "Aktifkan redaksi kredensial", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Aktifkan mesin", "enableDescription": "Berjalan terakhir dalam tumpukan (setelah RTK/Caveman membersihkan teks, OmniGlyph mengonversi sisanya menjadi gambar) dan juga berjalan mandiri melalui mode omniglyph. Ini adalah pratinjau dan tetap dinonaktifkan secara default hingga validasi menyeluruh selesai.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Disimpan.", "saveFailed": "Tidak dapat menyimpan.", "enableAria": "Aktifkan mesin OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "maksimum", "grokAutoTopUpMonth": "bulan", "grokAdditionalCredits": "Kredit Tambahan", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "penebang", "proxyTab": "Proksi", "budgetManagement": "Manajemen Anggaran", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Token Pertama", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index ca21aecc96..b9c7e6706c 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Garis Waktu Permintaan Visual", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "buka", "close": "tutup" }, - "noResults": "Tidak ada hasil", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Tidak ada hasil" }, "webhooks": { "title": "Webhook", @@ -1739,8 +1739,8 @@ "quotaShare": "Pembagian Kuota", "discovery": "Penemuan", "freeProviderRankings": "Peringkat Penyedia Gratis", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Tingkat Gratis", "gamification": "Gamifikasi", "leaderboard": "Papan Peringkat", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Penyedia ini sudah tidak digunakan lagi", "riskNotice": { "title": "Sebelum melanjutkan", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Penyedia dengan peringatan penggunaan — klik untuk detail", "oauth": "Penyedia ini menggunakan sesi produk/OAuth resmi Anda, yang tidak diizinkan untuk penggunaan proksi/router. Kami tidak menyarankan penggunaan agen otonom yang intensif (gaya OpenCloud, alur multi-langkah yang panjang, batch besar) — upstream mungkin bereaksi dengan membatasi atau memblokir akun. Gunakan dengan risiko Anda sendiri.", "webCookie": "Penyedia ini mengautentikasi melalui kuki sesi web Anda. Layanan upstream dapat membatalkan sesi kapan saja, mengharuskan Anda untuk masuk kembali. Tidak disarankan untuk operasi jangka panjang tanpa pengawasan. Gunakan dengan risiko Anda sendiri.", @@ -5107,9 +5111,9 @@ "cancel": "Batal" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabled", "enableProvider": "Enable provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Skipping {count} existing models", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "Ambil model upstream secara otomatis", + "autoFetchModelsTooltip": "Ambil dan simpan model upstream saat diperlukan", + "autoFetchModelsEnabled": "Model hulu auto-fetch diaktifkan", + "autoFetchModelsDisabled": "Model upstream auto-fetch dinonaktifkan", + "autoFetchModelsToggleFailed": "Gagal untuk mengubah model upstream auto-fetch", + "autoFetchModelsPartialFailure": "Beberapa koneksi diperbarui, tetapi pengambilan otomatis model upstream tidak berubah di mana-mana", + "overridesUpstreamModel": "Mengganti upstream", + "overridesUpstreamModelHint": "Pengaturan Anda menimpa model upstream ini", + "resetToUpstreamDefaults": "Kembalikan pengaturan default upstream", + "resetToUpstreamDefaultsSuccess": "Mengembalikan pengaturan model upstream ke default", + "resetToUpstreamDefaultsFailed": "Gagal mengembalikan pengaturan model upstream ke default", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Tulis ulang panggilan alat web_fetch bawaan ke /v1/web/fetch milik OmniRoute.", "interceptionLoadError": "Gagal memuat pengaturan intersepsi: {error}", "interceptionSaveError": "Gagal menyimpan pengaturan intersepsi: {error}", - "ccAliasSectionTitle": "Expose di Claude Code (claude/…)", - "ccAliasSectionHint": "Iklankan model penyedia ini di bawah claude/<provider>/<model> ID cermin agar penemuan model gateway Claude Code dapat mencantumkannya. Mati secara default — mengaktifkan ini menggandakan entri katalog untuk semua klien.", - "ccAliasProviderLevelLabel": "Penyedia default", - "ccAliasModelOverridesLabel": "Penggantian per-model", - "ccAliasModelOverrideAriaLabel": "Override untuk {modelId}", - "ccAliasStateInherit": "Warisi", - "ccAliasStateOn": "Hidup", - "ccAliasStateOff": "Matikan", - "ccAliasAddModelPlaceholder": "Model id (mis. gpt-4o)", - "ccAliasAddModelButton": "Tambahkan override", - "ccAliasLoadError": "Gagal memuat pengaturan discovery-alias: {error}", - "ccAliasSaveError": "Gagal menyimpan pengaturan discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Hubungkan Galadriel dengan kunci API.", "predibase": "Kredit uji coba gratis $25 (validitas 30 hari)", "chenzk": "Gateway yang kompatibel dengan OpenAI dengan katalog model langsung di chenzk.top.", - "freepik": "Hasilkan gambar dengan Mystic API dari Freepik.", + "magnific": "Hasilkan gambar dengan Mystic API dari Freepik.", "freetheai": "Gateway gratis yang kompatibel dengan OpenAI dengan dukungan model passthrough.", "g4f-gemini": "Proksi terbalik g4f.space tanpa kunci gratis ke Gemini, dibatasi hingga 5 permintaan per menit.", "g4f-groq": "Proksi terbalik g4f.space tanpa kunci gratis ke Groq, dibatasi hingga 5 permintaan per menit.", @@ -6209,6 +6224,7 @@ "claude": "Hubungkan Claude Code dengan alur OAuth yang ada.", "cline": "Hubungkan Cline dengan alur OAuth yang ada.", "cursor": "Hubungkan Cursor IDE dengan alur OAuth yang ada.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Hubungkan GitHub Copilot dengan alur OAuth yang ada.", "gitlab-duo": "Aplikasi OAuth dengan cakupan ai_features + read_user. Konfigurasikan GITLAB_DUO_OAUTH_CLIENT_ID dan secara opsional GITLAB_DUO_OAUTH_CLIENT_SECRET pada instansi OmniRoute ini.", "kilocode": "Hubungkan Kilo Code dengan alur OAuth yang ada.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Teman Open Source", "cheaperInferenceSupporterTooltip": "Cheaper Inference mendukung OmniRoute sebagai Teman Open Source", "kimiPartnerLinkNote": "Tautan mitra — mendukung OmniRoute tanpa biaya tambahan bagi Anda", + "codexQuotaPools": "Kumpulan kuota Codex", + "codexPoolAvailable": "Tersedia", + "codexPoolPartiallyLimited": "Dibatasi sebagian", + "codexPoolFullyLimited": "Dibatasi sepenuhnya", + "codexPoolLimited": "{count} dibatasi", + "codexPoolQuotaExhausted": "Kuota habis", + "codexPoolCoolingDown": "Dalam masa tunggu", + "codexPoolUsed": "terpakai", + "codexPoolUntil": "Hingga {value}", "anonymousFallbackTitle": "Fallback anonim", "anonymousFallbackDesc": "Ketika semua koneksi yang dikonfigurasi habis (kuota, kredit, atau masa berlaku), gunakan sementara tingkat tanpa kunci penyedia ini. Matikan untuk melewati penyedia ini alih-alih mengirim permintaan anonim — disarankan ketika tingkat tanpa kunci menolak permintaan tersebut (401).", "anonymousFallbackEnabled": "Fallback anonim diaktifkan untuk {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Pengaturan endpoint model yang disimpan", "searchByModelAria": "Cari berdasarkan model", "selectSupportedEndpoint": "Pilih setidaknya satu endpoint yang didukung", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Ambil model upstream secara otomatis", - "autoFetchModelsDisabled": "Model upstream auto-fetch dinonaktifkan", - "autoFetchModelsEnabled": "Model hulu auto-fetch diaktifkan", - "autoFetchModelsToggleFailed": "Gagal untuk mengubah model upstream auto-fetch", - "overridesUpstreamModel": "Mengganti upstream", - "autoFetchModelsPartialFailure": "Beberapa koneksi diperbarui, tetapi pengambilan otomatis model upstream tidak berubah di mana-mana", - "overridesUpstreamModelHint": "Pengaturan Anda menimpa model upstream ini", - "resetToUpstreamDefaults": "Kembalikan pengaturan default upstream", - "resetToUpstreamDefaultsSuccess": "Mengembalikan pengaturan model upstream ke default", - "resetToUpstreamDefaultsFailed": "Gagal mengembalikan pengaturan model upstream ke default", - "autoFetchModelsTooltip": "Ambil dan simpan model upstream saat diperlukan" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Settings", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Kata Kunci yang Dilarang", "customBannedSignalsDesc": "Kata kunci tambahan yang memicu deteksi pemblokiran akun permanen. Kata kunci bawaan selalu berlaku.", "customBannedSignalsPlaceholder": "mis. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "dikonfigurasi", "none": "Tidak ada", "modelOverrideValuePlaceholder": "Nilai numerik", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Tambah nilai kunci", "noModelOverrides": "Tidak ada penimpaan yang dikonfigurasi untuk model ini.", "modelOverrideLoadFailed": "Gagal memuat penimpaan model", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK Ringkas (文言)", "description": "Gaya ultra-ringkas Tionghoa Klasik (hanya tersedia untuk bahasa Tionghoa)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Kombo round-robin dan acak beralih ke koneksi yang berbeda pada setiap permintaan, alih-alih menetapkan seluruh percakapan ke satu koneksi berdasarkan hash pesan pertama. Biarkan nonaktif untuk mempertahankan hit prompt-cache pada obrolan multi-putaran. Pengabaian per-kombo memiliki prioritas lebih tinggi.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Redaksi Kredensial", "credentialRedactionDesc": "Redaksikan kunci API, token, dan rahasia dari konteks yang dikirim ke penyedia dan dari respons.", "enableCredentialRedaction": "Aktifkan redaksi kredensial", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Aktifkan mesin", "enableDescription": "Berjalan terakhir dalam tumpukan (setelah RTK/Caveman membersihkan teks, OmniGlyph mengonversi sisanya menjadi gambar) dan juga berjalan mandiri melalui mode omniglyph. Ini adalah pratinjau dan tetap nonaktif secara default hingga validasi menyeluruh selesai.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Tersimpan.", "saveFailed": "Tidak dapat menyimpan.", "enableAria": "Aktifkan mesin OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "maksimum", "grokAutoTopUpMonth": "bulan", "grokAdditionalCredits": "Kredit Tambahan", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Token Pertama", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 27212b1eb3..d777c72bf1 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Timeline visiva delle richieste", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "apri", "close": "chiudi" }, - "noResults": "Nessun risultato", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Nessun risultato" }, "webhooks": { "title": "Webhook", @@ -1739,8 +1739,8 @@ "quotaShare": "Quota condivisa", "discovery": "Scoperta", "freeProviderRankings": "Classifiche provider gratuiti", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Piani gratuiti", "gamification": "Gamification", "leaderboard": "Classifica", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Questo provider è stato deprecato", "riskNotice": { "title": "Prima di continuare", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Provider con avvertenze d'uso — fai clic per i dettagli", "oauth": "Questo provider utilizza la sessione/OAuth ufficiale del prodotto, che non è autorizzata per l'uso come proxy/router. Si sconsiglia l'uso intensivo di agenti autonomi (in stile OpenCloud, flussi lunghi a più passaggi, batch di grandi dimensioni): l'upstream potrebbe reagire limitando o bloccando l'account. Utilizzare a proprio rischio.", "webCookie": "Questo provider si autentica tramite i cookie della sessione web. Il servizio upstream potrebbe invalidare la sessione in qualsiasi momento, richiedendo di effettuare nuovamente l'accesso. Non consigliato per operazioni prolungate non presidiate. Utilizzare a proprio rischio.", @@ -5107,9 +5111,9 @@ "cancel": "Annulla" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabilitato", "enableProvider": "Abilita fornitore", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Salto {count} modelli esistenti", "autoSync": "Sincronizzazione automatica", "autoSyncShort": "Sincronizza", + "autoFetchModels": "Recupera automaticamente i modelli upstream", + "autoFetchModelsTooltip": "Recupera e memorizza nella cache i modelli upstream quando necessario", + "autoFetchModelsEnabled": "Modello upstream auto-fetch abilitato", + "autoFetchModelsDisabled": "Fetch automatico del modello upstream disabilitato", + "autoFetchModelsToggleFailed": "Impossibile attivare/disattivare il recupero automatico del modello upstream", + "autoFetchModelsPartialFailure": "Alcune connessioni aggiornate, ma l'auto-fetch del modello upstream non è stato cambiato ovunque", + "overridesUpstreamModel": "Sovrascrive upstream", + "overridesUpstreamModelHint": "Le tue impostazioni sovrascrivono questo modello upstream", + "resetToUpstreamDefaults": "Ripristina le impostazioni predefinite upstream", + "resetToUpstreamDefaultsSuccess": "Ripristinati i valori predefiniti del modello upstream", + "resetToUpstreamDefaultsFailed": "Impossibile ripristinare le impostazioni predefinite del modello upstream", "autoSyncTooltip": "Aggiorna automaticamente l'elenco dei modelli ogni 24 ore (configurabile tramite MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Sincronizzazione automatica abilitata — i modelli verranno aggiornati periodicamente", "autoSyncDisabled": "Sincronizzazione automatica disabilitata", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Riscrivi le chiamate dello strumento nativo web_fetch verso /v1/web/fetch di OmniRoute.", "interceptionLoadError": "Impossibile caricare le impostazioni di intercettazione: {error}", "interceptionSaveError": "Impossibile salvare le impostazioni di intercettazione: {error}", - "ccAliasSectionTitle": "Esponi in Claude Code (claude/…)", - "ccAliasSectionHint": "Mostra i modelli di questo provider sotto gli id specchio claude/<provider>/<model> così la scoperta modelli gateway di Claude Code può elencarli. Disattivato per impostazione predefinita — abilitarlo raddoppia le voci nel catalogo per tutti i client.", - "ccAliasProviderLevelLabel": "Impostazione predefinita del provider", - "ccAliasModelOverridesLabel": "Sostituzioni per modello", - "ccAliasModelOverrideAriaLabel": "Sostituzione per {modelId}", - "ccAliasStateInherit": "Eredita", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", "ccAliasStateOn": "On", "ccAliasStateOff": "Off", - "ccAliasAddModelPlaceholder": "Id modello (es. gpt-4o)", - "ccAliasAddModelButton": "Aggiungi sostituzione", - "ccAliasLoadError": "Impossibile caricare le impostazioni discovery-alias: {error}", - "ccAliasSaveError": "Impossibile salvare l'impostazione discovery-alias: {error}", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Connetti Galadriel con una chiave API.", "predibase": "$25 di crediti di prova gratuiti (validità di 30 giorni)", "chenzk": "Gateway compatibile con OpenAI con un catalogo di modelli live su chenzk.top.", - "freepik": "Genera immagini con la Mystic API di Freepik.", + "magnific": "Genera immagini con la Mystic API di Freepik.", "freetheai": "Gateway gratuito compatibile con OpenAI con supporto per modelli passthrough.", "g4f-gemini": "Reverse proxy gratuito senza chiave di g4f.space verso Gemini, limitato a 5 richieste al minuto.", "g4f-groq": "Reverse proxy gratuito senza chiave di g4f.space verso Groq, limitato a 5 richieste al minuto.", @@ -6209,6 +6224,7 @@ "claude": "Connetti Claude Code con il flusso OAuth esistente.", "cline": "Connetti Cline con il flusso OAuth esistente.", "cursor": "Connetti Cursor IDE con il flusso OAuth esistente.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Connetti GitHub Copilot con il flusso OAuth esistente.", "gitlab-duo": "Applicazione OAuth con scope ai_features + read_user. Configura GITLAB_DUO_OAUTH_CLIENT_ID e opzionalmente GITLAB_DUO_OAUTH_CLIENT_SECRET su questa istanza di OmniRoute.", "kilocode": "Connetti Kilo Code con il flusso OAuth esistente.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Amico open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference sostiene OmniRoute come amico open source", "kimiPartnerLinkNote": "Link partner — supporta OmniRoute senza costi aggiuntivi per te", + "codexQuotaPools": "Pool di quote Codex", + "codexPoolAvailable": "Disponibile", + "codexPoolPartiallyLimited": "Parzialmente limitato", + "codexPoolFullyLimited": "Completamente limitato", + "codexPoolLimited": "{count} limitati", + "codexPoolQuotaExhausted": "Quota esaurita", + "codexPoolCoolingDown": "In attesa", + "codexPoolUsed": "utilizzato", + "codexPoolUntil": "Fino a {value}", "anonymousFallbackTitle": "Fallback anonimo", "anonymousFallbackDesc": "Quando tutte le connessioni configurate sono esaurite (quota, crediti o scadenza), utilizza temporaneamente il livello senza chiave di questo fornitore. Disattiva per saltare questo fornitore invece di inviare richieste anonime — consigliato quando il livello senza chiave le rifiuta (401).", "anonymousFallbackEnabled": "Fallback anonimo abilitato per {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Impostazioni dell'endpoint del modello salvato", "searchByModelAria": "Cerca per modello", "selectSupportedEndpoint": "Seleziona almeno un endpoint supportato", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsTooltip": "Recupera e memorizza nella cache i modelli upstream quando necessario", - "autoFetchModels": "Recupera automaticamente i modelli upstream", - "autoFetchModelsDisabled": "Fetch automatico del modello upstream disabilitato", - "autoFetchModelsToggleFailed": "Impossibile attivare/disattivare il recupero automatico del modello upstream", - "overridesUpstreamModel": "Sovrascrive upstream", - "autoFetchModelsPartialFailure": "Alcune connessioni aggiornate, ma l'auto-fetch del modello upstream non è stato cambiato ovunque", - "overridesUpstreamModelHint": "Le tue impostazioni sovrascrivono questo modello upstream", - "resetToUpstreamDefaults": "Ripristina le impostazioni predefinite upstream", - "resetToUpstreamDefaultsSuccess": "Ripristinati i valori predefiniti del modello upstream", - "resetToUpstreamDefaultsFailed": "Impossibile ripristinare le impostazioni predefinite del modello upstream", - "autoFetchModelsEnabled": "Modello upstream auto-fetch abilitato" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Impostazioni", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Parole chiave vietate", "customBannedSignalsDesc": "Parole chiave aggiuntive che attivano il rilevamento del ban permanente dell'account. Le parole chiave integrate si applicano sempre.", "customBannedSignalsPlaceholder": "es. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "configurato", "none": "Nessuno", "modelOverrideValuePlaceholder": "Valore numerico", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Aggiungi chiave-valore", "noModelOverrides": "Nessun override configurato per questo modello.", "modelOverrideLoadFailed": "Impossibile caricare gli override del modello", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK conciso (文言)", "description": "Stile ultra-conciso in cinese classico (disponibile solo per il cinese)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Le combinazioni round-robin e casuali ruotano su una connessione diversa a ogni richiesta invece di associare un'intera conversazione a una sola connessione tramite l'hash del primo messaggio. Lascia disattivato per preservare i riscontri della cache dei prompt per le chat a più turni. Le sostituzioni per singola combinazione hanno la precedenza.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Oscuramento delle credenziali", "credentialRedactionDesc": "Oscura chiavi API, token e segreti dal contesto inviato ai provider e dalle risposte.", "enableCredentialRedaction": "Abilita l'oscuramento delle credenziali", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Abilita il motore", "enableDescription": "Viene eseguito per ultimo nello stack (dopo che RTK/Caveman ha pulito il testo, OmniGlyph converte il resto in immagini) e funziona anche in modalità autonoma tramite la modalità omniglyph. Questa è un'anteprima e rimane disattivata per impostazione predefinita fino al completamento della convalida end-to-end.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Salvato.", "saveFailed": "Impossibile salvare.", "enableAria": "Abilita il motore OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "massimo", "grokAutoTopUpMonth": "mese", "grokAdditionalCredits": "Crediti Aggiuntivi", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Registratore", "proxyTab": "Procura", "budgetManagement": "Gestione del bilancio", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Primo Token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index ea2b51f9d3..ee7b56d074 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "ビジュアルリクエストタイムライン", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "開く", "close": "閉じる" }, - "noResults": "結果がありません", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "結果がありません" }, "webhooks": { "title": "Webhook", @@ -1739,8 +1739,8 @@ "quotaShare": "クォータ共有", "discovery": "ディスカバリー", "freeProviderRankings": "無料プロバイダーランキング", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "無料枠", "gamification": "ゲーミフィケーション", "leaderboard": "リーダーボード", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "このプロバイダーは廃止されました", "riskNotice": { "title": "続行する前に", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "使用上の注意点があるプロバイダー — クリックして詳細を表示", "oauth": "このプロバイダーは、プロキシ/ルーターでの使用が許可されていない公式製品のセッション/OAuthを使用します。自律型エージェントの集中的な使用(OpenCloudスタイル、長いマルチステップフロー、大量のバッチ処理)は推奨されません。アップストリームがアカウントを制限または禁止する可能性があります。自己責任でご利用ください。", "webCookie": "このプロバイダーは、Webセッションクッキーを使用して認証します。アップストリームサービスはいつでもセッションを無効化する可能性があり、その場合は再ログインが必要になります。長時間の無人運用には推奨されません。自己責任でご利用ください。", @@ -5107,11 +5111,11 @@ "cancel": "キャンセル" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, - "disabled": "障害者", + "disabled": "無効", "enableProvider": "プロバイダーを有効にする", "disableProvider": "プロバイダーを無効にする", "testResults": "テスト結果", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "{count}件の既存モデルをスキップ", "autoSync": "自動同期", "autoSyncShort": "同期", + "autoFetchModels": "アップストリームモデルを自動取得", + "autoFetchModelsTooltip": "必要に応じてアップストリームモデルを取得してキャッシュする", + "autoFetchModelsEnabled": "上流モデルの自動取得が有効になりました", + "autoFetchModelsDisabled": "上流モデルの自動取得が無効になっています", + "autoFetchModelsToggleFailed": "アップストリームモデルの自動取得の切り替えに失敗しました", + "autoFetchModelsPartialFailure": "いくつかの接続が更新されましたが、上流モデルの自動取得はすべての場所で変更されませんでした", + "overridesUpstreamModel": "上流をオーバーライド", + "overridesUpstreamModelHint": "あなたの設定がこの上流モデルを上書きします", + "resetToUpstreamDefaults": "アップストリームのデフォルトを復元する", + "resetToUpstreamDefaultsSuccess": "アップストリームモデルのデフォルトを復元しました", + "resetToUpstreamDefaultsFailed": "アップストリームモデルのデフォルトを復元できませんでした", "autoSyncTooltip": "24時間ごとにモデルリストを自動更新(MODEL_SYNC_INTERVAL_HOURSで設定可能)", "autoSyncEnabled": "自動同期有効 — モデルは定期的に更新されます", "autoSyncDisabled": "自動同期無効", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "ネイティブの web_fetch ツール呼び出しを OmniRoute の /v1/web/fetch に書き換えます。", "interceptionLoadError": "インターセプト設定の読み込みに失敗しました: {error}", "interceptionSaveError": "インターセプト設定の保存に失敗しました: {error}", - "ccAliasSectionTitle": "Claude Codeで公開する (claude/…)", - "ccAliasSectionHint": "このプロバイダーのモデルを claude/<provider>/<model> ミラー ID の下で広告し、Claude Code のゲートウェイモデル発見がそれらをリストできるようにします。デフォルトではオフになっており、これを有効にするとすべてのクライアントのカタログエントリが2倍になります。", - "ccAliasProviderLevelLabel": "プロバイダーのデフォルト", - "ccAliasModelOverridesLabel": "モデルごとのオーバーライド", - "ccAliasModelOverrideAriaLabel": "{modelId}のオーバーライド", - "ccAliasStateInherit": "継承", - "ccAliasStateOn": "オン", - "ccAliasStateOff": "オフ", - "ccAliasAddModelPlaceholder": "モデルID(例:gpt-4o)", - "ccAliasAddModelButton": "オーバーライドを追加", - "ccAliasLoadError": "ディスカバリーエイリアス設定の読み込みに失敗しました: {error}", - "ccAliasSaveError": "発見エイリアス設定の保存に失敗しました: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "APIキーを使用してGaladrielに接続します。", "predibase": "$25の無料トライアルクレジット(有効期限30日間)", "chenzk": "chenzk.top でライブモデルカタログを提供するOpenAI互換ゲートウェイ。", - "freepik": "FreepikのMystic APIで画像を生成します。", + "magnific": "FreepikのMystic APIで画像を生成します。", "freetheai": "パススルーモデルをサポートする無料のOpenAI互換ゲートウェイ。", "g4f-gemini": "キー不要で無料のg4f.spaceによるGeminiへのリバースプロキシ(1分あたり5リクエストに制限)。", "g4f-groq": "キー不要で無料のg4f.spaceによるGroqへのリバースプロキシ(1分あたり5リクエストに制限)。", @@ -6209,6 +6224,7 @@ "claude": "既存のOAuthフローを使用してClaude Codeに接続します。", "cline": "既存のOAuthフローを使用してClineに接続します。", "cursor": "既存のOAuthフローを使用してCursor IDEに接続します。", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "既存のOAuthフローを使用してGitHub Copilotに接続します。", "gitlab-duo": "ai_features + read_user スコープを持つOAuthアプリケーション。このOmniRouteインスタンスで GITLAB_DUO_OAUTH_CLIENT_ID と、必要に応じて GITLAB_DUO_OAUTH_CLIENT_SECRET を設定してください。", "kilocode": "既存のOAuthフローを使用してKilo Codeに接続します。", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "オープンソースフレンド", "cheaperInferenceSupporterTooltip": "Cheaper Inference は OmniRoute をオープンソースフレンドとして支援しています", "kimiPartnerLinkNote": "パートナーリンク — 追加費用なしで OmniRoute をサポートします", + "codexQuotaPools": "Codex クォータプール", + "codexPoolAvailable": "利用可能", + "codexPoolPartiallyLimited": "一部制限中", + "codexPoolFullyLimited": "すべて制限中", + "codexPoolLimited": "{count} 件が制限中", + "codexPoolQuotaExhausted": "クォータを使い切りました", + "codexPoolCoolingDown": "クールダウン中", + "codexPoolUsed": "使用済み", + "codexPoolUntil": "{value} まで", "anonymousFallbackTitle": "匿名フォールバック", "anonymousFallbackDesc": "すべての設定された接続が使い果たされた場合(クォータ、クレジット、または有効期限)、このプロバイダーのキーなしティアを一時的に使用します。このプロバイダーをスキップするにはオフにしてください。匿名リクエストを送信する代わりに、キーなしティアがそれらを拒否する場合(401)に推奨されます。", "anonymousFallbackEnabled": "{provider}の匿名フォールバックが有効になりました", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "保存されたモデルエンドポイント設定", "searchByModelAria": "モデルで検索", "selectSupportedEndpoint": "サポートされているエンドポイントを少なくとも1つ選択してください", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsEnabled": "上流モデルの自動取得が有効になりました", - "autoFetchModelsTooltip": "必要に応じてアップストリームモデルを取得してキャッシュする", - "autoFetchModelsDisabled": "上流モデルの自動取得が無効になっています", - "autoFetchModels": "アップストリームモデルを自動取得", - "autoFetchModelsToggleFailed": "アップストリームモデルの自動取得の切り替えに失敗しました", - "overridesUpstreamModel": "上流をオーバーライド", - "autoFetchModelsPartialFailure": "いくつかの接続が更新されましたが、上流モデルの自動取得はすべての場所で変更されませんでした", - "overridesUpstreamModelHint": "あなたの設定がこの上流モデルを上書きします", - "resetToUpstreamDefaults": "アップストリームのデフォルトを復元する", - "resetToUpstreamDefaultsFailed": "アップストリームモデルのデフォルトを復元できませんでした", - "resetToUpstreamDefaultsSuccess": "アップストリームモデルのデフォルトを復元しました" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "設定", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "禁止キーワード", "customBannedSignalsDesc": "アカウントの永久BAN検出のトリガーとなる追加のキーワード。組み込みのキーワードは常に適用されます。", "customBannedSignalsPlaceholder": "例: api key revoked", @@ -6724,7 +6738,7 @@ "global": "グローバル", "rule": "ルール", "enabled": "有効", - "disabled": "障害者", + "disabled": "無効", "nodeCount": "ノード: {count}", "needsCoreCount": "{count} にはローカル コアが必要です", "lastSynced": "最終同期: {time}", @@ -6995,7 +7009,7 @@ "systemActor": "システム", "ipAccessControl": "IPアクセス制御", "ipAccessControlDesc": "特定の IP アドレスをブロックまたは許可する", - "ipModeDisabled": "障害者", + "ipModeDisabled": "無効", "ipModeBlacklist": "ブラックリスト", "ipModeWhitelist": "ホワイトリスト", "ipModeWhitelistPriority": "WL優先", @@ -7189,6 +7203,7 @@ "configured": "設定済み", "none": "なし", "modelOverrideValuePlaceholder": "数値", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "キーと値を追加", "noModelOverrides": "このモデル用に設定されたオーバーライドはありません。", "modelOverrideLoadFailed": "モデルのオーバーライドの読み込みに失敗しました", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "簡潔なCJK (文言)", "description": "漢文の超簡潔スタイル (中国語でのみ利用可能)。" @@ -7919,7 +7938,7 @@ "triggerLabel": "トリガー", "effectLabel": "効果", "statusEnabled": "有効", - "statusDisabled": "障害者", + "statusDisabled": "無効", "resilienceRequestQueueScope": "リクエストキューごと", "resilienceRequestQueueTrigger": "上流に送る前に", "resilienceRequestQueueEffect": "リクエストをキューに入れ、同時実行を制限し、呼び出しの間隔を空けます。", @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "ラウンドロビンおよびランダムのコンボにおいて、最初のメッセージのハッシュによって会話全体を1つの接続に固定するのではなく、リクエストごとに異なる接続にローテーションします。複数ターンのチャットでプロンプトキャッシュのヒット率を維持するには、オフのままにしてください。コンボごとの個別設定が優先されます。", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "認証情報の秘匿化", "credentialRedactionDesc": "プロバイダーに送信されるコンテキストおよびレスポンスから、API キー、トークン、シークレットを秘匿化します。", "enableCredentialRedaction": "認証情報の秘匿化を有効にする", @@ -8600,6 +8623,27 @@ }, "enableTitle": "エンジンを有効にする", "enableDescription": "スタックの最後に実行され(RTK/Cavemanがテキストをクリーンアップした後、OmniGlyphが残りを画像に変換)、omniglyph モードを介してスタンドアロンでも実行されます。これはプレビュー版であり、エンドツーエンドの検証が完了するまではデフォルトでオフのままになります。", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "保存されました。", "saveFailed": "保存できませんでした。", "enableAria": "OmniGlyphエンジンを有効にする", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "最大", "grokAutoTopUpMonth": "月", "grokAdditionalCredits": "追加のクレジット", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "ロガー", "proxyTab": "プロキシ", "budgetManagement": "予算管理", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "最初のトークン", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "オファー", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days} 日間" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 63f33c3b78..3a5feebf4e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "비주얼 요청 타임라인", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "열기", "close": "닫기" }, - "noResults": "결과가 없습니다", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "결과가 없습니다" }, "webhooks": { "title": "웹훅", @@ -1739,8 +1739,8 @@ "quotaShare": "할당량 공유", "discovery": "탐색", "freeProviderRankings": "무료 제공업체 순위", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "무료 티어", "gamification": "게이미피케이션", "leaderboard": "리더보드", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "모델 전반에 요청이 분산되는 방식을 선택하세요 - 14가지 전략 사용 가능", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "이 공급자는 더 이상 사용되지 않습니다.", "riskNotice": { "title": "계속하기 전에", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "사용 시 주의 사항이 있는 제공자 — 자세한 내용을 보려면 클릭하세요", "oauth": "이 제공자는 공식 제품 세션/OAuth를 사용하며, 이는 프록시/라우터 사용에 대해 승인되지 않았습니다. 집중적인 자율 에이전트 사용(OpenCloud 스타일, 긴 다단계 흐름, 대량 배치)은 권장하지 않습니다. 업스트림에서 계정을 제한하거나 차단할 수 있습니다. 본인 책임 하에 사용하십시오.", "webCookie": "이 제공자는 웹 세션 쿠키를 통해 인증합니다. 업스트림 서비스가 언제든지 세션을 무효화할 수 있어 다시 로그인해야 할 수 있습니다. 장시간 자리를 비우는 작업에는 권장하지 않습니다. 본인 책임 하에 사용하십시오.", @@ -5107,9 +5111,9 @@ "cancel": "취소" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "비활성화됨", "enableProvider": "공급자 활성화", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "{count}개의 기존 모델 건너뛰기", "autoSync": "자동 동기화", "autoSyncShort": "동기화", + "autoFetchModels": "업스트림 모델 자동 가져오기", + "autoFetchModelsTooltip": "필요할 때 업스트림 모델을 가져와 캐시합니다.", + "autoFetchModelsEnabled": "업스트림 모델 자동 가져오기 활성화됨", + "autoFetchModelsDisabled": "업스트림 모델 자동 가져오기 비활성화됨", + "autoFetchModelsToggleFailed": "업스트림 모델 자동 가져오기를 전환하지 못했습니다.", + "autoFetchModelsPartialFailure": "일부 연결이 업데이트되었지만, 업스트림 모델 자동 가져오기가 모든 곳에서 변경되지 않았습니다.", + "overridesUpstreamModel": "업스트림 재정의", + "overridesUpstreamModelHint": "귀하의 설정이 이 업스트림 모델을 덮어씁니다.", + "resetToUpstreamDefaults": "업스트림 기본값 복원", + "resetToUpstreamDefaultsSuccess": "복원된 업스트림 모델 기본값", + "resetToUpstreamDefaultsFailed": "업스트림 모델 기본값을 복원하지 못했습니다.", "autoSyncTooltip": "24시간마다 모델 목록 자동 업데이트 (MODEL_SYNC_INTERVAL_HOURS로 구성 가능)", "autoSyncEnabled": "자동 동기화 활성화 — 모델이 주기적으로 업데이트됩니다", "autoSyncDisabled": "자동 동기화 비활성화", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "네이티브 web_fetch 도구 호출을 OmniRoute의 /v1/web/fetch로 재작성합니다.", "interceptionLoadError": "가로채기 설정을 불러오지 못했습니다: {error}", "interceptionSaveError": "가로채기 설정을 저장하지 못했습니다: {error}", - "ccAliasSectionTitle": "Claude 코드에서 노출하기 (claude/…)", - "ccAliasSectionHint": "이 공급자의 모델을 claude/<provider>/<model> 미러 ID 아래에 광고하여 Claude Code의 게이트웨이 모델 검색이 이를 나열할 수 있도록 합니다. 기본적으로 비활성화되어 있으며, 이를 활성화하면 모든 클라이언트에 대해 카탈로그 항목이 두 배로 증가합니다.", - "ccAliasProviderLevelLabel": "제공자 기본값", - "ccAliasModelOverridesLabel": "모델별 재정의", - "ccAliasModelOverrideAriaLabel": "{modelId}에 대한 재정의", - "ccAliasStateInherit": "상속", - "ccAliasStateOn": "켜짐", - "ccAliasStateOff": "꺼짐", - "ccAliasAddModelPlaceholder": "모델 ID (예: gpt-4o)", - "ccAliasAddModelButton": "오버라이드 추가", - "ccAliasLoadError": "discovery-alias 설정을 로드하지 못했습니다: {error}", - "ccAliasSaveError": "discovery-alias 설정을 저장하지 못했습니다: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "API 키로 Galadriel을 연결합니다.", "predibase": "$25 무료 체험 크레딧 (30일 유효)", "chenzk": "chenzk.top의 실시간 모델 카탈로그를 지원하는 OpenAI 호환 게이트웨이입니다.", - "freepik": "Freepik의 Mystic API로 이미지를 생성합니다.", + "magnific": "Freepik의 Mystic API로 이미지를 생성합니다.", "freetheai": "패스스루 모델을 지원하는 무료 OpenAI 호환 게이트웨이입니다.", "g4f-gemini": "키가 필요 없는 무료 g4f.space Gemini 리버스 프록시(분당 5회 요청으로 제한).", "g4f-groq": "키가 필요 없는 무료 g4f.space Groq 리버스 프록시(분당 5회 요청으로 제한).", @@ -6209,6 +6224,7 @@ "claude": "기존 OAuth 흐름으로 Claude Code를 연결합니다.", "cline": "기존 OAuth 흐름으로 Cline을 연결합니다.", "cursor": "기존 OAuth 흐름으로 Cursor IDE를 연결합니다.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "기존 OAuth 흐름으로 GitHub Copilot을 연결합니다.", "gitlab-duo": "ai_features + read_user 스코프가 있는 OAuth 애플리케이션입니다. 이 OmniRoute 인스턴스에서 GITLAB_DUO_OAUTH_CLIENT_ID 및 선택적으로 GITLAB_DUO_OAUTH_CLIENT_SECRET을 구성하세요.", "kilocode": "기존 OAuth 흐름으로 Kilo Code를 연결합니다.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "오픈 소스 친구", "cheaperInferenceSupporterTooltip": "Cheaper Inference는 오픈 소스 친구로서 OmniRoute를 후원합니다", "kimiPartnerLinkNote": "파트너 링크 — 추가 비용 없이 OmniRoute를 지원합니다", + "codexQuotaPools": "Codex 할당량 풀", + "codexPoolAvailable": "사용 가능", + "codexPoolPartiallyLimited": "일부 제한됨", + "codexPoolFullyLimited": "모두 제한됨", + "codexPoolLimited": "{count}개 제한됨", + "codexPoolQuotaExhausted": "할당량 소진", + "codexPoolCoolingDown": "대기 시간 적용 중", + "codexPoolUsed": "사용됨", + "codexPoolUntil": "{value}까지", "anonymousFallbackTitle": "익명 대체", "anonymousFallbackDesc": "모든 구성된 연결이 소진되면(쿼터, 크레딧 또는 만료), 이 공급자의 키 없는 계층을 임시로 사용합니다. 익명 요청을 보내는 대신 이 공급자를 건너뛰려면 끄세요. 키 없는 계층이 요청을 거부할 때(401) 권장됩니다.", "anonymousFallbackEnabled": "{provider}에 대한 익명 대체가 활성화되었습니다.", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "저장된 모델 엔드포인트 설정", "searchByModelAria": "모델로 검색", "selectSupportedEndpoint": "지원되는 엔드포인트를 최소한 하나 선택하세요.", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "업스트림 모델 자동 가져오기", - "autoFetchModelsEnabled": "업스트림 모델 자동 가져오기 활성화됨", - "autoFetchModelsDisabled": "업스트림 모델 자동 가져오기 비활성화됨", - "autoFetchModelsTooltip": "필요할 때 업스트림 모델을 가져와 캐시합니다.", - "overridesUpstreamModel": "업스트림 재정의", - "autoFetchModelsPartialFailure": "일부 연결이 업데이트되었지만, 업스트림 모델 자동 가져오기가 모든 곳에서 변경되지 않았습니다.", - "autoFetchModelsToggleFailed": "업스트림 모델 자동 가져오기를 전환하지 못했습니다.", - "overridesUpstreamModelHint": "귀하의 설정이 이 업스트림 모델을 덮어씁니다.", - "resetToUpstreamDefaultsSuccess": "복원된 업스트림 모델 기본값", - "resetToUpstreamDefaults": "업스트림 기본값 복원", - "resetToUpstreamDefaultsFailed": "업스트림 모델 기본값을 복원하지 못했습니다." + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "설정", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "차단 키워드", "customBannedSignalsDesc": "영구 계정 차단 감지를 트리거하는 추가 키워드입니다. 기본 제공 키워드는 항상 적용됩니다.", "customBannedSignalsPlaceholder": "예: api key revoked", @@ -7189,6 +7203,7 @@ "configured": "configured", "none": "None", "modelOverrideValuePlaceholder": "Numeric value", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Add key value", "noModelOverrides": "No overrides configured for this model.", "modelOverrideLoadFailed": "Failed to load model overrides", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "간결한 CJK (文言)", "description": "한문 초간결 스타일 (중국어만 지원)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "라운드 로빈 및 랜덤 콤보는 첫 번째 메시지 해시를 통해 전체 대화를 하나의 연결에 고정하는 대신 요청마다 다른 연결로 순환합니다. 멀티턴 대화에서 프롬프트 캐시 히트를 유지하려면 꺼두세요. 콤보별 재정의가 우선 적용됩니다.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "자격 증명 마스킹", "credentialRedactionDesc": "공급자에게 전송되는 컨텍스트 및 응답에서 API 키, 토큰, 비밀 정보를 마스킹합니다.", "enableCredentialRedaction": "자격 증명 마스킹 활성화", @@ -8600,6 +8623,27 @@ }, "enableTitle": "엔진 활성화", "enableDescription": "스택의 마지막에 실행되며(RTK/Caveman이 텍스트를 정리한 후 OmniGlyph가 나머지를 이미지로 변환), omniglyph 모드를 통해 독립 실행형으로도 실행됩니다. 이것은 미리보기이며 엔드투엔드 검증이 완료될 때까지 기본적으로 비활성화되어 있습니다.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "저장되었습니다.", "saveFailed": "저장할 수 없습니다.", "enableAria": "OmniGlyph 엔진 활성화", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "최대", "grokAutoTopUpMonth": "월", "grokAdditionalCredits": "추가 크레딧", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "로거", "proxyTab": "프록시", "budgetManagement": "예산 관리", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "첫 토큰", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 4f79317243..63afee6a7f 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "दृश्य विनंती कालरेषा", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "उघडा", "close": "बंद करा" }, - "noResults": "कोणतेही परिणाम नाहीत", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "कोणतेही परिणाम नाहीत" }, "webhooks": { "title": "वेबहुक", @@ -1739,8 +1739,8 @@ "quotaShare": "कोटा वाटा", "discovery": "शोध", "freeProviderRankings": "मोफत प्रदाता क्रमवारी", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "मोफत स्तर", "gamification": "गेमिफिकेशन", "leaderboard": "लीडरबोर्ड", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "हा प्रदाता बहिष्कृत केला गेला आहे", "riskNotice": { "title": "पुढे जाण्यापूर्वी", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "वापराच्या मर्यादा असलेला प्रदाता — तपशीलांसाठी क्लिक करा", "oauth": "हा प्रदाता तुमचे अधिकृत उत्पादन सत्र/OAuth वापरतो, जे प्रॉक्सी/राऊटर वापरासाठी अधिकृत नाही. आम्ही सघन स्वायत्त एजंट वापराची (OpenCloud-शैली, लांब बहु-चरण प्रवाह, मोठे बॅचेस) शिफारस करत नाही — अपस्ट्रीम खाते प्रतिबंधित किंवा बॅन करून प्रतिक्रिया देऊ शकते. स्वतःच्या जोखमीवर वापरा.", "webCookie": "हा प्रदाता तुमच्या वेब सत्र कुकीजद्वारे प्रमाणीकरण करतो. अपस्ट्रीम सेवा कोणत्याही वेळी सत्र अवैध करू शकते, ज्यामुळे तुम्हाला पुन्हा लॉग इन करावे लागेल. दीर्घकाळ लक्ष न ठेवलेल्या ऑपरेशन्ससाठी शिफारस केलेली नाही. स्वतःच्या जोखमीवर वापरा.", @@ -5107,9 +5111,9 @@ "cancel": "रद्द करा" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabled", "enableProvider": "Enable provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Skipping {count} existing models", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "ऑटो-फेच अपस्ट्रीम मॉडेल्स", + "autoFetchModelsTooltip": "आवश्यकतेनुसार अपस्ट्रीम मॉडेल्स आणा आणि कॅश करा", + "autoFetchModelsEnabled": "उपधारा मॉडेल स्वयंचलित-आकर्षण सक्षम आहे", + "autoFetchModelsDisabled": "उपधारा मॉडेल स्वयंचलित-आकर्षण अक्षम आहे", + "autoFetchModelsToggleFailed": "उपस्ट्रीम मॉडेल ऑटो-फेच टॉगल करण्यात अयशस्वी", + "autoFetchModelsPartialFailure": "काही कनेक्शन अद्यतनित झाले, परंतु अपस्ट्रीम मॉडेल ऑटो-फेच सर्वत्र बदलले नाही.", + "overridesUpstreamModel": "उपधारक ओव्हरराइड्स", + "overridesUpstreamModelHint": "तुमच्या सेटिंग्ज या अपस्ट्रीम मॉडेलला ओव्हरराईड करतात", + "resetToUpstreamDefaults": "अपस्ट्रीम डिफॉल्ट्स पुनर्स्थापित करा", + "resetToUpstreamDefaultsSuccess": "उपस्ट्रीम मॉडेल डिफॉल्ट्स पुनर्स्थापित केले", + "resetToUpstreamDefaultsFailed": "उपस्ट्रीम मॉडेल डिफॉल्ट्स पुनर्स्थापित करण्यात अयशस्वी", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "मूळ web_fetch टूल कॉल्स OmniRoute च्या /v1/web/fetch वर पुन्हा लिहा.", "interceptionLoadError": "इंटरसेप्शन सेटिंग्ज लोड करण्यात अयशस्वी: {error}", "interceptionSaveError": "इंटरसेप्शन सेटिंग्ज सेव्ह करण्यात अयशस्वी: {error}", - "ccAliasSectionTitle": "Claude कोडमध्ये उघडा (claude/…)", - "ccAliasSectionHint": "या प्रदात्याच्या मॉडेल्सची जाहिरात claude/<provider>/<model> मिरर आयडी अंतर्गत करा जेणेकरून Claude Code च्या गेटवे मॉडेल शोधाने त्यांची यादी करू शकेल. डीफॉल्टने बंद — हे सक्षम केल्याने सर्व क्लायंटसाठी कॅटलॉग नोंदी दुप्पट होतात.", - "ccAliasProviderLevelLabel": "प्रदाता डिफॉल्ट", - "ccAliasModelOverridesLabel": "प्रत्येक मॉडेलसाठी ओव्हरराइड्स", - "ccAliasModelOverrideAriaLabel": "{modelId} साठी ओव्हरराइड", - "ccAliasStateInherit": "विरासत", - "ccAliasStateOn": "वर", - "ccAliasStateOff": "बंद", - "ccAliasAddModelPlaceholder": "मॉडेल आयडी (उदा. gpt-4o)", - "ccAliasAddModelButton": "ओव्हरराइड जोडा", - "ccAliasLoadError": "डिस्कवरी-अलियास सेटिंग्ज लोड करण्यात अयशस्वी: {error}", - "ccAliasSaveError": "डिस्कवरी-उपनाम सेटिंग जतन करण्यात अयशस्वी: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "API की सह Galadriel कनेक्ट करा.", "predibase": "$25 मोफत ट्रायल क्रेडिट्स (30 दिवसांची वैधता)", "chenzk": "chenzk.top वर थेट मॉडेल कॅटलॉगसह OpenAI-सुसंगत गेटवे.", - "freepik": "Freepik च्या Mystic API सह प्रतिमा तयार करा.", + "magnific": "Freepik च्या Mystic API सह प्रतिमा तयार करा.", "freetheai": "passthrough मॉडेल समर्थनासह मोफत OpenAI-सुसंगत गेटवे.", "g4f-gemini": "Gemini साठी मोफत विना-की g4f.space रिव्हर्स प्रॉक्सी, प्रति मिनिट 5 विनंत्यांपर्यंत मर्यादित.", "g4f-groq": "Groq साठी मोफत विना-की g4f.space रिव्हर्स प्रॉक्सी, प्रति मिनिट 5 विनंत्यांपर्यंत मर्यादित.", @@ -6209,6 +6224,7 @@ "claude": "सध्याच्या OAuth फ्लोसह Claude Code कनेक्ट करा.", "cline": "सध्याच्या OAuth फ्लोसह Cline कनेक्ट करा.", "cursor": "सध्याच्या OAuth फ्लोसह Cursor IDE कनेक्ट करा.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "सध्याच्या OAuth फ्लोसह GitHub Copilot कनेक्ट करा.", "gitlab-duo": "ai_features + read_user स्कोप्ससह OAuth ॲप्लिकेशन. या OmniRoute इन्स्टन्सवर GITLAB_DUO_OAUTH_CLIENT_ID आणि पर्यायीपणे GITLAB_DUO_OAUTH_CLIENT_SECRET कॉन्फिगर करा.", "kilocode": "सध्याच्या OAuth फ्लोसह Kilo Code कनेक्ट करा.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "ओपन सोर्स मित्र", "cheaperInferenceSupporterTooltip": "Cheaper Inference ओपन सोर्स मित्र म्हणून OmniRoute ला पाठिंबा देते", "kimiPartnerLinkNote": "भागीदार लिंक — तुमच्यासाठी कोणत्याही अतिरिक्त खर्चाशिवाय OmniRoute ला सपोर्ट करते", + "codexQuotaPools": "Codex कोटा पूल", + "codexPoolAvailable": "उपलब्ध", + "codexPoolPartiallyLimited": "अंशतः मर्यादित", + "codexPoolFullyLimited": "पूर्णपणे मर्यादित", + "codexPoolLimited": "{count} मर्यादित", + "codexPoolQuotaExhausted": "कोटा संपला", + "codexPoolCoolingDown": "प्रतीक्षा कालावधीत", + "codexPoolUsed": "वापरले", + "codexPoolUntil": "{value} पर्यंत", "anonymousFallbackTitle": "अज्ञात बॅकअप", "anonymousFallbackDesc": "जेव्हा सर्व कॉन्फिगर केलेले कनेक्शन संपतात (कोटा, क्रेडिट्स, किंवा कालावधी), तेव्हा तात्पुरते या प्रदात्याचा कीलेस स्तर वापरा. गुप्त विनंत्या पाठविण्याऐवजी या प्रदात्याला वगळण्यासाठी बंद करा — जेव्हा कीलेस स्तर त्यांना नकार देतो (401) तेव्हा शिफारस केले जाते.", "anonymousFallbackEnabled": "{provider} साठी गुप्तFallback सक्षम आहे", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "सुरक्षित केलेल्या मॉडेल एंडपॉइंट सेटिंग्ज", "searchByModelAria": "मॉडेलद्वारे शोधा", "selectSupportedEndpoint": "किमान एक समर्थित एंडपॉइंट निवडा", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "ऑटो-फेच अपस्ट्रीम मॉडेल्स", - "autoFetchModelsDisabled": "उपधारा मॉडेल स्वयंचलित-आकर्षण अक्षम आहे", - "autoFetchModelsTooltip": "आवश्यकतेनुसार अपस्ट्रीम मॉडेल्स आणा आणि कॅश करा", - "autoFetchModelsEnabled": "उपधारा मॉडेल स्वयंचलित-आकर्षण सक्षम आहे", - "autoFetchModelsToggleFailed": "उपस्ट्रीम मॉडेल ऑटो-फेच टॉगल करण्यात अयशस्वी", - "overridesUpstreamModel": "उपधारक ओव्हरराइड्स", - "autoFetchModelsPartialFailure": "काही कनेक्शन अद्यतनित झाले, परंतु अपस्ट्रीम मॉडेल ऑटो-फेच सर्वत्र बदलले नाही.", - "overridesUpstreamModelHint": "तुमच्या सेटिंग्ज या अपस्ट्रीम मॉडेलला ओव्हरराईड करतात", - "resetToUpstreamDefaultsSuccess": "उपस्ट्रीम मॉडेल डिफॉल्ट्स पुनर्स्थापित केले", - "resetToUpstreamDefaults": "अपस्ट्रीम डिफॉल्ट्स पुनर्स्थापित करा", - "resetToUpstreamDefaultsFailed": "उपस्ट्रीम मॉडेल डिफॉल्ट्स पुनर्स्थापित करण्यात अयशस्वी" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Settings", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "प्रतिबंधित कीवर्ड", "customBannedSignalsDesc": "अतिरिक्त कीवर्ड जे कायमचे खाते बंदी शोधणे ट्रिगर करतात. अंगभूत कीवर्ड नेहमी लागू होतात.", "customBannedSignalsPlaceholder": "उदा. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "कॉन्फिगर केलेले", "none": "काहीही नाही", "modelOverrideValuePlaceholder": "संख्यात्मक मूल्य", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "की व्हॅल्यू जोडा", "noModelOverrides": "या मॉडेलसाठी कोणतेही ओव्हरराइड्स कॉन्फिगर केलेले नाहीत.", "modelOverrideLoadFailed": "मॉडेल ओव्हरराइड्स लोड करण्यात अयशस्वी", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "संक्षिप्त CJK (文言)", "description": "अभिजात-चिनी अति-संक्षिप्त शैली (केवळ चिनी भाषेसाठी उपलब्ध)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "राउंड-रॉबिन आणि रँडम कॉम्बोज पहिल्या-मेसेज हॅशद्वारे संपूर्ण संभाषण एका कनेक्शनवर पिन करण्याऐवजी प्रत्येक विनंतीवर वेगळ्या कनेक्शनवर रोटेट होतात. मल्टी-टर्न चॅट्ससाठी प्रॉम्प्ट-कॅशे हिट्स जतन करण्यासाठी हे बंद ठेवा. प्रति-कॉम्बो ओव्हरराइड्सना प्राधान्य दिले जाईल.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "क्रेडेन्शियल रिडॅक्शन", "credentialRedactionDesc": "प्रदात्यांना पाठवलेल्या संदर्भातून आणि प्रतिसादांमधून API की, टोकन आणि सिक्रेट्स रिडॅक्ट करा.", "enableCredentialRedaction": "क्रेडेंशियल रिडॅक्शन सक्षम करा", @@ -8600,6 +8623,27 @@ }, "enableTitle": "इंजिन सक्षम करा", "enableDescription": "स्टॅकमध्ये शेवटी चालते (RTK/Caveman मजकूर साफ केल्यानंतर, OmniGlyph उर्वरित मजकूर इमेजेसमध्ये रूपांतरित करते) आणि omniglyph मोडद्वारे स्वतंत्रपणे देखील चालते. हे एक पूर्वावलोकन आहे आणि एंड-टू-एंड प्रमाणीकरण पूर्ण होईपर्यंत डीफॉल्टनुसार बंद राहते.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "जतन केले.", "saveFailed": "जतन करता आले नाही.", "enableAria": "OmniGlyph इंजिन सक्षम करा", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "कमाल", "grokAutoTopUpMonth": "महिना", "grokAdditionalCredits": "अतिरिक्त श्रेय", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "पहिला टोकन", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index a3b3cb299b..b6c322c3e2 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Garis Masa Permintaan Visual", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "buka", "close": "tutup" }, - "noResults": "Tiada hasil", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Tiada hasil" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "Perkongsian Kuota", "discovery": "Penemuan", "freeProviderRankings": "Kedudukan Penyedia Percuma", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Tier Percuma", "gamification": "Gamifikasi", "leaderboard": "Papan Pendahulu", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Pembekal ini telah ditamatkan", "riskNotice": { "title": "Sebelum meneruskan", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Penyedia dengan kekangan penggunaan — klik untuk butiran", "oauth": "Penyedia ini menggunakan sesi produk/OAuth rasmi anda, yang tidak dibenarkan untuk penggunaan proksi/penghala. Kami tidak mengesyorkan penggunaan ejen autonomi yang intensif (gaya OpenCloud, aliran berbilang langkah yang panjang, kelompok besar) — upstream mungkin bertindak balas dengan menyekat atau mengharamkan akaun tersebut. Gunakan atas risiko anda sendiri.", "webCookie": "Penyedia ini mengesahkan melalui kuki sesi web anda. Perkhidmatan upstream mungkin membatalkan sesi pada bila-bila masa, memerlukan anda untuk log masuk semula. Tidak disyorkan untuk operasi tanpa pengawasan yang lama. Gunakan atas risiko anda sendiri.", @@ -5107,9 +5111,9 @@ "cancel": "Batal" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Dilumpuhkan", "enableProvider": "Dayakan pembekal", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Melangkau {count} model sedia ada", "autoSync": "Auto-Segerak", "autoSyncShort": "Segerak", + "autoFetchModels": "Ambil model hulu secara automatik", + "autoFetchModelsTooltip": "Ambil dan simpan model hulu apabila diperlukan", + "autoFetchModelsEnabled": "Model hulu auto-fetch diaktifkan", + "autoFetchModelsDisabled": "Model hulu auto-fetch dinyahdayakan", + "autoFetchModelsToggleFailed": "Gagal untuk menghidupkan model upstream auto-fetch", + "autoFetchModelsPartialFailure": "Beberapa sambungan telah dikemas kini, tetapi pengambilan auto model hulu tidak diubah di semua tempat", + "overridesUpstreamModel": "Mengganti hulu", + "overridesUpstreamModelHint": "Tetapan anda mengatasi model hulu ini", + "resetToUpstreamDefaults": "Pulihkan tetapan asal upstream", + "resetToUpstreamDefaultsSuccess": "Mengembalikan tetapan lalai model upstream", + "resetToUpstreamDefaultsFailed": "Gagal untuk memulihkan tetapan lalai model upstream", "autoSyncTooltip": "Muat semula senarai model secara automatik setiap 24j (boleh dikonfigurasikan melalui MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Autosegerak didayakan — model akan dimuat semula secara berkala", "autoSyncDisabled": "Autosegerak dilumpuhkan", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Tulis semula panggilan alat web_fetch asli ke /v1/web/fetch OmniRoute.", "interceptionLoadError": "Gagal memuatkan tetapan pintasan: {error}", "interceptionSaveError": "Gagal menyimpan tetapan pintasan: {error}", - "ccAliasSectionTitle": "Dedahkan dalam Claude Code (claude/…)", - "ccAliasSectionHint": "Iklankan model penyedia ini di bawah claude/<provider>/<model> ID cermin supaya penemuan model gateway Claude Code dapat menyenaraikannya. Dimatikan secara lalai — mengaktifkannya menggandakan entri katalog untuk semua pelanggan.", - "ccAliasProviderLevelLabel": "Penyedia lalai", - "ccAliasModelOverridesLabel": "Tindakan mengikut model", - "ccAliasModelOverrideAriaLabel": "Tindakan Ganti untuk {modelId}", - "ccAliasStateInherit": "Warisi", - "ccAliasStateOn": "Hidup", - "ccAliasStateOff": "Matikan", - "ccAliasAddModelPlaceholder": "Id Model (contoh: gpt-4o)", - "ccAliasAddModelButton": "Tambah override", - "ccAliasLoadError": "Gagal memuat tetapan discovery-alias: {error}", - "ccAliasSaveError": "Gagal menyimpan tetapan discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Sambungkan Galadriel dengan kunci API.", "predibase": "Kredit percubaan percuma $25 (tempoh sah 30 hari)", "chenzk": "Gerbang serasi OpenAI dengan katalog model langsung di chenzk.top.", - "freepik": "Jana imej dengan API Mystic Freepik.", + "magnific": "Jana imej dengan API Mystic Freepik.", "freetheai": "Gerbang serasi OpenAI percuma dengan sokongan model passthrough.", "g4f-gemini": "Proksi terbalik g4f.space tanpa kunci percuma ke Gemini, terhad kepada 5 permintaan seminit.", "g4f-groq": "Proksi terbalik g4f.space tanpa kunci percuma ke Groq, terhad kepada 5 permintaan seminit.", @@ -6209,6 +6224,7 @@ "claude": "Sambungkan Claude Code dengan aliran OAuth sedia ada.", "cline": "Sambungkan Cline dengan aliran OAuth sedia ada.", "cursor": "Sambungkan Cursor IDE dengan aliran OAuth sedia ada.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Sambungkan GitHub Copilot dengan aliran OAuth sedia ada.", "gitlab-duo": "Aplikasi OAuth dengan skop ai_features + read_user. Konfigurasikan GITLAB_DUO_OAUTH_CLIENT_ID dan secara pilihan GITLAB_DUO_OAUTH_CLIENT_SECRET pada tika OmniRoute ini.", "kilocode": "Sambungkan Kilo Code dengan aliran OAuth sedia ada.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Rakan Sumber Terbuka", "cheaperInferenceSupporterTooltip": "Cheaper Inference menyokong OmniRoute sebagai Rakan Sumber Terbuka", "kimiPartnerLinkNote": "Pautan rakan kongsi — menyokong OmniRoute tanpa kos tambahan kepada anda", + "codexQuotaPools": "Kumpulan kuota Codex", + "codexPoolAvailable": "Tersedia", + "codexPoolPartiallyLimited": "Dihadkan sebahagian", + "codexPoolFullyLimited": "Dihadkan sepenuhnya", + "codexPoolLimited": "{count} dihadkan", + "codexPoolQuotaExhausted": "Kuota telah habis", + "codexPoolCoolingDown": "Dalam tempoh menunggu", + "codexPoolUsed": "digunakan", + "codexPoolUntil": "Sehingga {value}", "anonymousFallbackTitle": "Fallback Tanpa Nama", "anonymousFallbackDesc": "Apabila semua sambungan yang dikonfigurasikan habis (kuota, kredit, atau tamat tempoh), gunakan sementara tier tanpa kunci penyedia ini. Matikan untuk mengabaikan penyedia ini daripada menghantar permintaan tanpa nama — disyorkan apabila tier tanpa kunci menolak permintaan tersebut (401).", "anonymousFallbackEnabled": "Fallback tanpa nama diaktifkan untuk {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Tetapan titik akhir model yang disimpan", "searchByModelAria": "Cari mengikut model", "selectSupportedEndpoint": "Pilih sekurang-kurangnya satu titik akhir yang disokong", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsEnabled": "Model hulu auto-fetch diaktifkan", - "autoFetchModels": "Ambil model hulu secara automatik", - "autoFetchModelsTooltip": "Ambil dan simpan model hulu apabila diperlukan", - "autoFetchModelsToggleFailed": "Gagal untuk menghidupkan model upstream auto-fetch", - "autoFetchModelsDisabled": "Model hulu auto-fetch dinyahdayakan", - "overridesUpstreamModel": "Mengganti hulu", - "autoFetchModelsPartialFailure": "Beberapa sambungan telah dikemas kini, tetapi pengambilan auto model hulu tidak diubah di semua tempat", - "overridesUpstreamModelHint": "Tetapan anda mengatasi model hulu ini", - "resetToUpstreamDefaults": "Pulihkan tetapan asal upstream", - "resetToUpstreamDefaultsFailed": "Gagal untuk memulihkan tetapan lalai model upstream", - "resetToUpstreamDefaultsSuccess": "Mengembalikan tetapan lalai model upstream" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "tetapan", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Kata Kunci Disekat", "customBannedSignalsDesc": "Kata kunci tambahan yang mencetuskan pengesanan sekatan akaun kekal. Kata kunci terbina dalam sentiasa digunakan.", "customBannedSignalsPlaceholder": "cth. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "dikonfigurasikan", "none": "Tiada", "modelOverrideValuePlaceholder": "Nilai angka", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Tambah nilai kunci", "noModelOverrides": "Tiada pintasan dikonfigurasikan untuk model ini.", "modelOverrideLoadFailed": "Gagal memuatkan pintasan model", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK Ringkas (文言)", "description": "Gaya ultra-ringkas Bahasa Cina Klasik (hanya tersedia untuk bahasa Cina)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Kombinasi round-robin dan rawak bertukar ke sambungan yang berbeza pada setiap permintaan dan bukannya menyematkan keseluruhan perbualan pada satu sambungan melalui hash mesej pertama. Biarkan dimatikan untuk mengekalkan hit cache prompt bagi sembang berbilang giliran. Penggantian bagi setiap kombinasi diutamakan.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Redaksi Kredensial", "credentialRedactionDesc": "Redaksikan kunci API, token, dan rahsia daripada konteks yang dihantar kepada penyedia dan daripada respons.", "enableCredentialRedaction": "Dayakan penyuntingan kelayakan", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Dayakan enjin", "enableDescription": "Berjalan terakhir dalam tindanan (selepas RTK/Caveman membersihkan teks, OmniGlyph menukar baki kepada imej) dan juga berjalan secara bersendirian melalui mod omniglyph. Ini ialah pratonton dan kekal dimatikan secara lalai sehingga pengesahan hujung-ke-hujung selesai.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Disimpan.", "saveFailed": "Tidak dapat menyimpan.", "enableAria": "Dayakan enjin OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "maksimum", "grokAutoTopUpMonth": "bulan", "grokAdditionalCredits": "Kredit Tambahan", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Pembalak", "proxyTab": "proksi", "budgetManagement": "Pengurusan Belanjawan", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Token Pertama", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 3e1b2d34c2..397c206888 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Visueel verzoek tijdlijn", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "open", "close": "sluiten" }, - "noResults": "Geen resultaten", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Geen resultaten" }, "webhooks": { "title": "Webhaken", @@ -1739,8 +1739,8 @@ "quotaShare": "Quota-aandeel", "discovery": "Ontdekking", "freeProviderRankings": "Ranglijst gratis providers", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Gratis niveaus", "gamification": "Gamification", "leaderboard": "Leaderboard", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Deze aanbieder is beëindigd", "riskNotice": { "title": "Voordat je doorgaat", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Provider met gebruiksvoorbehouden — klik voor details", "oauth": "Deze provider gebruikt je officiële productsessie/OAuth, die niet is geautoriseerd voor proxy-/routergebruik. We raden intensief gebruik van autonome agenten (OpenCloud-stijl, lange stappenreeksen, grote batches) af — de upstream kan reageren door het account te beperken of te blokkeren. Gebruik op eigen risico.", "webCookie": "Deze provider authenticeert via je websessiecookies. De upstream-dienst kan de sessie op elk moment ongeldig maken, waardoor je opnieuw moet inloggen. Niet aanbevolen voor langdurig onbeheerd gebruik. Gebruik op eigen risico.", @@ -5107,9 +5111,9 @@ "cancel": "Annuleren" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Uitgeschakeld", "enableProvider": "Aanbieder inschakelen", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "{count} bestaande modellen overgeslagen", "autoSync": "Automatische synchronisatie", "autoSyncShort": "Synchroniseren", + "autoFetchModels": "Automatisch upstream-modellen ophalen", + "autoFetchModelsTooltip": "Haal upstream-modellen op en cache ze indien nodig", + "autoFetchModelsEnabled": "Upstream model auto-fetch ingeschakeld", + "autoFetchModelsDisabled": "Auto-fetch van upstreammodel uitgeschakeld", + "autoFetchModelsToggleFailed": "Kon upstream model auto-fetch niet omzetten", + "autoFetchModelsPartialFailure": "Sommige verbindingen zijn bijgewerkt, maar het automatisch ophalen van het upstream-model is niet overal gewijzigd", + "overridesUpstreamModel": "Overschrijft upstream", + "overridesUpstreamModelHint": "Jouw instellingen overschrijven dit upstream model", + "resetToUpstreamDefaults": "Herstel upstream standaardinstellingen", + "resetToUpstreamDefaultsSuccess": "Herstelde standaardinstellingen van upstream-model", + "resetToUpstreamDefaultsFailed": "Het is niet gelukt om de standaardinstellingen van het upstream-model te herstellen", "autoSyncTooltip": "Modellijst automatisch elke 24 uur vernieuwen (configureerbaar via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Automatische synchronisatie ingeschakeld: modellen worden periodiek vernieuwd", "autoSyncDisabled": "Automatische synchronisatie uitgeschakeld", @@ -5439,17 +5454,17 @@ "interceptionLoadError": "Laden van onderscheppingsinstellingen mislukt: {error}", "interceptionSaveError": "Opslaan van onderscheppingsinstellingen mislukt: {error}", "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", - "ccAliasSectionHint": "Adverteer de modellen van deze provider onder claude/<provider>/<model> mirror-id's zodat het gateway-modelontdekkingsmechanisme van Claude Code ze kan weergeven. Standaard uitgeschakeld — het inschakelen hiervan verdubbelt de catalogusvermeldingen voor alle klanten.", - "ccAliasProviderLevelLabel": "Provider standaard", - "ccAliasModelOverridesLabel": "Per-model overschrijvingen", - "ccAliasModelOverrideAriaLabel": "Overschrijving voor {modelId}", - "ccAliasStateInherit": "Overnemen", - "ccAliasStateOn": "Aan", - "ccAliasStateOff": "Uit", - "ccAliasAddModelPlaceholder": "Model-id (bijv. gpt-4o)", - "ccAliasAddModelButton": "Voeg overschrijving toe", - "ccAliasLoadError": "Kon de discovery-alias instellingen niet laden: {error}", - "ccAliasSaveError": "Kon de discovery-alias instelling niet opslaan: {error}", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Verbind Galadriel met een API-sleutel.", "predibase": "$25 gratis proeftegoed (30 dagen geldig)", "chenzk": "OpenAI-compatibele gateway met een live modelcatalogus op chenzk.top.", - "freepik": "Genereer afbeeldingen met de Mystic API van Freepik.", + "magnific": "Genereer afbeeldingen met de Mystic API van Freepik.", "freetheai": "Gratis OpenAI-compatibele gateway met ondersteuning voor passthrough-modellen.", "g4f-gemini": "Gratis no-key g4f.space reverse proxy naar Gemini, beperkt tot 5 verzoeken per minuut.", "g4f-groq": "Gratis no-key g4f.space reverse proxy naar Groq, beperkt tot 5 verzoeken per minuut.", @@ -6209,6 +6224,7 @@ "claude": "Verbind Claude Code met de bestaande OAuth-flow.", "cline": "Verbind Cline met de bestaande OAuth-flow.", "cursor": "Verbind Cursor IDE met de bestaande OAuth-flow.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Verbind GitHub Copilot met de bestaande OAuth-flow.", "gitlab-duo": "OAuth-applicatie met ai_features + read_user scopes. Configureer GITLAB_DUO_OAUTH_CLIENT_ID en optioneel GITLAB_DUO_OAUTH_CLIENT_SECRET op deze OmniRoute-instantie.", "kilocode": "Verbind Kilo Code met de bestaande OAuth-flow.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Opensourcevriend", "cheaperInferenceSupporterTooltip": "Cheaper Inference steunt OmniRoute als opensourcevriend", "kimiPartnerLinkNote": "Partnerlink — ondersteunt OmniRoute zonder extra kosten voor u", + "codexQuotaPools": "Codex-quotapools", + "codexPoolAvailable": "Beschikbaar", + "codexPoolPartiallyLimited": "Gedeeltelijk beperkt", + "codexPoolFullyLimited": "Volledig beperkt", + "codexPoolLimited": "{count} beperkt", + "codexPoolQuotaExhausted": "Quota opgebruikt", + "codexPoolCoolingDown": "In afkoelperiode", + "codexPoolUsed": "gebruikt", + "codexPoolUntil": "Tot {value}", "anonymousFallbackTitle": "Anonieme fallback", "anonymousFallbackDesc": "Wanneer alle geconfigureerde verbindingen zijn uitgeput (quota, tegoeden of vervaldatum), gebruik tijdelijk de keyless-laag van deze provider. Zet uit om deze provider over te slaan in plaats van anonieme verzoeken te verzenden - aanbevolen wanneer de keyless-laag deze afwijst (401).", "anonymousFallbackEnabled": "Anonieme fallback ingeschakeld voor {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Instellingen voor opgeslagen model-eindpunt", "searchByModelAria": "Zoeken op model", "selectSupportedEndpoint": "Selecteer ten minste één ondersteunde eindpunt", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsDisabled": "Auto-fetch van upstreammodel uitgeschakeld", - "autoFetchModelsTooltip": "Haal upstream-modellen op en cache ze indien nodig", - "autoFetchModels": "Automatisch upstream-modellen ophalen", - "autoFetchModelsEnabled": "Upstream model auto-fetch ingeschakeld", - "autoFetchModelsToggleFailed": "Kon upstream model auto-fetch niet omzetten", - "overridesUpstreamModelHint": "Jouw instellingen overschrijven dit upstream model", - "overridesUpstreamModel": "Overschrijft upstream", - "autoFetchModelsPartialFailure": "Sommige verbindingen zijn bijgewerkt, maar het automatisch ophalen van het upstream-model is niet overal gewijzigd", - "resetToUpstreamDefaults": "Herstel upstream standaardinstellingen", - "resetToUpstreamDefaultsSuccess": "Herstelde standaardinstellingen van upstream-model", - "resetToUpstreamDefaultsFailed": "Het is niet gelukt om de standaardinstellingen van het upstream-model te herstellen" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Instellingen", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Verboden trefwoorden", "customBannedSignalsDesc": "Aanvullende trefwoorden die detectie van permanente accountblokkering activeren. Ingebouwde trefwoorden zijn altijd van toepassing.", "customBannedSignalsPlaceholder": "bijv. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "geconfigureerd", "none": "Geen", "modelOverrideValuePlaceholder": "Numerieke waarde", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Sleutelwaarde toevoegen", "noModelOverrides": "Geen overschrijvingen geconfigureerd voor dit model.", "modelOverrideLoadFailed": "Laden van modeloverschrijvingen mislukt", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Beknopt CJK (文言)", "description": "Klassiek-Chinese ultra-beknopte stijl (alleen beschikbaar voor Chinees)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Round-robin- en willekeurige combinaties wisselen bij elk verzoek naar een andere verbinding in plaats van een heel gesprek vast te pinnen aan één verbinding op basis van de hash van het eerste bericht. Laat uitgeschakeld om prompt-cache-hits voor multi-turn chats te behouden. Overschrijvingen per combinatie hebben voorrang.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Maskeren van inloggegevens", "credentialRedactionDesc": "Maskeer API-sleutels, tokens en geheimen in context die naar providers wordt verzonden en in antwoorden.", "enableCredentialRedaction": "Maskeren van inloggegevens inschakelen", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Schakel de engine in", "enableDescription": "Draait als laatste in de stack (nadat RTK/Caveman de tekst opschoont, converteert OmniGlyph de rest naar afbeeldingen) en draait ook standalone via de omniglyph-modus. Dit is een preview en blijft standaard uitgeschakeld totdat de end-to-end-validatie is voltooid.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Opgeslagen.", "saveFailed": "Opslaan mislukt.", "enableAria": "Schakel de OmniGlyph-engine in", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "maand", "grokAdditionalCredits": "Aanvullende Credits", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budgetbeheer", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Eerste token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index f8309f8536..502bbae12e 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Visuell forespørselstidslinje", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "åpne", "close": "lukk" }, - "noResults": "Ingen resultater", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Ingen resultater" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "Kvoteandel", "discovery": "Oppdagelse", "freeProviderRankings": "Rangering av gratisleverandører", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Gratisnivåer", "gamification": "Spillifisering", "leaderboard": "Ledertavle", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Denne leverandøren er avviklet", "riskNotice": { "title": "Før du fortsetter", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Leverandør med forbehold om bruk — klikk for detaljer", "oauth": "Denne leverandøren bruker din offisielle produktøkt/OAuth, som ikke er autorisert for proxy-/rutingsbruk. Vi anbefaler ikke intensiv bruk av autonome agenter (OpenCloud-stil, lange flertrinnsflyter, store batcher) — oppstrømmen kan reagere med å begrense eller utestenge kontoen. Bruk på egen risiko.", "webCookie": "Denne leverandøren autentiserer via informasjonskapsler (cookies) fra nettøkten din. Oppstrømstjenesten kan ugyldiggjøre økten når som helst, noe som krever at du logger inn på nytt. Anbefales ikke for lange uovervåkede operasjoner. Bruk på egen risiko.", @@ -5107,9 +5111,9 @@ "cancel": "Avbryt" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Deaktivert", "enableProvider": "Aktiver leverandør", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Hopper over {count} eksisterende modeller", "autoSync": "Auto-synkronisering", "autoSyncShort": "Synkronisering", + "autoFetchModels": "Auto-hent oppstrøms modeller", + "autoFetchModelsTooltip": "Hent og cache upstream-modeller når det er nødvendig", + "autoFetchModelsEnabled": "Oppstrømsmodell automatisk henting aktivert", + "autoFetchModelsDisabled": "Oppstrømsmodell automatisk henting deaktivert", + "autoFetchModelsToggleFailed": "Kunne ikke aktivere automatisk henting av upstream-modell", + "autoFetchModelsPartialFailure": "Noen tilkoblinger ble oppdatert, men upstream-modellens auto-hent ble ikke endret overalt", + "overridesUpstreamModel": "Overstyrer upstream", + "overridesUpstreamModelHint": "Dine innstillinger overstyrer denne upstream-modellen", + "resetToUpstreamDefaults": "Gjenopprett upstream-standardinnstillinger", + "resetToUpstreamDefaultsSuccess": "Gjenopprettet upstream-modellinnstillinger", + "resetToUpstreamDefaultsFailed": "Kunne ikke gjenopprette standardinnstillinger for upstream-modellen", "autoSyncTooltip": "Oppdater modelllisten automatisk hver 24. time (kan konfigureres via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Automatisk synkronisering aktivert – modellene oppdateres med jevne mellomrom", "autoSyncDisabled": "Automatisk synkronisering er deaktivert", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Omskriv innebygde web_fetch-verktøykall til OmniRoutes /v1/web/fetch.", "interceptionLoadError": "Kunne ikke laste inn avskjæringsinnstillinger: {error}", "interceptionSaveError": "Kunne ikke lagre avskjæringsinnstillinger: {error}", - "ccAliasSectionTitle": "Eksponer i Claude Code (claude/…)", - "ccAliasSectionHint": "Reklamer denne leverandørens modeller under claude/<provider>/<model> speil-ID-er slik at Claude Codes gateway-modelloppdagelse kan liste dem. Av som standard — aktivering av dette dobler katalogoppføringene for alle klienter.", - "ccAliasProviderLevelLabel": "Leverandørstandard", - "ccAliasModelOverridesLabel": "Per-modell overstyringer", - "ccAliasModelOverrideAriaLabel": "Overstyring for {modelId}", - "ccAliasStateInherit": "Arv", - "ccAliasStateOn": "På", - "ccAliasStateOff": "Av", - "ccAliasAddModelPlaceholder": "Modell-ID (f.eks. gpt-4o)", - "ccAliasAddModelButton": "Legg til overstyring", - "ccAliasLoadError": "Kunne ikke laste inn discovery-alias-innstillinger: {error}", - "ccAliasSaveError": "Kunne ikke lagre discovery-alias-innstillingen: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Koble til Galadriel med en API-nøkkel.", "predibase": "$25 gratis prøveperiode-kreditt (30 dagers gyldighet)", "chenzk": "OpenAI-kompatibel gateway med en live modellkatalog på chenzk.top.", - "freepik": "Generer bilder med Freepiks Mystic-API.", + "magnific": "Generer bilder med Freepiks Mystic-API.", "freetheai": "Gratis OpenAI-kompatibel gateway med passthrough-modellstøtte.", "g4f-gemini": "Gratis nøkkelfri g4f.space reverse proxy til Gemini, begrenset til 5 forespørsler per minutt.", "g4f-groq": "Gratis nøkkelfri g4f.space reverse proxy til Groq, begrenset til 5 forespørsler per minutt.", @@ -6209,6 +6224,7 @@ "claude": "Koble til Claude Code med den eksisterende OAuth-flyten.", "cline": "Koble til Cline med den eksisterende OAuth-flyten.", "cursor": "Koble til Cursor IDE med den eksisterende OAuth-flyten.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Koble til GitHub Copilot med den eksisterende OAuth-flyten.", "gitlab-duo": "OAuth-applikasjon med ai_features + read_user-scopes. Konfigurer GITLAB_DUO_OAUTH_CLIENT_ID og eventuelt GITLAB_DUO_OAUTH_CLIENT_SECRET på denne OmniRoute-instansen.", "kilocode": "Koble til Kilo Code med den eksisterende OAuth-flyten.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Åpen kildekode-venn", "cheaperInferenceSupporterTooltip": "Cheaper Inference støtter OmniRoute som åpen kildekode-venn", "kimiPartnerLinkNote": "Partnerlenke — støtter OmniRoute uten ekstra kostnad for deg", + "codexQuotaPools": "Codex-kvotepuljer", + "codexPoolAvailable": "Tilgjengelig", + "codexPoolPartiallyLimited": "Delvis begrenset", + "codexPoolFullyLimited": "Fullstendig begrenset", + "codexPoolLimited": "{count} begrenset", + "codexPoolQuotaExhausted": "Kvoten er oppbrukt", + "codexPoolCoolingDown": "I nedkjølingsperiode", + "codexPoolUsed": "brukt", + "codexPoolUntil": "Til {value}", "anonymousFallbackTitle": "Anonym fallback", "anonymousFallbackDesc": "Når alle konfigurerte tilkoblinger er brukt opp (kvote, kreditter eller utløp), bruk midlertidig denne leverandørens nøkkelløse nivå. Slå av for å hoppe over denne leverandøren i stedet for å sende anonyme forespørsel — anbefales når det nøkkelløse nivået avviser dem (401).", "anonymousFallbackEnabled": "Anonym fallback aktivert for {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Innstillinger for lagrede modellendepunkter", "searchByModelAria": "Søk etter modell", "selectSupportedEndpoint": "Velg minst ett støttet endepunkt", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsTooltip": "Hent og cache upstream-modeller når det er nødvendig", - "autoFetchModelsEnabled": "Oppstrømsmodell automatisk henting aktivert", - "autoFetchModelsDisabled": "Oppstrømsmodell automatisk henting deaktivert", - "autoFetchModels": "Auto-hent oppstrøms modeller", - "autoFetchModelsToggleFailed": "Kunne ikke aktivere automatisk henting av upstream-modell", - "overridesUpstreamModel": "Overstyrer upstream", - "autoFetchModelsPartialFailure": "Noen tilkoblinger ble oppdatert, men upstream-modellens auto-hent ble ikke endret overalt", - "overridesUpstreamModelHint": "Dine innstillinger overstyrer denne upstream-modellen", - "resetToUpstreamDefaultsSuccess": "Gjenopprettet upstream-modellinnstillinger", - "resetToUpstreamDefaults": "Gjenopprett upstream-standardinnstillinger", - "resetToUpstreamDefaultsFailed": "Kunne ikke gjenopprette standardinnstillinger for upstream-modellen" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Innstillinger", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Forbudte nøkkelord", "customBannedSignalsDesc": "Ytterligere nøkkelord som utløser deteksjon av permanent kontoutestengelse. Innebygde nøkkelord gjelder alltid.", "customBannedSignalsPlaceholder": "f.eks. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "konfigurert", "none": "Ingen", "modelOverrideValuePlaceholder": "Numerisk verdi", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Legg til nøkkelverdi", "noModelOverrides": "Ingen overstyringer er konfigurert for denne modellen.", "modelOverrideLoadFailed": "Kunne ikke laste modelloverstyringer", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Kortfattet CJK (文言)", "description": "Klassisk-kinesisk ultrakortfattet stil (kun tilgjengelig for kinesisk)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Round-robin- og tilfeldige kombinasjoner roterer til en annen tilkobling for hver forespørsel i stedet for å låse en hel samtale til én tilkobling basert på hashen til den første meldingen. La være av for å bevare prompt-cache-treff for samtaler med flere runder. Overstyringer per kombinasjon har forrang.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Sladding av legitimasjon", "credentialRedactionDesc": "Sladd API-nøkler, tokener og hemmeligheter fra kontekst som sendes til leverandører, og fra svar.", "enableCredentialRedaction": "Aktiver sladding av legitimasjon", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Aktiver motoren", "enableDescription": "Kjører sist i stakken (etter at RTK/Caveman renser teksten, og OmniGlyph konverterer resten til bilder) og kjører også frittstående via omniglyph-modus. Dette er en forhåndsvisning og forblir deaktivert som standard til end-til-end-validering er fullført.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Lagret.", "saveFailed": "Kunne ikke lagre.", "enableAria": "Aktiver OmniGlyph-motoren", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "maks", "grokAutoTopUpMonth": "måned", "grokAdditionalCredits": "Ytterligere Krediteringer", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Fullmakt", "budgetManagement": "Budsjettstyring", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Første token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index c8048289c4..5285b3d18c 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Visual na kahilingan ng timeline", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "buksan", "close": "isara" }, - "noResults": "Walang resulta", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Walang resulta" }, "webhooks": { "title": "Mga Webhook", @@ -1739,8 +1739,8 @@ "quotaShare": "Quota Share", "discovery": "Pagtuklas", "freeProviderRankings": "Mga Ranggo ng Libreng Provider", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Mga Libreng Tier", "gamification": "Gamification", "leaderboard": "Leaderboard", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Ang provider na ito ay hindi na ginagamit", "riskNotice": { "title": "Bago magpatuloy", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Provider na may mga paalala sa paggamit — i-click para sa mga detalye", "oauth": "Gumagamit ang provider na ito ng iyong opisyal na session ng produkto/OAuth, na hindi awtorisado para sa paggamit ng proxy/router. Hindi namin inirerekomenda ang masinsinang paggamit ng autonomous agent (estilong OpenCloud, mahabang multi-step na flow, malalaking batch) — maaaring tumugon ang upstream sa pamamagitan ng paghihigpit o pag-ban sa account. Gamitin sa sarili mong panganib.", "webCookie": "Nagpapatotoo ang provider na ito sa pamamagitan ng iyong mga cookie sa web session. Maaaring pawalang-bisa ng upstream na serbisyo ang session anumang oras, na nangangailangan sa iyong mag-log in muli. Hindi inirerekomenda para sa mahabang operasyon na walang bantay. Gamitin sa sarili mong panganib.", @@ -5107,9 +5111,9 @@ "cancel": "Kanselahin" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Hindi pinagana", "enableProvider": "Paganahin ang provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Pinapalampas ang {count} na umiiral na mga modelo", "autoSync": "Auto-Sync", "autoSyncShort": "I-sync", + "autoFetchModels": "Awtomatikong kunin ang mga upstream na modelo", + "autoFetchModelsTooltip": "Kunin at i-cache ang upstream models kapag kinakailangan", + "autoFetchModelsEnabled": "Naka-enable ang auto-fetch ng upstream model", + "autoFetchModelsDisabled": "Naka-disable ang auto-fetch ng upstream model", + "autoFetchModelsToggleFailed": "Nabigong i-toggle ang upstream model auto-fetch", + "autoFetchModelsPartialFailure": "Ilang koneksyon ang na-update, ngunit ang auto-fetch ng upstream model ay hindi nagbago sa lahat ng lugar", + "overridesUpstreamModel": "Pinalitan ang upstream", + "overridesUpstreamModelHint": "Ang iyong mga setting ay nangingibabaw sa modelong ito mula sa upstream", + "resetToUpstreamDefaults": "Ibalik ang mga default ng upstream", + "resetToUpstreamDefaultsSuccess": "Ibinalik ang mga default ng upstream model", + "resetToUpstreamDefaultsFailed": "Nabigong maibalik ang mga default ng upstream model", "autoSyncTooltip": "Awtomatikong i-refresh ang listahan ng modelo tuwing 24h (mako-configure sa pamamagitan ng MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Pinagana ang auto-sync — pana-panahong magre-refresh ang mga modelo", "autoSyncDisabled": "Na-disable ang auto-sync", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "I-rewrite ang mga native na tawag sa tool na web_fetch sa /v1/web/fetch ng OmniRoute.", "interceptionLoadError": "Hindi nai-load ang mga setting ng interception: {error}", "interceptionSaveError": "Hindi nai-save ang mga setting ng interception: {error}", - "ccAliasSectionTitle": "I-expose sa Claude Code (claude/…)", - "ccAliasSectionHint": "I-anunsyo ang mga modelo ng provider na ito sa ilalim ng claude/<provider>/<model> mirror ids upang ma-lista ang mga ito sa gateway model discovery ng Claude Code. Off sa default — ang pag-enable nito ay nagdodoble ng mga entry sa katalogo para sa lahat ng kliyente.", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", "ccAliasProviderLevelLabel": "Provider default", - "ccAliasModelOverridesLabel": "Mga Override Bawat Modelo", - "ccAliasModelOverrideAriaLabel": "Override para sa {modelId}", - "ccAliasStateInherit": "Mamana", - "ccAliasStateOn": "Sa", - "ccAliasStateOff": "Patay", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", - "ccAliasAddModelButton": "Magdagdag ng override", - "ccAliasLoadError": "Nabigong i-load ang mga setting ng discovery-alias: {error}", - "ccAliasSaveError": "Nabigong i-save ang setting ng discovery-alias: {error}", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Ikonekta ang Galadriel gamit ang isang API key.", "predibase": "$25 na libreng trial credits (30-araw na validity)", "chenzk": "OpenAI-compatible na gateway na may live model catalog sa chenzk.top.", - "freepik": "Bumuo ng mga larawan gamit ang Mystic API ng Freepik.", + "magnific": "Bumuo ng mga larawan gamit ang Mystic API ng Freepik.", "freetheai": "Libreng OpenAI-compatible na gateway na may suporta sa passthrough model.", "g4f-gemini": "Libreng no-key na g4f.space reverse proxy sa Gemini, limitado sa 5 kahilingan bawat minuto.", "g4f-groq": "Libreng no-key na g4f.space reverse proxy sa Groq, limitado sa 5 kahilingan bawat minuto.", @@ -6209,6 +6224,7 @@ "claude": "Ikonekta ang Claude Code gamit ang umiiral na OAuth flow.", "cline": "Ikonekta ang Cline gamit ang umiiral na OAuth flow.", "cursor": "Ikonekta ang Cursor IDE gamit ang umiiral na OAuth flow.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Ikonekta ang GitHub Copilot gamit ang umiiral na OAuth flow.", "gitlab-duo": "OAuth application na may ai_features + read_user scopes. I-configure ang GITLAB_DUO_OAUTH_CLIENT_ID at opsyonal ang GITLAB_DUO_OAUTH_CLIENT_SECRET sa OmniRoute instance na ito.", "kilocode": "Ikonekta ang Kilo Code gamit ang umiiral na OAuth flow.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Kaibigan ng Open Source", "cheaperInferenceSupporterTooltip": "Sinusuportahan ng Cheaper Inference ang OmniRoute bilang Kaibigan ng Open Source", "kimiPartnerLinkNote": "Link ng kasosyo — sumusuporta sa OmniRoute nang walang karagdagang gastos sa iyo", + "codexQuotaPools": "Mga pool ng quota ng Codex", + "codexPoolAvailable": "Magagamit", + "codexPoolPartiallyLimited": "Bahagyang limitado", + "codexPoolFullyLimited": "Ganap na limitado", + "codexPoolLimited": "{count} limitado", + "codexPoolQuotaExhausted": "Ubos na ang quota", + "codexPoolCoolingDown": "Nasa panahon ng paghihintay", + "codexPoolUsed": "nagamit", + "codexPoolUntil": "Hanggang {value}", "anonymousFallbackTitle": "Anonymous fallback", "anonymousFallbackDesc": "Kapag naubos na ang lahat ng nakatakdang koneksyon (quota, kredito, o pag-expire), pansamantalang gamitin ang keyless tier ng provider na ito. Patayin upang laktawan ang provider na ito sa halip na magpadala ng mga hindi nagpapakilalang kahilingan — inirerekomenda kapag tinanggihan ng keyless tier ang mga ito (401).", "anonymousFallbackEnabled": "Naka-enable ang anonymous fallback para sa {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Naka-save na mga setting ng endpoint ng modelo", "searchByModelAria": "Maghanap ayon sa modelo", "selectSupportedEndpoint": "Pumili ng hindi bababa sa isang sinusuportahang endpoint", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsDisabled": "Naka-disable ang auto-fetch ng upstream model", - "autoFetchModelsTooltip": "Kunin at i-cache ang upstream models kapag kinakailangan", - "autoFetchModelsEnabled": "Naka-enable ang auto-fetch ng upstream model", - "autoFetchModels": "Awtomatikong kunin ang mga upstream na modelo", - "autoFetchModelsToggleFailed": "Nabigong i-toggle ang upstream model auto-fetch", - "overridesUpstreamModel": "Pinalitan ang upstream", - "overridesUpstreamModelHint": "Ang iyong mga setting ay nangingibabaw sa modelong ito mula sa upstream", - "resetToUpstreamDefaults": "Ibalik ang mga default ng upstream", - "resetToUpstreamDefaultsSuccess": "Ibinalik ang mga default ng upstream model", - "autoFetchModelsPartialFailure": "Ilang koneksyon ang na-update, ngunit ang auto-fetch ng upstream model ay hindi nagbago sa lahat ng lugar", - "resetToUpstreamDefaultsFailed": "Nabigong maibalik ang mga default ng upstream model" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Mga setting", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Mga Banned na Keyword", "customBannedSignalsDesc": "Mga karagdagang keyword na nagti-trigger ng pagtukoy sa permanenteng pag-ban ng account. Palaging nalalapat ang mga built-in na keyword.", "customBannedSignalsPlaceholder": "hal. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "naka-configure", "none": "Wala", "modelOverrideValuePlaceholder": "Numerikong halaga", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Magdagdag ng key value", "noModelOverrides": "Walang naka-configure na mga override para sa modelong ito.", "modelOverrideLoadFailed": "Bigo sa pag-load ng mga override ng modelo", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Maikling CJK (文言)", "description": "Klasikong Tsino na ultra-maikling estilo (magagamit lamang para sa Tsino)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Ang mga round-robin at random combo ay umiikot sa ibang koneksyon sa bawat request sa halip na i-pin ang buong pag-uusap sa isang koneksyon sa pamamagitan ng hash ng unang mensahe. Iwanang naka-off upang mapanatili ang mga prompt-cache hit para sa mga multi-turn chat. Mas nangingibabaw ang mga per-combo override.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Pag-redact ng Kredensyal", "credentialRedactionDesc": "I-redact ang mga API key, token, at secret mula sa kontekstong ipinadala sa mga provider at mula sa mga tugon.", "enableCredentialRedaction": "I-enable ang credential redaction", @@ -8600,6 +8623,27 @@ }, "enableTitle": "I-enable ang engine", "enableDescription": "Tumatakbo nang huli sa stack (pagkatapos linisin ng RTK/Caveman ang text, kino-convert ng OmniGlyph ang natitira sa mga imahe) at tumatakbo rin nang standalone sa pamamagitan ng omniglyph mode. Ito ay isang preview at nananatiling naka-off by default hanggang sa makumpleto ang end-to-end na pagpapatunay.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Nai-save.", "saveFailed": "Hindi mai-save.", "enableAria": "I-enable ang OmniGlyph engine", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "buwan", "grokAdditionalCredits": "Karagdagang Kredito", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Magtotroso", "proxyTab": "Proxy", "budgetManagement": "Pamamahala ng Badyet", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Unang Token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 660d2fa395..e29e73670e 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Logi konsoli", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Wizualny harmonogram żądań", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Routing globalny", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "otwórz", "close": "zamknij" }, - "noResults": "Brak wyników", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Brak wyników" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "Udział w limicie", "discovery": "Odkrywanie", "freeProviderRankings": "Rankingi darmowych dostawców", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Bezpłatne pakiety", "gamification": "Grywalizacja", "leaderboard": "Tabela liderów", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Wybierz, jak żądania są rozdzielane między models - dostępnych jest 14 strategii", "wizardStep4Title": "Przejrzyj i zapisz", "wizardStep4Desc": "Przejrzyj konfigurację i aktywuj combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "Wł.", "emailVisibilityStateOff": "Wył.", "reorderHandle": "Przeciągnij, aby zmienić kolejność", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Ten provider jest przestarzały", "riskNotice": { "title": "Przed kontynuowaniem", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Provider z zastrzeżeniami dotyczącymi użytkowania — kliknij, aby uzyskać szczegóły", "oauth": "Ten provider korzysta z oficjalnej sesji produktu/OAuth, która nie jest autoryzowana do użytku jako proxy/router. Nie zalecamy intensywnego korzystania z autonomicznych agentów (w stylu OpenCloud, długich wieloetapowych przepływów, dużych partii) — upstream może zareagować ograniczeniem lub zablokowaniem konta. Użycie na własne ryzyko.", "webCookie": "Ten provider uwierzytelnia się za pomocą plików cookie sesji internetowej. Usługa upstream może unieważnić sesję w dowolnym momencie, co będzie wymagać ponownego zalogowania. Opcja ta nie jest zalecana do długich operacji bez nadzoru. Użycie na własne ryzyko.", @@ -5107,9 +5111,9 @@ "cancel": "Anuluj" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Wyłączone", "enableProvider": "Włącz provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Pomijanie {count} istniejących models", "autoSync": "Auto-Sync", "autoSyncShort": "Synchronizuj", + "autoFetchModels": "Automatyczne pobieranie modeli upstream", + "autoFetchModelsTooltip": "Pobierz i przechowuj modele upstream w razie potrzeby", + "autoFetchModelsEnabled": "Włączone automatyczne pobieranie modelu upstream", + "autoFetchModelsDisabled": "Automatyczne pobieranie modelu upstream wyłączone", + "autoFetchModelsToggleFailed": "Nie udało się przełączyć automatycznego pobierania modelu upstream", + "autoFetchModelsPartialFailure": "Niektóre połączenia zostały zaktualizowane, ale automatyczne pobieranie modelu upstream nie zostało zmienione wszędzie", + "overridesUpstreamModel": "Nadpisuje upstream", + "overridesUpstreamModelHint": "Twoje ustawienia nadpisują ten model upstream", + "resetToUpstreamDefaults": "Przywróć domyślne ustawienia upstream", + "resetToUpstreamDefaultsSuccess": "Przywrócono domyślne ustawienia modelu upstream", + "resetToUpstreamDefaultsFailed": "Nie udało się przywrócić domyślnych ustawień modelu upstream", "autoSyncTooltip": "Automatyczne odświeżanie listy models co 24h (konfigurowalne przez MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync włączony — models będą odświeżane okresowo", "autoSyncDisabled": "Auto-sync wyłączony", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Przepisywanie natywnych wywołań narzędzi web_fetch do /v1/web/fetch w OmniRoute.", "interceptionLoadError": "Nie udało się załadować ustawień przechwytywania: {error}", "interceptionSaveError": "Nie udało się zapisać ustawień przechwytywania: {error}", - "ccAliasSectionTitle": "Eksponuj w Claude Code (claude/…)", - "ccAliasSectionHint": "Reklamuj modele tego dostawcy pod identyfikatorami lustrzanymi claude/<provider>/<model>, aby model odkrywania bramy Claude Code mógł je wyświetlić. Domyślnie wyłączone — włączenie tego podwaja wpisy w katalogu dla wszystkich klientów.", - "ccAliasProviderLevelLabel": "Domyślny dostawca", - "ccAliasModelOverridesLabel": "Nadpisy dla poszczególnych modeli", - "ccAliasModelOverrideAriaLabel": "Nadpisz dla {modelId}", - "ccAliasStateInherit": "Dziedzicz", - "ccAliasStateOn": "Włączone", - "ccAliasStateOff": "Wyłączone", - "ccAliasAddModelPlaceholder": "Identyfikator modelu (np. gpt-4o)", - "ccAliasAddModelButton": "Dodaj nadpisanie", - "ccAliasLoadError": "Nie udało się załadować ustawień discovery-alias: {error}", - "ccAliasSaveError": "Nie udało się zapisać ustawienia discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Dodatkowe nagłówki upstream", "compatUpstreamHeadersHint": "Ustawienie o wysokich uprawnieniach — ten sam poziom zaufania, co edycja danych uwierzytelniających API dla provider; powinno być używane wyłącznie przez zaufanych administratorów. Scalane po dodaniu autoryzacji przez OmniRoute na podstawie klucza API dla provider. Jeśli niestandardowy nagłówek ma taką samą nazwę jak istniejący (np. Authorization), wprowadzona wartość w pełni zastępuje automatycznie wygenerowany nagłówek (w tym Bearer token) — upstream zobaczy tylko wpisaną wartość, a nie klucz z ustawień. Błędna konfiguracja może spowodować błąd 401 lub uszkodzenie autoryzacji upstream. Jeden wiersz na nagłówek (np. dodatkowe Authentication dla niektórych bramek). Najedź myszą lub kliknij pole, aby wyświetlić podgląd. Zapisuje się po utracie fokusu (blur), kliknięciu poza obszarem lub zamknięciu tego panelu.", "compatUpstreamHeaderName": "Nazwa nagłówka", @@ -6194,7 +6209,7 @@ "galadriel": "Połącz z Galadriel za pomocą klucza API.", "predibase": "$25 darmowych środków próbnych (ważność 30 dni)", "chenzk": "Brama zgodna z OpenAI z aktywnym katalogiem modeli na chenzk.top.", - "freepik": "Generuj obrazy za pomocą Mystic API od Freepik.", + "magnific": "Generuj obrazy za pomocą Mystic API od Freepik.", "freetheai": "Darmowa brama zgodna z OpenAI z obsługą modeli w trybie passthrough.", "g4f-gemini": "Darmowe, niewymagające klucza reverse proxy g4f.space do Gemini, z limitem do 5 żądań na minutę.", "g4f-groq": "Darmowe, niewymagające klucza reverse proxy g4f.space do Groq, z limitem do 5 żądań na minutę.", @@ -6209,6 +6224,7 @@ "claude": "Połącz Claude Code za pomocą istniejącego przepływu OAuth.", "cline": "Połącz Cline za pomocą istniejącego przepływu OAuth.", "cursor": "Połącz Cursor IDE za pomocą istniejącego przepływu OAuth.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Połącz GitHub Copilot za pomocą istniejącego przepływu OAuth.", "gitlab-duo": "Aplikacja OAuth z zakresami ai_features + read_user. Skonfiguruj GITLAB_DUO_OAUTH_CLIENT_ID i opcjonalnie GITLAB_DUO_OAUTH_CLIENT_SECRET w tej instancji OmniRoute.", "kilocode": "Połącz Kilo Code za pomocą istniejącego przepływu OAuth.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Przyjaciel open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference wspiera OmniRoute jako przyjaciel open source", "kimiPartnerLinkNote": "Link partnerski — wspiera OmniRoute bez żadnych dodatkowych kosztów dla Ciebie", + "codexQuotaPools": "Pule limitów Codex", + "codexPoolAvailable": "Dostępna", + "codexPoolPartiallyLimited": "Częściowo ograniczona", + "codexPoolFullyLimited": "Całkowicie ograniczona", + "codexPoolLimited": "Ograniczone: {count}", + "codexPoolQuotaExhausted": "Limit wyczerpany", + "codexPoolCoolingDown": "W okresie oczekiwania", + "codexPoolUsed": "wykorzystano", + "codexPoolUntil": "Do {value}", "anonymousFallbackTitle": "Anonimowe zapasowe", "anonymousFallbackDesc": "Gdy wszystkie skonfigurowane połączenia są wyczerpane (kwota, kredyty lub wygaśnięcie), tymczasowo użyj bezkluczowego poziomu tego dostawcy. Wyłącz, aby pominąć tego dostawcę zamiast wysyłać anonimowe żądania — zalecane, gdy bezkluczowy poziom je odrzuca (401).", "anonymousFallbackEnabled": "Anonimowe przełączanie włączone dla {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Ustawienia punktu końcowego zapisanego modelu", "searchByModelAria": "Szukaj według modelu", "selectSupportedEndpoint": "Wybierz przynajmniej jeden obsługiwany punkt końcowy", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Automatyczne pobieranie modeli upstream", - "autoFetchModelsEnabled": "Włączone automatyczne pobieranie modelu upstream", - "autoFetchModelsTooltip": "Pobierz i przechowuj modele upstream w razie potrzeby", - "autoFetchModelsDisabled": "Automatyczne pobieranie modelu upstream wyłączone", - "overridesUpstreamModel": "Nadpisuje upstream", - "overridesUpstreamModelHint": "Twoje ustawienia nadpisują ten model upstream", - "autoFetchModelsPartialFailure": "Niektóre połączenia zostały zaktualizowane, ale automatyczne pobieranie modelu upstream nie zostało zmienione wszędzie", - "autoFetchModelsToggleFailed": "Nie udało się przełączyć automatycznego pobierania modelu upstream", - "resetToUpstreamDefaultsSuccess": "Przywrócono domyślne ustawienia modelu upstream", - "resetToUpstreamDefaults": "Przywróć domyślne ustawienia upstream", - "resetToUpstreamDefaultsFailed": "Nie udało się przywrócić domyślnych ustawień modelu upstream" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Ustawienia", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Trwałe oznaczanie połączeń provider jako dezaktywowane, jeśli zwrócą one określone końcowe sygnały blokady (np. HTTP 403 'verify your account'). Spowoduje to usunięcie ich z rotacji combo.", "autoDisableThreshold": "Próg blokady", "autoDisableThresholdDesc": "Liczba kolejnych sygnałów blokady wymagana do trwałej dezaktywacji.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Zablokowane słowa kluczowe", "customBannedSignalsDesc": "Dodatkowe słowa kluczowe wyzwalające wykrywanie trwałej blokady konta. Wbudowane słowa kluczowe mają zawsze zastosowanie.", "customBannedSignalsPlaceholder": "np. api key revoked", @@ -6724,7 +6738,7 @@ "global": "Globalny", "rule": "Reguła", "enabled": "Włączone", - "disabled": "Niepełnosprawny", + "disabled": "Wyłączone", "nodeCount": "Węzły: {count}", "needsCoreCount": "{count} potrzebuje lokalnego rdzenia", "lastSynced": "Ostatnia synchronizacja: {time}", @@ -7189,6 +7203,7 @@ "configured": "skonfigurowano", "none": "Brak", "modelOverrideValuePlaceholder": "Wartość liczbowa", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Dodaj wartość klucza", "noModelOverrides": "Brak skonfigurowanych nadpisań dla tego model.", "modelOverrideLoadFailed": "Nie udało się załadować nadpisań model", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Zwięzłe CJK (文言)", "description": "Klasyczny chiński styl ultra-zwięzły (dostępny tylko dla języka chińskiego)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "combos typu round-robin i random zmieniają połączenie przy każdym żądaniu, zamiast przypinać całą konwersację do jednego połączenia na podstawie hasha pierwszej wiadomości. Pozostaw wyłączone, aby zachować trafienia prompt-cache dla wieloturowych czatów. Nadpisania per-combo mają pierwszeństwo.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Ukrywanie danych uwierzytelniających", "credentialRedactionDesc": "Ukrywaj klucze API, tokeny i sekrety w kontekście wysyłanym do dostawców oraz w odpowiedziach.", "enableCredentialRedaction": "Włącz ukrywanie danych uwierzytelniających", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Włącz silnik", "enableDescription": "Uruchamia się jako ostatni w stosie (po tym, jak RTK/Caveman oczyści tekst, a OmniGlyph skonwertuje resztę na obrazy), a także działa samodzielnie w trybie omniglyph. To jest wersja zapoznawcza i pozostaje domyślnie wyłączona do czasu zakończenia pełnej walidacji.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Zapisano.", "saveFailed": "Nie można zapisać.", "enableAria": "Włącz silnik OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "maksymalny", "grokAutoTopUpMonth": "miesiąc", "grokAdditionalCredits": "Dodatkowe Kredyty", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Zarządzanie budżetem", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Pierwszy Token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 0d79c2a0d2..4ba29762d0 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Linha do tempo de solicitação visual", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1282,8 +1284,6 @@ "resilienceConnectionsSubtitle": "Cooldown, disjuntor, estado de bloqueio", "settingsModalityBridge": "Ponte de Modalidade", "settingsModalityBridgeSubtitle": "Fallback de imagem/áudio → texto para modelos apenas de texto", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations", "commandPalette": { "title": "Paleta de Comandos", "searchPlaceholder": "Pesquisar páginas, configurações, ferramentas...", @@ -1800,6 +1800,11 @@ "updateStarted": "Atualização iniciada...", "reloadingPageAutomatically": "Recarregando a página automaticamente...", "providerTopology": "Topologia do provedor", + "recentRequests": "Requisições recentes", + "recentRequestsEmpty": "Nenhuma requisição ainda.", + "recentRequestsModel": "Modelo", + "recentRequestsTokens": "Entrada / Saída", + "recentRequestsWhen": "Quando", "downloadDmg": "Baixar DMG (macOS)", "downloadDmgDescription": "Uma nova versão do aplicativo de desktop OmniRoute está disponível. Por favor, baixe e instale o instalador DMG para macOS para atualizar (atual: v{version}).", "downloadExe": "Baixar EXE (Windows)", @@ -3606,6 +3611,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5106,7 @@ "deprecatedProvider": "Este provedor foi descontinuado", "riskNotice": { "title": "Antes de continuar", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Provider com restrições de uso — clique para detalhes", "oauth": "Este provider usa sua sessão/OAuth oficial do produto, que não autoriza uso em proxy/router. Não recomendamos uso intensivo em agentes autônomos (estilo OpenCloud, multi-passos longos, batches grandes) — o upstream pode reagir restringindo ou banindo a conta. Use por sua conta e risco.", "webCookie": "Este provider autentica através dos cookies da sua sessão web. O serviço upstream pode invalidar a sessão a qualquer momento, exigindo re-login. Não recomendado para operações longas e não-supervisionadas. Use por sua conta e risco.", @@ -5107,9 +5116,9 @@ "cancel": "Cancelar" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Desativado", "enableProvider": "Ativar provedor", @@ -5233,6 +5242,17 @@ "skippingExistingModels": "Ignorando {count} modelos existentes", "autoSync": "Sincronização automática", "autoSyncShort": "Sincronizar", + "autoFetchModels": "Buscar automaticamente modelos upstream", + "autoFetchModelsTooltip": "Busque e armazene em cache os modelos upstream quando necessário", + "autoFetchModelsEnabled": "Modelo upstream de auto-busca habilitado", + "autoFetchModelsDisabled": "Busca automática do modelo upstream desativada", + "autoFetchModelsToggleFailed": "Falha ao alternar a busca automática do modelo upstream", + "autoFetchModelsPartialFailure": "Algumas conexões foram atualizadas, mas a busca automática do modelo upstream não foi alterada em todos os lugares", + "overridesUpstreamModel": "Substitui upstream", + "overridesUpstreamModelHint": "Suas configurações substituem este modelo upstream", + "resetToUpstreamDefaults": "Restaurar padrões do upstream", + "resetToUpstreamDefaultsSuccess": "Restaurados os padrões do modelo upstream", + "resetToUpstreamDefaultsFailed": "Falha ao restaurar as configurações padrão do modelo upstream", "autoSyncTooltip": "Atualize automaticamente a lista de modelos a cada 24h (configurável via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Sincronização automática habilitada – os modelos serão atualizados periodicamente", "autoSyncDisabled": "Sincronização automática desativada", @@ -5438,18 +5458,18 @@ "interceptFetchHint": "Reescreve chamadas nativas de web_fetch para /v1/web/fetch da OmniRoute.", "interceptionLoadError": "Falha ao carregar configuração de interceptação: {error}", "interceptionSaveError": "Falha ao salvar configuração de interceptação: {error}", - "ccAliasSectionTitle": "Expose em Claude Code (claude/…)", - "ccAliasSectionHint": "Anuncie os modelos deste provedor sob claude/<provider>/<model> IDs de espelho para que a descoberta de modelos do gateway do Claude Code possa listá-los. Desativado por padrão — habilitar isso dobra as entradas do catálogo para todos os clientes.", - "ccAliasProviderLevelLabel": "Provedor padrão", - "ccAliasModelOverridesLabel": "Substituições por modelo", - "ccAliasModelOverrideAriaLabel": "Substituição para {modelId}", - "ccAliasStateInherit": "Herdar", - "ccAliasStateOn": "Ligado", - "ccAliasStateOff": "Desligado", - "ccAliasAddModelPlaceholder": "ID do modelo (por exemplo, gpt-4o)", - "ccAliasAddModelButton": "Adicionar substituição", - "ccAliasLoadError": "Falha ao carregar as configurações de discovery-alias: {error}", - "ccAliasSaveError": "Falha ao salvar a configuração de discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6214,7 @@ "galadriel": "Conecte o Galadriel com uma chave de API.", "predibase": "$25 em créditos de teste gratuitos (validade de 30 dias)", "chenzk": "Gateway compatível com OpenAI com um catálogo de modelos ao vivo em chenzk.top.", - "freepik": "Gere imagens com a API Mystic do Freepik.", + "magnific": "Gere imagens com a API Mystic do Freepik.", "freetheai": "Gateway gratuito compatível com OpenAI com suporte a modelos via passthrough.", "g4f-gemini": "Proxy reverso gratuito e sem chave do g4f.space para o Gemini, limitado a 5 solicitações por minuto.", "g4f-groq": "Proxy reverso gratuito e sem chave do g4f.space para o Groq, limitado a 5 solicitações por minuto.", @@ -6209,6 +6229,7 @@ "claude": "Conecte o Claude Code com o fluxo OAuth existente.", "cline": "Conecte o Cline com o fluxo OAuth existente.", "cursor": "Conecte o Cursor IDE com o fluxo OAuth existente.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Conecte o GitHub Copilot com o fluxo OAuth existente.", "gitlab-duo": "Aplicação OAuth com os escopos ai_features + read_user. Configure GITLAB_DUO_OAUTH_CLIENT_ID e, opcionalmente, GITLAB_DUO_OAUTH_CLIENT_SECRET nesta instância do OmniRoute.", "kilocode": "Conecte o Kilo Code com o fluxo OAuth existente.", @@ -6271,6 +6292,15 @@ "cheaperInferenceSupporterBadge": "Amigo do Código Aberto", "cheaperInferenceSupporterTooltip": "A Cheaper Inference apoia o OmniRoute como amiga do código aberto", "kimiPartnerLinkNote": "Link de parceria — apoia o OmniRoute sem custo extra para você", + "codexQuotaPools": "Pools de cotas do Codex", + "codexPoolAvailable": "Disponível", + "codexPoolPartiallyLimited": "Parcialmente limitado", + "codexPoolFullyLimited": "Totalmente limitado", + "codexPoolLimited": "{count} limitados", + "codexPoolQuotaExhausted": "Cota esgotada", + "codexPoolCoolingDown": "Em período de espera", + "codexPoolUsed": "usado", + "codexPoolUntil": "Até {value}", "anonymousFallbackTitle": "Fallback anônimo", "anonymousFallbackDesc": "Quando todas as conexões configuradas estiverem esgotadas (cota, créditos ou expiração), use temporariamente a camada sem chave deste provedor. Desative para ignorar este provedor em vez de enviar solicitações anônimas — recomendado quando a camada sem chave as rejeita (401).", "anonymousFallbackEnabled": "Fallback anônimo ativado para {provider}", @@ -6346,18 +6376,7 @@ "savedModelEndpointSettings": "Configurações do endpoint do modelo salvo", "searchByModelAria": "Pesquisar por modelo", "selectSupportedEndpoint": "Selecione pelo menos um endpoint suportado", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsEnabled": "Modelo upstream de auto-busca habilitado", - "autoFetchModelsDisabled": "Busca automática do modelo upstream desativada", - "autoFetchModels": "Buscar automaticamente modelos upstream", - "autoFetchModelsTooltip": "Busque e armazene em cache os modelos upstream quando necessário", - "autoFetchModelsToggleFailed": "Falha ao alternar a busca automática do modelo upstream", - "overridesUpstreamModel": "Substitui upstream", - "autoFetchModelsPartialFailure": "Algumas conexões foram atualizadas, mas a busca automática do modelo upstream não foi alterada em todos os lugares", - "overridesUpstreamModelHint": "Suas configurações substituem este modelo upstream", - "resetToUpstreamDefaults": "Restaurar padrões do upstream", - "resetToUpstreamDefaultsFailed": "Falha ao restaurar as configurações padrão do modelo upstream", - "resetToUpstreamDefaultsSuccess": "Restaurados os padrões do modelo upstream" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Configurações", @@ -6576,12 +6595,12 @@ "autoDisableDescription": "Marca permanentemente conexões de provedor como desativadas quando retornam sinais terminais de banimento (ex.: HTTP 403 'verify your account'). Isso remove a conexão da rotação de combos.", "autoDisableThreshold": "Limite de banimento", "autoDisableThresholdDesc": "Quantidade de sinais consecutivos de banimento antes da desativação permanente.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Palavras-Chave Proibidas", "customBannedSignalsDesc": "Palavras-chave adicionais que acionam a detecção de banimento permanente da conta. Palavras-chave integradas sempre se aplicam.", "customBannedSignalsPlaceholder": "chave da API revogada", @@ -7189,6 +7208,7 @@ "configured": "configurado", "none": "Nenhum", "modelOverrideValuePlaceholder": "Valor numérico", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Adicionar valor da chave", "noModelOverrides": "Nenhuma substituição configurada para este modelo.", "modelOverrideLoadFailed": "Falha ao carregar substituições de modelo", @@ -7760,6 +7780,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK conciso (文言)", "description": "Estilo ultra-conciso em chinês clássico (disponível apenas para chinês)." @@ -8040,6 +8064,10 @@ "disableSessionStickinessDesc": "Combos round-robin e aleatórios alternam para uma conexão diferente a cada requisição, em vez de fixar toda a conversa em uma conexão pelo hash da primeira mensagem. Deixe desativado para preservar acertos de cache de prompt em conversas com múltiplos turnos. Sobrescritas por combo têm prioridade.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Redação de Credenciais", "credentialRedactionDesc": "Redija chaves de API, tokens e segredos do contexto enviado para provedores e das respostas.", "enableCredentialRedaction": "Ativar a ocultação de credenciais", @@ -9090,6 +9118,16 @@ "grokAutoTopUpMax": "máximo", "grokAutoTopUpMonth": "mês", "grokAdditionalCredits": "Créditos adicionais", + "kimiExtraUsageCredits": "Créditos de uso extra", + "kimiExtraUsage": "Uso extra", + "kimiExtraUsageEnabled": "Ativado", + "kimiExtraUsageDisabled": "Desativado", + "kimiExtraUsageFrozen": "Congelado", + "kimiExtraUsageUnavailable": "Indisponível", + "kimiMonthlyUsed": "Usado neste mês", + "kimiMonthlyLimit": "Limite mensal", + "kimiMonthlyLimitUnlimited": "Ilimitado", + "kimiAdditionalCredits": "Créditos adicionais", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Gerenciamento de Orçamento", @@ -12716,6 +12754,10 @@ "ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE": { "description": "Permite múltiplas conexões para cada nó de compatibilidade." }, + "DISABLE_CONTEXT_WINDOW_CHECKS": { + "label": "Desativar verificações da janela de contexto", + "description": "Ignora a verificação local do OmniRoute para janela de contexto e limite máximo de tokens de entrada em solicitações diretas a um único modelo. Os provedores upstream continuam aplicando seus limites reais. A compactação de prompts e os limites de tokens de saída permanecem ativos." + }, "RESPONSES_PASSTHROUGH_DROP_COMMENTARY": { "description": "Remove itens de saída da fase de comentário interno dos streams de passthrough da Responses API antes de encaminhá-los aos clientes. Desative esta flag para receber o comentário bruto do upstream." }, @@ -13356,6 +13398,7 @@ } }, "featureFlagCapabilityFilterEnabledDescription": "Rejeitar requisições antes do despacho quando o modelo alvo nao possui as capacidades necessarias (visao, ferramentas, saída estruturada, janela de contexto). Protege requisições diretas que ignoram o filtro de compatibilidade do combo.", + "featureFlagDisableContextWindowChecksDescription": "Ignora a verificação local do OmniRoute para janela de contexto e limite máximo de tokens de entrada em solicitações diretas a um único modelo. Os provedores upstream continuam aplicando seus limites reais. A compactação de prompts e os limites de tokens de saída permanecem ativos.", "publicSystem": { "notFound": { "title": "Página não encontrada", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 45afde064c..05441018a3 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Registos da Consola", "logsTimeline": "Linha do Tempo", "logsTimelineSubtitle": "Linha do tempo de pedidos visuais", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Encaminhamento Global", "mitmProxy": "Proxy MITM", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "abrir", "close": "fechar" }, - "noResults": "Sem resultados", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Sem resultados" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "Partilha de Quota", "discovery": "Descoberta", "freeProviderRankings": "Rankings de Fornecedores Gratuitos", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Escalões Gratuitos", "gamification": "Gamificação", "leaderboard": "Tabela de classificação", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Rever e Guardar", "wizardStep4Desc": "Rever a configuração e ativar o combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "Ligado", "emailVisibilityStateOff": "Desligado", "reorderHandle": "Arrasta para reordenar", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Este provedor foi descontinuado", "riskNotice": { "title": "Antes de continuar", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Provedor com advertências de utilização — clique para detalhes", "oauth": "Este provedor utiliza a sua sessão oficial do produto/OAuth, que não está autorizada para utilização de proxy/router. Não recomendamos a utilização intensiva de agentes autónomos (estilo OpenCloud, fluxos longos de vários passos, grandes lotes) — o upstream pode reagir restringindo ou banindo a conta. Utilize por sua conta e risco.", "webCookie": "Este provedor autentica-se através dos cookies da sua sessão web. O serviço upstream pode invalidar a sessão a qualquer momento, exigindo que inicie sessão novamente. Não recomendado para operações longas sem supervisão. Utilize por sua conta e risco.", @@ -5107,9 +5111,9 @@ "cancel": "Cancelar" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Desativado", "enableProvider": "Habilitar provedor", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "A ignorar {count} modelos existentes", "autoSync": "Sincronização automática", "autoSyncShort": "Sincronizar", + "autoFetchModels": "Busca automática de modelos upstream", + "autoFetchModelsTooltip": "Buscar e armazenar em cache modelos upstream quando necessário", + "autoFetchModelsEnabled": "Modelo upstream de auto-busca ativado", + "autoFetchModelsDisabled": "Auto-busca do modelo upstream desativada", + "autoFetchModelsToggleFailed": "Falha ao alternar a busca automática do modelo upstream", + "autoFetchModelsPartialFailure": "Algumas ligações foram atualizadas, mas a busca automática do modelo upstream não foi alterada em todos os lugares", + "overridesUpstreamModel": "Substitui upstream", + "overridesUpstreamModelHint": "As suas definições substituem este modelo upstream", + "resetToUpstreamDefaults": "Restaurar as definições padrão do upstream", + "resetToUpstreamDefaultsSuccess": "Restaurados os valores padrão do modelo upstream", + "resetToUpstreamDefaultsFailed": "Falha ao restaurar as definições padrão do modelo upstream", "autoSyncTooltip": "Atualiza automaticamente a lista de modelos a cada 24 horas (configurável via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Sincronização automática ativada — modelos serão atualizados periodicamente", "autoSyncDisabled": "Sincronização automática desativada", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Reescrever chamadas de ferramentas nativas web_fetch para o /v1/web/fetch do OmniRoute.", "interceptionLoadError": "Falha ao carregar as definições de interceção: {error}", "interceptionSaveError": "Falha ao guardar as definições de interceção: {error}", - "ccAliasSectionTitle": "Expose em Claude Code (claude/…)", - "ccAliasSectionHint": "Anuncie os modelos deste fornecedor sob claude/<provider>/<model> IDs de espelho para que a descoberta de modelos do gateway do Claude Code os possa listar. Desativado por padrão — ativar isto duplica as entradas do catálogo para todos os clientes.", - "ccAliasProviderLevelLabel": "Fornecedor padrão", - "ccAliasModelOverridesLabel": "Substituições por modelo", - "ccAliasModelOverrideAriaLabel": "Substituição para {modelId}", - "ccAliasStateInherit": "Herdar", - "ccAliasStateOn": "Ligado", - "ccAliasStateOff": "Desligado", - "ccAliasAddModelPlaceholder": "Id do modelo (ex: gpt-4o)", - "ccAliasAddModelButton": "Adicionar substituição", - "ccAliasLoadError": "Falha ao carregar as definições de discovery-alias: {error}", - "ccAliasSaveError": "Falha ao salvar a configuração discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Cabeçalhos upstream extra", "compatUpstreamHeadersHint": "Definição de alto privilégio — mesmo nível de confiança que editar credenciais de API do fornecedor; só admins de confiança devem usar.", "compatUpstreamHeaderName": "Nome do cabeçalho", @@ -6194,7 +6209,7 @@ "galadriel": "Ligue a Galadriel com uma chave de API.", "predibase": "$25 em créditos de avaliação gratuita (validade de 30 dias)", "chenzk": "Gateway compatível com a OpenAI com um catálogo de modelos em tempo real em chenzk.top.", - "freepik": "Gere imagens com a API Mystic da Freepik.", + "magnific": "Gere imagens com a API Mystic da Freepik.", "freetheai": "Gateway gratuito compatível com a OpenAI com suporte a modelos passthrough.", "g4f-gemini": "Proxy inverso g4f.space gratuito e sem chave para o Gemini, limitado a 5 pedidos por minuto.", "g4f-groq": "Proxy inverso g4f.space gratuito e sem chave para o Groq, limitado a 5 pedidos por minuto.", @@ -6209,6 +6224,7 @@ "claude": "Ligar o Claude Code com o fluxo OAuth existente.", "cline": "Ligar o Cline com o fluxo OAuth existente.", "cursor": "Ligar o Cursor IDE com o fluxo OAuth existente.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Ligar o GitHub Copilot com o fluxo OAuth existente.", "gitlab-duo": "Aplicação OAuth com os âmbitos ai_features + read_user. Configure GITLAB_DUO_OAUTH_CLIENT_ID e, opcionalmente, GITLAB_DUO_OAUTH_CLIENT_SECRET nesta instância do OmniRoute.", "kilocode": "Ligar o Kilo Code com o fluxo OAuth existente.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Amigo do Código Aberto", "cheaperInferenceSupporterTooltip": "A Cheaper Inference apoia o OmniRoute como amiga do código aberto", "kimiPartnerLinkNote": "Link de parceiro — apoia o OmniRoute sem custos adicionais para si", + "codexQuotaPools": "Pools de quotas do Codex", + "codexPoolAvailable": "Disponível", + "codexPoolPartiallyLimited": "Parcialmente limitado", + "codexPoolFullyLimited": "Totalmente limitado", + "codexPoolLimited": "{count} limitados", + "codexPoolQuotaExhausted": "Quota esgotada", + "codexPoolCoolingDown": "Em período de espera", + "codexPoolUsed": "utilizado", + "codexPoolUntil": "Até {value}", "anonymousFallbackTitle": "Fallback anónimo", "anonymousFallbackDesc": "Quando todas as conexões configuradas estiverem esgotadas (quota, créditos ou expiração), use temporariamente o nível sem chave deste fornecedor. Desative para ignorar este fornecedor em vez de enviar pedidos anónimos — recomendado quando o nível sem chave os rejeita (401).", "anonymousFallbackEnabled": "Fallback anónimo ativado para {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Definições do ponto de extremidade do modelo guardado", "searchByModelAria": "Pesquisar por modelo", "selectSupportedEndpoint": "Selecione pelo menos um endpoint suportado", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsDisabled": "Auto-busca do modelo upstream desativada", - "autoFetchModelsTooltip": "Buscar e armazenar em cache modelos upstream quando necessário", - "autoFetchModelsEnabled": "Modelo upstream de auto-busca ativado", - "autoFetchModels": "Busca automática de modelos upstream", - "overridesUpstreamModel": "Substitui upstream", - "overridesUpstreamModelHint": "As suas definições substituem este modelo upstream", - "autoFetchModelsToggleFailed": "Falha ao alternar a busca automática do modelo upstream", - "autoFetchModelsPartialFailure": "Algumas ligações foram atualizadas, mas a busca automática do modelo upstream não foi alterada em todos os lugares", - "resetToUpstreamDefaults": "Restaurar as definições padrão do upstream", - "resetToUpstreamDefaultsSuccess": "Restaurados os valores padrão do modelo upstream", - "resetToUpstreamDefaultsFailed": "Falha ao restaurar as definições padrão do modelo upstream" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Configurações", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Marca permanentemente conexões de provedor como desativadas quando retornam sinais terminais de banimento (ex.: HTTP 403 'verify your account'). Isso remove a conexão da rotação de combos.", "autoDisableThreshold": "Limite de banimento", "autoDisableThresholdDesc": "Quantidade de sinais consecutivos de banimento antes da desativação permanente.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Palavras-chave Proibidas", "customBannedSignalsDesc": "Palavras-chave adicionais que acionam a deteção de banimento permanente da conta. As palavras-chave integradas aplicam-se sempre.", "customBannedSignalsPlaceholder": "ex. chave de API revogada", @@ -7189,6 +7203,7 @@ "configured": "configurado", "none": "Nenhum", "modelOverrideValuePlaceholder": "Valor numérico", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Adicionar chave-valor", "noModelOverrides": "Nenhuma substituição configurada para este modelo.", "modelOverrideLoadFailed": "Falha ao carregar as substituições de modelo", @@ -7760,6 +7775,10 @@ "label": "Ponytail (dev sénior preguiçoso)", "description": "Disciplina de dev sénior preguiçoso: sobe a escada YAGNI, corrige a causa raiz, menor diff funcional." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK conciso (文言)", "description": "Estilo ultraconciso em chinês clássico (disponível apenas para chinês)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "As combinações round-robin e aleatórias alternam para uma ligação diferente a cada pedido em vez de fixarem uma conversa inteira a uma ligação através do hash da primeira mensagem. Deixe desativado para preservar os hits da cache de prompts em conversas com múltiplos turnos. As sobreposições por combinação têm precedência.", "promptCacheAffinity": "Encaminhamento por localidade de prompt-cache", "promptCacheAffinityDesc": "Prefere a mesma conta de fornecedor para chaves de prompt-cache correspondentes, preservando o failover de saúde e quota.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Ocultação de Credenciais", "credentialRedactionDesc": "Oculte chaves de API, tokens e segredos do contexto enviado para os fornecedores e das respostas.", "enableCredentialRedaction": "Ativar ocultação de credenciais", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Ativar o motor", "enableDescription": "Executa em último lugar na pilha (após o RTK/Caveman limpar o texto, o OmniGlyph converte o restante em imagens) e também executa de forma autónoma através do modo omniglyph. Esta é uma pré-visualização e permanece desativada por predefinição até que a validação de ponta a ponta esteja concluída.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Guardado.", "saveFailed": "Não foi possível guardar.", "enableAria": "Ativar o motor OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "máx", "grokAutoTopUpMonth": "mês", "grokAdditionalCredits": "Créditos Adicionais", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Registrador", "proxyTab": "Procurador", "budgetManagement": "Gestão Orçamentária", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Primeiro Token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Falha ao guardar as definições locais do Radar", "guidedCombos": "Guided combos", "offers": "Ofertas", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13325,7 +13379,7 @@ "pollErrorStopped": "Sondagem interrompida: o servidor rejeitou o pedido (404/403).", "pollErrorTransient": "Erro ao obter dados: a repetir automaticamente.", "degraded": { - "message": "__MISSING__:Partial data: unavailable sources: {sources}", + "message": "Partial data: unavailable sources: {sources}", "source": { "database": "Base de Dados", "circuitBreaker": "Disjuntor", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# dia} other {# dias}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 40328b5ea2..b8ef63cbb8 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Cronologia cererilor vizuale", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "deschide", "close": "închide" }, - "noResults": "Niciun rezultat", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Niciun rezultat" }, "webhooks": { "title": "Webhook-uri", @@ -1739,8 +1739,8 @@ "quotaShare": "Partajare cotă", "discovery": "Descoperire", "freeProviderRankings": "Clasament furnizori gratuiți", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Niveluri gratuite", "gamification": "Gamificare", "leaderboard": "Clasament", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Acest furnizor a fost retras", "riskNotice": { "title": "Înainte de a continua", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Furnizor cu avertismente de utilizare — faceți clic pentru detalii", "oauth": "Acest furnizor utilizează sesiunea oficială a produsului/OAuth, care nu este autorizată pentru utilizarea ca proxy/router. Nu recomandăm utilizarea intensivă a agenților autonomi (stil OpenCloud, fluxuri lungi cu mai mulți pași, loturi mari) — upstream-ul poate reacționa prin restricționarea sau blocarea contului. Utilizați pe propriul risc.", "webCookie": "Acest furnizor se autentifică prin cookie-urile sesiunii web. Serviciul upstream poate invalida sesiunea în orice moment, solicitându-vă să vă autentificați din nou. Nu este recomandat pentru operațiuni lungi nesupravegheate. Utilizați pe propriul risc.", @@ -5107,9 +5111,9 @@ "cancel": "Anulează" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Dezactivat", "enableProvider": "Activați furnizorul", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Se omit {count} modele existente", "autoSync": "Sincronizare automată", "autoSyncShort": "Sincronizează", + "autoFetchModels": "Obține automat modelele upstream", + "autoFetchModelsTooltip": "Recuperează și stochează modelele upstream atunci când este necesar", + "autoFetchModelsEnabled": "Modelul upstream auto-fetch activat", + "autoFetchModelsDisabled": "Modelul upstream auto-fetch dezactivat", + "autoFetchModelsToggleFailed": "Nu s-a reușit comutarea automată a preluării modelului upstream", + "autoFetchModelsPartialFailure": "Unele conexiuni au fost actualizate, dar modelul upstream auto-fetch nu a fost schimbat peste tot", + "overridesUpstreamModel": "Suprascrie upstream", + "overridesUpstreamModelHint": "Setările tale suprascriu acest model de bază", + "resetToUpstreamDefaults": "Restabilește valorile implicite upstream", + "resetToUpstreamDefaultsSuccess": "Restabilite valorile implicite ale modelului upstream", + "resetToUpstreamDefaultsFailed": "Restaurarea valorilor implicite ale modelului upstream a eșuat", "autoSyncTooltip": "Actualizează automat lista de modele la fiecare 24 de ore (configurabil prin MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Sincronizare automată activată — modelele se vor reîmprospăta periodic", "autoSyncDisabled": "Sincronizarea automată a fost dezactivată", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Rescrie apelurile native de instrument web_fetch către /v1/web/fetch al OmniRoute.", "interceptionLoadError": "Nu s-au putut încărca setările de interceptare: {error}", "interceptionSaveError": "Nu s-au putut salva setările de interceptare: {error}", - "ccAliasSectionTitle": "Expune în Claude Code (claude/…)", - "ccAliasSectionHint": "Publica modelele acestui furnizor sub claude/<provider>/<model> ID-uri mirror, astfel încât descoperirea modelului gateway al Claude Code să le poată lista. Dezactivat în mod implicit — activarea acestuia dublează intrările din catalog pentru toți clienții.", - "ccAliasProviderLevelLabel": "Provider implicit", - "ccAliasModelOverridesLabel": "Suprapuneri pe model per model", - "ccAliasModelOverrideAriaLabel": "Suprascriere pentru {modelId}", - "ccAliasStateInherit": "Moștenește", - "ccAliasStateOn": "Activat", - "ccAliasStateOff": "Oprit", - "ccAliasAddModelPlaceholder": "ID model (de exemplu, gpt-4o)", - "ccAliasAddModelButton": "Adaugă suprascriere", - "ccAliasLoadError": "Nu s-au putut încărca setările discovery-alias: {error}", - "ccAliasSaveError": "Nu s-a reușit salvarea setării discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Conectați Galadriel cu o cheie API.", "predibase": "Credite de încercare gratuită de 25 $ (valabilitate 30 de zile)", "chenzk": "Gateway compatibil cu OpenAI cu un catalog live de modele la chenzk.top.", - "freepik": "Generați imagini cu API-ul Mystic de la Freepik.", + "magnific": "Generați imagini cu API-ul Mystic de la Freepik.", "freetheai": "Gateway gratuit compatibil cu OpenAI cu suport pentru modele passthrough.", "g4f-gemini": "Reverse proxy gratuit fără cheie g4f.space către Gemini, limitat la 5 cereri pe minut.", "g4f-groq": "Reverse proxy gratuit fără cheie g4f.space către Groq, limitat la 5 cereri pe minut.", @@ -6209,6 +6224,7 @@ "claude": "Conectați Claude Code cu fluxul OAuth existent.", "cline": "Conectați Cline cu fluxul OAuth existent.", "cursor": "Conectați Cursor IDE cu fluxul OAuth existent.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Conectați GitHub Copilot cu fluxul OAuth existent.", "gitlab-duo": "Aplicație OAuth cu permisiunile ai_features + read_user. Configurați GITLAB_DUO_OAUTH_CLIENT_ID și opțional GITLAB_DUO_OAUTH_CLIENT_SECRET pe această instanță OmniRoute.", "kilocode": "Conectați Kilo Code cu fluxul OAuth existent.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Prieten open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference susține OmniRoute ca prieten open source", "kimiPartnerLinkNote": "Link de partener — susține OmniRoute fără costuri suplimentare pentru dvs.", + "codexQuotaPools": "Grupuri de cote Codex", + "codexPoolAvailable": "Disponibil", + "codexPoolPartiallyLimited": "Limitat parțial", + "codexPoolFullyLimited": "Limitat complet", + "codexPoolLimited": "{count} limitate", + "codexPoolQuotaExhausted": "Cota a fost epuizată", + "codexPoolCoolingDown": "În perioada de așteptare", + "codexPoolUsed": "utilizat", + "codexPoolUntil": "Până la {value}", "anonymousFallbackTitle": "Fallback anonim", "anonymousFallbackDesc": "Când toate conexiunile configurate sunt epuizate (cota, credite sau expirare), folosiți temporar nivelul fără cheie al acestui furnizor. Dezactivați pentru a sări peste acest furnizor în loc de a trimite cereri anonime — recomandat atunci când nivelul fără cheie le respinge (401).", "anonymousFallbackEnabled": "Fallback anonim activat pentru {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Setările punctului final al modelului salvat", "searchByModelAria": "Caută după model", "selectSupportedEndpoint": "Selectați cel puțin un punct final acceptat", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Obține automat modelele upstream", - "autoFetchModelsDisabled": "Modelul upstream auto-fetch dezactivat", - "autoFetchModelsTooltip": "Recuperează și stochează modelele upstream atunci când este necesar", - "autoFetchModelsEnabled": "Modelul upstream auto-fetch activat", - "overridesUpstreamModel": "Suprascrie upstream", - "autoFetchModelsToggleFailed": "Nu s-a reușit comutarea automată a preluării modelului upstream", - "overridesUpstreamModelHint": "Setările tale suprascriu acest model de bază", - "autoFetchModelsPartialFailure": "Unele conexiuni au fost actualizate, dar modelul upstream auto-fetch nu a fost schimbat peste tot", - "resetToUpstreamDefaults": "Restabilește valorile implicite upstream", - "resetToUpstreamDefaultsSuccess": "Restabilite valorile implicite ale modelului upstream", - "resetToUpstreamDefaultsFailed": "Restaurarea valorilor implicite ale modelului upstream a eșuat" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Setări", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Cuvinte cheie interzise", "customBannedSignalsDesc": "Cuvinte cheie suplimentare care declanșează detectarea blocării permanente a contului. Cuvintele cheie integrate se aplică întotdeauna.", "customBannedSignalsPlaceholder": "de ex. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "configurat", "none": "Niciunul", "modelOverrideValuePlaceholder": "Valoare numerică", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Adaugă cheie-valoare", "noModelOverrides": "Nu sunt configurate suprascrieri pentru acest model.", "modelOverrideLoadFailed": "Eroare la încărcarea suprascrierilor de model", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK concis (文言)", "description": "Stil ultra-concis în chineza clasică (disponibil doar pentru chineză)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Combinațiile round-robin și aleatorii comută la o conexiune diferită la fiecare solicitare, în loc să fixeze o întreagă conversație la o singură conexiune pe baza hash-ului primului mesaj. Lăsați dezactivat pentru a păstra accesările din cache-ul de prompturi pentru conversațiile cu mai multe replici. Suprascrierile per combinație au prioritate.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Mascare date de autentificare", "credentialRedactionDesc": "Mascați cheile API, tokenurile și secretele din contextul trimis către furnizori și din răspunsuri.", "enableCredentialRedaction": "Activează redactarea credențialelor", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Activează motorul", "enableDescription": "Rulează ultimul în stivă (după ce RTK/Caveman curăță textul, OmniGlyph convertește restul în imagini) și rulează, de asemenea, de sine stătător prin modul omniglyph. Aceasta este o previzualizare și rămâne dezactivată în mod implicit până când validarea end-to-end este finalizată.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Salvat.", "saveFailed": "Nu s-a putut salva.", "enableAria": "Activează motorul OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "luna", "grokAdditionalCredits": "Credite Suplimentare", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Managementul bugetului", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Primul token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index d2ceb4e4fb..cf9ff5e8c2 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Визуальная временная шкала запросов", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "открыть", "close": "закрыть" }, - "noResults": "Нет результатов", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Нет результатов" }, "webhooks": { "title": "Вебхуки", @@ -1739,8 +1739,8 @@ "quotaShare": "Доля квоты", "discovery": "Обнаружение", "freeProviderRankings": "Рейтинг бесплатных провайдеров", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Бесплатные тарифы", "gamification": "Геймификация", "leaderboard": "Таблица лидеров", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Этот провайдер устарел", "riskNotice": { "title": "Перед продолжением", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Провайдер с ограничениями по использованию — нажмите для получения деталей", "oauth": "Этот провайдер использует вашу официальную сессию продукта/OAuth, которая не разрешена для использования с прокси/маршрутизатором. Мы не рекомендуем интенсивное использование автономных агентов (в стиле OpenCloud, длинные многошаговые потоки, большие партии) — вышестоящий сервис может отреагировать, ограничив или заблокировав аккаунт. Используйте на свой страх и риск.", "webCookie": "Этот провайдер аутентифицируется через ваши веб-сессионные куки. Внешний сервис может аннулировать сессию в любое время, требуя повторного входа в систему. Не рекомендуется для длительных unattended операций. Используйте на свой страх и риск.", @@ -5107,9 +5111,9 @@ "cancel": "Отмена" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Отключено", "enableProvider": "Включить провайдера", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Пропуск {count} существующих моделей", "autoSync": "Автосинхронизация", "autoSyncShort": "Синхронизация", + "autoFetchModels": "Автоматически получать модели из upstream", + "autoFetchModelsTooltip": "Получить и кэшировать модели upstream по мере необходимости", + "autoFetchModelsEnabled": "Включен автоматический выбор модели upstream", + "autoFetchModelsDisabled": "Автоматическое получение модели из upstream отключено", + "autoFetchModelsToggleFailed": "Не удалось переключить автоматическую выборку модели upstream", + "autoFetchModelsPartialFailure": "Некоторые соединения обновлены, но авто-загрузка модели upstream не была изменена везде", + "overridesUpstreamModel": "Переопределяет upstream", + "overridesUpstreamModelHint": "Ваши настройки переопределяют эту модель upstream", + "resetToUpstreamDefaults": "Восстановить настройки по умолчанию upstream", + "resetToUpstreamDefaultsSuccess": "Восстановлены настройки модели по умолчанию для upstream", + "resetToUpstreamDefaultsFailed": "Не удалось восстановить значения по умолчанию для модели upstream", "autoSyncTooltip": "Автоматически обновляет список моделей каждые 24 часа (настраивается через MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Автосинхронизация включена — модели будут периодически обновляться", "autoSyncDisabled": "Автосинхронизация отключена", @@ -5438,16 +5453,16 @@ "interceptFetchHint": "Перенаправлять нативные вызовы инструмента web_fetch на /v1/web/fetch в OmniRoute.", "interceptionLoadError": "Не удалось загрузить настройки перехвата: {error}", "interceptionSaveError": "Не удалось сохранить настройки перехвата: {error}", - "ccAliasSectionTitle": "Зеркальные ID Claude Code", - "ccAliasSectionHint": "Эти зеркала публикуют не-Claude модели под ID claude/<провайдер>/<модель>, чтобы Claude Code gateway model discovery мог их перечислить.", - "ccAliasProviderLevelLabel": "Провайдер включён", - "ccAliasModelOverridesLabel": "Переопределения моделей", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", "ccAliasModelOverrideAriaLabel": "Override for {modelId}", - "ccAliasStateInherit": "Наследовать", - "ccAliasStateOn": "Вкл", - "ccAliasStateOff": "Выкл", - "ccAliasAddModelPlaceholder": "Добавить модель…", - "ccAliasAddModelButton": "Добавить", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Дополнительные заголовки upstream", @@ -6194,7 +6209,7 @@ "galadriel": "Подключите Galadriel с помощью API-ключа.", "predibase": "Бесплатный пробный баланс $25 (срок действия 30 дней)", "chenzk": "Совместимый с OpenAI шлюз с актуальным каталогом моделей на chenzk.top.", - "freepik": "Генерация изображений с помощью Freepik Mystic API.", + "magnific": "Генерация изображений с помощью Freepik Mystic API.", "freetheai": "Бесплатный совместимый с OpenAI шлюз с поддержкой сквозной передачи моделей (passthrough).", "g4f-gemini": "Бесплатный обратный прокси g4f.space без ключа к Gemini, лимит 5 запросов в минуту.", "g4f-groq": "Бесплатный обратный прокси g4f.space без ключа к Groq, лимит 5 запросов в минуту.", @@ -6209,6 +6224,7 @@ "claude": "Подключите Claude Code с помощью существующего процесса OAuth.", "cline": "Подключите Cline с помощью существующего процесса OAuth.", "cursor": "Подключите Cursor IDE с помощью существующего процесса OAuth.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Подключите GitHub Copilot с помощью существующего процесса OAuth.", "gitlab-duo": "Приложение OAuth с областями доступа (scopes) ai_features + read_user. Настройте GITLAB_DUO_OAUTH_CLIENT_ID и, при необходимости, GITLAB_DUO_OAUTH_CLIENT_SECRET на этом экземпляре OmniRoute.", "kilocode": "Подключите Kilo Code с помощью существующего процесса OAuth.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Друг открытого кода", "cheaperInferenceSupporterTooltip": "Cheaper Inference поддерживает OmniRoute как друг открытого кода", "kimiPartnerLinkNote": "Партнерская ссылка — поддерживает OmniRoute без дополнительных затрат с вашей стороны", + "codexQuotaPools": "Пулы квот Codex", + "codexPoolAvailable": "Доступен", + "codexPoolPartiallyLimited": "Частично ограничен", + "codexPoolFullyLimited": "Полностью ограничен", + "codexPoolLimited": "Ограничено: {count}", + "codexPoolQuotaExhausted": "Квота исчерпана", + "codexPoolCoolingDown": "В периоде ожидания", + "codexPoolUsed": "использовано", + "codexPoolUntil": "До {value}", "anonymousFallbackTitle": "Анонимный резервный вариант", "anonymousFallbackDesc": "Когда все настроенные соединения исчерпаны (квота, кредиты или срок действия), временно используйте безключевой уровень этого провайдера. Выключите, чтобы пропустить этого провайдера вместо отправки анонимных запросов — рекомендуется, когда безключевой уровень их отклоняет (401).", "anonymousFallbackEnabled": "Анонимный резервный вариант включен для {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Настройки конечной точки сохраненной модели", "searchByModelAria": "Поиск по модели", "selectSupportedEndpoint": "Выберите хотя бы одну поддерживаемую конечную точку", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsDisabled": "Автоматическое получение модели из upstream отключено", - "autoFetchModels": "Автоматически получать модели из upstream", - "autoFetchModelsTooltip": "Получить и кэшировать модели upstream по мере необходимости", - "autoFetchModelsEnabled": "Включен автоматический выбор модели upstream", - "overridesUpstreamModel": "Переопределяет upstream", - "overridesUpstreamModelHint": "Ваши настройки переопределяют эту модель upstream", - "autoFetchModelsToggleFailed": "Не удалось переключить автоматическую выборку модели upstream", - "autoFetchModelsPartialFailure": "Некоторые соединения обновлены, но авто-загрузка модели upstream не была изменена везде", - "resetToUpstreamDefaults": "Восстановить настройки по умолчанию upstream", - "resetToUpstreamDefaultsFailed": "Не удалось восстановить значения по умолчанию для модели upstream", - "resetToUpstreamDefaultsSuccess": "Восстановлены настройки модели по умолчанию для upstream" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Настройки", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Навсегда помечать соединения провайдера как отключённые, если они возвращают сигнал окончательной блокировки (например, HTTP 403 'verify your account'). Это убирает их из ротации комбо.", "autoDisableThreshold": "Порог блокировки", "autoDisableThresholdDesc": "Количество подряд идущих сигналов блокировки, необходимых перед постоянным отключением.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Запрещенные ключевые слова", "customBannedSignalsDesc": "Дополнительные ключевые слова, которые вызывают обнаружение постоянной блокировки аккаунта. Встроенные ключевые слова применяются всегда.", "customBannedSignalsPlaceholder": "например, api key revoked", @@ -7189,6 +7203,7 @@ "configured": "настроено", "none": "Нет", "modelOverrideValuePlaceholder": "Числовое значение", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Добавить ключ-значение", "noModelOverrides": "Для этой модели не настроено переопределений.", "modelOverrideLoadFailed": "Не удалось загрузить переопределения модели", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Краткий CJK (文言)", "description": "Ультракраткий классический китайский стиль (доступно только для китайского языка)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Комбинации Round-robin и Random переключаются на другое подключение при каждом запросе вместо привязки всего диалога к одному подключению по хэшу первого сообщения. Оставьте выключенным, чтобы сохранить попадания в кэш промптов для многоходовых чатов. Переопределения для конкретных комбинаций имеют приоритет.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Маскирование учетных данных", "credentialRedactionDesc": "Маскировать API-ключи, токены и секреты в контексте, отправляемом провайдерам, и в ответах.", "enableCredentialRedaction": "Включить маскирование учетных данных", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Включить движок", "enableDescription": "Запускается последним в стеке (после того как RTK/Caveman очищает текст, а OmniGlyph преобразует оставшуюся часть в изображения), а также работает автономно в режиме omniglyph. Это предварительная версия, которая остается отключенной по умолчанию до завершения сквозной проверки.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Сохранено.", "saveFailed": "Не удалось сохранить.", "enableAria": "Включить движок OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "макс", "grokAutoTopUpMonth": "месяц", "grokAdditionalCredits": "Дополнительные кредиты", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Регистратор", "proxyTab": "Прокси", "budgetManagement": "Управление бюджетом", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Первый токен", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 61e20724cf..10bf077a43 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Vizualizácia časovej osi požiadaviek", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "otvorené", "close": "zatvoriť" }, - "noResults": "Žiadne výsledky", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Žiadne výsledky" }, "webhooks": { "title": "Webhooky", @@ -1739,8 +1739,8 @@ "quotaShare": "Zdieľanie kvóty", "discovery": "Objavovanie", "freeProviderRankings": "Bezplatné rebríčky poskytovateľov", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Bezplatné úrovne", "gamification": "Gamifikácia", "leaderboard": "Rebríček", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Podpora tohto poskytovateľa bola ukončená", "riskNotice": { "title": "Pred pokračovaním", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Poskytovateľ s obmedzeniami používania — kliknutím zobrazíte podrobnosti", "oauth": "Tento poskytovateľ používa vašu oficiálnu reláciu produktu/OAuth, ktorá nie je autorizovaná na použitie ako proxy/smerovač. Neodporúčame intenzívne používanie autonómnych agentov (v štýle OpenCloud, dlhé viacstupňové toky, veľké dávky) — upstream môže reagovať obmedzením alebo zablokovaním účtu. Používajte na vlastné riziko.", "webCookie": "Tento poskytovateľ sa autentifikuje prostredníctvom súborov cookie vašej webovej relácie. Služba upstream môže reláciu kedykoľvek zneplatniť, čo si vyžiada opätovné prihlásenie. Neodporúča sa pre dlhé operácie bez dozoru. Používajte na vlastné riziko.", @@ -5107,9 +5111,9 @@ "cancel": "Zrušiť" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Zakázané", "enableProvider": "Povoliť poskytovateľa", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Preskakujem {count} existujúcich modelov", "autoSync": "Automatická synchronizácia", "autoSyncShort": "Synchronizovať", + "autoFetchModels": "Automaticky načítať upstream modely", + "autoFetchModelsTooltip": "Načítajte a uložte upstream modely, keď je to potrebné", + "autoFetchModelsEnabled": "Automatické načítanie modelu upstream je povolené", + "autoFetchModelsDisabled": "Automatické načítanie modelu upstream je zakázané", + "autoFetchModelsToggleFailed": "Nepodarilo sa prepnúť automatické získavanie modelu upstream", + "autoFetchModelsPartialFailure": "Niektoré pripojenia boli aktualizované, ale automatické načítanie modelu upstream nebolo zmenené všade", + "overridesUpstreamModel": "Prepisuje upstream", + "overridesUpstreamModelHint": "Vaše nastavenia prepisujú tento upstream model", + "resetToUpstreamDefaults": "Obnoviť predvolené nastavenia upstream", + "resetToUpstreamDefaultsSuccess": "Obnovené predvolené nastavenia upstream modelu", + "resetToUpstreamDefaultsFailed": "Obnovenie predvolených nastavení modelu upstream zlyhalo", "autoSyncTooltip": "Automaticky obnovovať zoznam modelov každých 24 hodín (konfigurovateľné cez MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Automatická synchronizácia povolená – modely sa budú pravidelne obnovovať", "autoSyncDisabled": "Automatická synchronizácia je zakázaná", @@ -5439,17 +5454,17 @@ "interceptionLoadError": "Nepodarilo sa načítať nastavenia zachytávania: {error}", "interceptionSaveError": "Nepodarilo sa uložiť nastavenia zachytávania: {error}", "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", - "ccAliasSectionHint": "Inzerujte modely tohto poskytovateľa pod claude/<provider>/<model> zrkadlovými ID, aby mohol modelový objav brány Claude Code ich zoznamovať. Predvolene vypnuté — povolením sa zdvojnásobia záznamy v katalógu pre všetkých klientov.", - "ccAliasProviderLevelLabel": "Predvolený poskytovateľ", - "ccAliasModelOverridesLabel": "Pre každú modelovú výnimku", - "ccAliasModelOverrideAriaLabel": "Prepis pre {modelId}", - "ccAliasStateInherit": "Dedičstvo", - "ccAliasStateOn": "Na", - "ccAliasStateOff": "Vypnuté", - "ccAliasAddModelPlaceholder": "Model id (napr. gpt-4o)", - "ccAliasAddModelButton": "Pridať prepis", - "ccAliasLoadError": "Nepodarilo sa načítať nastavenia discovery-alias: {error}", - "ccAliasSaveError": "Nepodarilo sa uložiť nastavenie discovery-alias: {error}", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Pripojte Galadriel pomocou API kľúča.", "predibase": "Bezplatný skúšobný kredit 25 $ (platnosť 30 dní)", "chenzk": "Brána kompatibilná s OpenAI so živým katalógom modelov na chenzk.top.", - "freepik": "Generujte obrázky pomocou Mystic API od Freepik.", + "magnific": "Generujte obrázky pomocou Mystic API od Freepik.", "freetheai": "Bezplatná brána kompatibilná s OpenAI s podporou prenosu modelov (passthrough).", "g4f-gemini": "Bezplatný reverzný proxy server g4f.space bez kľúča pre Gemini, obmedzený na 5 požiadaviek za minútu.", "g4f-groq": "Bezplatný reverzný proxy server g4f.space bez kľúča pre Groq, obmedzený na 5 požiadaviek za minútu.", @@ -6209,6 +6224,7 @@ "claude": "Pripojte Claude Code pomocou existujúceho toku OAuth.", "cline": "Pripojte Cline pomocou existujúceho toku OAuth.", "cursor": "Pripojte Cursor IDE pomocou existujúceho toku OAuth.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Pripojte GitHub Copilot pomocou existujúceho toku OAuth.", "gitlab-duo": "Aplikácia OAuth s rozsahmi (scopes) ai_features + read_user. Nakonfigurujte GITLAB_DUO_OAUTH_CLIENT_ID a voliteľne GITLAB_DUO_OAUTH_CLIENT_SECRET na tejto inštancii OmniRoute.", "kilocode": "Pripojte Kilo Code pomocou existujúceho toku OAuth.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Priateľ open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference podporuje OmniRoute ako priateľ open source", "kimiPartnerLinkNote": "Partnerský odkaz — podporuje OmniRoute bez akýchkoľvek dodatočných nákladov pre vás", + "codexQuotaPools": "Fondy kvót Codex", + "codexPoolAvailable": "Dostupný", + "codexPoolPartiallyLimited": "Čiastočne obmedzený", + "codexPoolFullyLimited": "Úplne obmedzený", + "codexPoolLimited": "Obmedzené: {count}", + "codexPoolQuotaExhausted": "Kvóta vyčerpaná", + "codexPoolCoolingDown": "V čakacej lehote", + "codexPoolUsed": "využité", + "codexPoolUntil": "Do {value}", "anonymousFallbackTitle": "Anonymný záložný systém", "anonymousFallbackDesc": "Keď sú všetky nakonfigurované pripojenia vyčerpané (kvóta, kredity alebo vypršanie platnosti), dočasne použite bezkľúčovú úroveň tohto poskytovateľa. Vypnite, aby ste preskočili tohto poskytovateľa namiesto odosielania anonymných požiadaviek — odporúča sa, keď bezkľúčová úroveň ich odmieta (401).", "anonymousFallbackEnabled": "Anonymný záložný režim povolený pre {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Nastavenia koncového bodu uloženého modelu", "searchByModelAria": "Hľadať podľa modelu", "selectSupportedEndpoint": "Vyberte aspoň jeden podporovaný koncový bod", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsEnabled": "Automatické načítanie modelu upstream je povolené", - "autoFetchModelsDisabled": "Automatické načítanie modelu upstream je zakázané", - "autoFetchModelsTooltip": "Načítajte a uložte upstream modely, keď je to potrebné", - "autoFetchModels": "Automaticky načítať upstream modely", - "overridesUpstreamModel": "Prepisuje upstream", - "autoFetchModelsToggleFailed": "Nepodarilo sa prepnúť automatické získavanie modelu upstream", - "overridesUpstreamModelHint": "Vaše nastavenia prepisujú tento upstream model", - "autoFetchModelsPartialFailure": "Niektoré pripojenia boli aktualizované, ale automatické načítanie modelu upstream nebolo zmenené všade", - "resetToUpstreamDefaultsSuccess": "Obnovené predvolené nastavenia upstream modelu", - "resetToUpstreamDefaults": "Obnoviť predvolené nastavenia upstream", - "resetToUpstreamDefaultsFailed": "Obnovenie predvolených nastavení modelu upstream zlyhalo" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Nastavenia", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Zakázané kľúčové slová", "customBannedSignalsDesc": "Ďalšie kľúčové slová, ktoré spúšťajú detekciu trvalého zablokovania účtu. Vstavané kľúčové slová platia vždy.", "customBannedSignalsPlaceholder": "napr. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "nakonfigurované", "none": "Žiadne", "modelOverrideValuePlaceholder": "Číselná hodnota", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Pridať kľúč – hodnotu", "noModelOverrides": "Pre tento model nie sú nakonfigurované žiadne prepísania.", "modelOverrideLoadFailed": "Nepodarilo sa načítať prepísania modelov", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Stručné CJK (文言)", "description": "Klasický čínsky ultra stručný štýl (dostupný len pre čínštinu)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Kombinácie round-robin a náhodného výberu sa pri každej požiadavke prepnú na iné pripojenie namiesto toho, aby celú konverzáciu priradili k jednému pripojeniu na základe hašu prvej správy. Ponechajte vypnuté, ak chcete zachovať zásahy do vyrovnávacej pamäte promptov (prompt-cache) pre viacúrovňové chaty. Prepísania pre jednotlivé kombinácie majú prednosť.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Maskovanie prihlasovacích údajov", "credentialRedactionDesc": "Maskovať API kľúče, tokeny a tajné kľúče v kontexte odosielanom poskytovateľom a v odpovediach.", "enableCredentialRedaction": "Enable credential redaction", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Povoliť engine", "enableDescription": "Spúšťa sa ako posledný v stacku (po tom, čo RTK/Caveman vyčistí text, OmniGlyph skonvertuje zvyšok na obrázky) a funguje aj samostatne v režime omniglyph. Toto je predbežná verzia a predvolene zostáva vypnutá, kým sa nedokončí end-to-end validácia.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Uložené.", "saveFailed": "Nepodarilo sa uložiť.", "enableAria": "Povoliť engine OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "mesiac", "grokAdditionalCredits": "Ďalšie kredity", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Správa rozpočtu", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Prvý token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index ed67f44d8b..46f53972d7 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Visuell begäran tidslinje", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "öppna", "close": "stäng" }, - "noResults": "Inga resultat", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Inga resultat" }, "webhooks": { "title": "Webhooks", @@ -1739,8 +1739,8 @@ "quotaShare": "Kvotandel", "discovery": "Upptäckt", "freeProviderRankings": "Rankning av gratisleverantörer", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Gratisnivåer", "gamification": "Spelifiering", "leaderboard": "Topplista", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Denna leverantör har fasats ut", "riskNotice": { "title": "Innan du fortsätter", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Leverantör med användningsförbehåll — klicka för detaljer", "oauth": "Denna leverantör använder din officiella produktsession/OAuth, vilket inte är godkänt för proxy-/routeranvändning. Vi rekommenderar inte intensiv användning av autonoma agenter (OpenCloud-stil, långa flerstegsflöden, stora batcher) — uppströmsleverantören kan reagera genom att begränsa eller stänga av kontot. Används på egen risk.", "webCookie": "Denna leverantör autentiserar via dina webbsessionscookies. Uppströmstjänsten kan ogiltigförklara sessionen när som helst, vilket kräver att du loggar in igen. Rekommenderas inte för långa obevakade körningar. Används på egen risk.", @@ -5107,9 +5111,9 @@ "cancel": "Avbryt" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Inaktiverad", "enableProvider": "Aktivera leverantör", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Hoppar över {count} befintliga modeller", "autoSync": "Automatisk synkronisering", "autoSyncShort": "Synkronisera", + "autoFetchModels": "Automatiskt hämta upstream-modeller", + "autoFetchModelsTooltip": "Hämta och cacha upstream-modeller vid behov", + "autoFetchModelsEnabled": "Automatisk hämtning av upstream-modell aktiverad", + "autoFetchModelsDisabled": "Automatisk hämtning av upstream-modell inaktiverad", + "autoFetchModelsToggleFailed": "Misslyckades med att växla upstream-modellens automatisk hämtning", + "autoFetchModelsPartialFailure": "Vissa anslutningar har uppdaterats, men upstream-modellens automatisk hämtning ändrades inte överallt", + "overridesUpstreamModel": "Överskrider upstream", + "overridesUpstreamModelHint": "Dina inställningar åsidosätter denna upstream-modell", + "resetToUpstreamDefaults": "Återställ upstream-standarder", + "resetToUpstreamDefaultsSuccess": "Återställda standardinställningar för upstream-modellen", + "resetToUpstreamDefaultsFailed": "Misslyckades med att återställa standardinställningar för upstream-modellen", "autoSyncTooltip": "Uppdatera modelllistan automatiskt var 24:e timme (konfigurerbar via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Automatisk synkronisering aktiverad — modeller uppdateras regelbundet", "autoSyncDisabled": "Automatisk synkronisering inaktiverad", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Skriv om inbyggda web_fetch-verktygsanrop till OmniRoutes /v1/web/fetch.", "interceptionLoadError": "Kunde inte läsa in inställningar för interception: {error}", "interceptionSaveError": "Kunde inte spara inställningar för interception: {error}", - "ccAliasSectionTitle": "Exponera i Claude Code (claude/…)", - "ccAliasSectionHint": "Reklamera denna leverantörs modeller under claude/<provider>/<model> spegel-id så att Claude Codes gateway-modellupptäckten kan lista dem. Avstängd som standard — att aktivera detta dubblar katalogposterna för alla klienter.", - "ccAliasProviderLevelLabel": "Leverantör standard", - "ccAliasModelOverridesLabel": "Per-modell överskrivningar", - "ccAliasModelOverrideAriaLabel": "Överskrivning för {modelId}", - "ccAliasStateInherit": "Ärv", - "ccAliasStateOn": "På", - "ccAliasStateOff": "Av", - "ccAliasAddModelPlaceholder": "Modell-id (t.ex. gpt-4o)", - "ccAliasAddModelButton": "Lägg till överskrivning", - "ccAliasLoadError": "Misslyckades med att ladda discovery-alias-inställningar: {error}", - "ccAliasSaveError": "Misslyckades med att spara inställningen för discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Anslut Galadriel med en API-nyckel.", "predibase": "$25 i gratis provkrediter (30 dagars giltighet)", "chenzk": "OpenAI-kompatibel gateway med en live-modellkatalog på chenzk.top.", - "freepik": "Generera bilder med Freepiks Mystic API.", + "magnific": "Generera bilder med Freepiks Mystic API.", "freetheai": "Gratis OpenAI-kompatibel gateway med stöd för passthrough-modeller.", "g4f-gemini": "Gratis nyckelfri g4f.space reverse proxy till Gemini, begränsad till 5 anrop per minut.", "g4f-groq": "Gratis nyckelfri g4f.space reverse proxy till Groq, begränsad till 5 anrop per minut.", @@ -6209,6 +6224,7 @@ "claude": "Anslut Claude Code med det befintliga OAuth-flödet.", "cline": "Anslut Cline med det befintliga OAuth-flödet.", "cursor": "Anslut Cursor IDE med det befintliga OAuth-flödet.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Anslut GitHub Copilot med det befintliga OAuth-flödet.", "gitlab-duo": "OAuth-applikation med ai_features + read_user-omfång. Konfigurera GITLAB_DUO_OAUTH_CLIENT_ID och valfritt GITLAB_DUO_OAUTH_CLIENT_SECRET på denna OmniRoute-instans.", "kilocode": "Anslut Kilo Code med det befintliga OAuth-flödet.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Öppen källkod-vän", "cheaperInferenceSupporterTooltip": "Cheaper Inference stödjer OmniRoute som öppen källkod-vän", "kimiPartnerLinkNote": "Partnerlänk — stöder OmniRoute utan extra kostnad för dig", + "codexQuotaPools": "Codex-kvotpooler", + "codexPoolAvailable": "Tillgänglig", + "codexPoolPartiallyLimited": "Delvis begränsad", + "codexPoolFullyLimited": "Helt begränsad", + "codexPoolLimited": "{count} begränsade", + "codexPoolQuotaExhausted": "Kvoten är förbrukad", + "codexPoolCoolingDown": "I vänteperiod", + "codexPoolUsed": "använt", + "codexPoolUntil": "Till {value}", "anonymousFallbackTitle": "Anonym fallback", "anonymousFallbackDesc": "När alla konfigurerade anslutningar är uttömda (kvot, krediter eller utgång), använd tillfälligt denna leverantörs nyckellösa nivå. Stäng av för att hoppa över denna leverantör istället för att skicka anonyma förfrågningar — rekommenderas när den nyckellösa nivån avvisar dem (401).", "anonymousFallbackEnabled": "Anonym fallback aktiverad för {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Inställningar för sparad modellslutpunkt", "searchByModelAria": "Sök efter modell", "selectSupportedEndpoint": "Välj minst en stödd slutpunkt", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Automatiskt hämta upstream-modeller", - "autoFetchModelsDisabled": "Automatisk hämtning av upstream-modell inaktiverad", - "autoFetchModelsEnabled": "Automatisk hämtning av upstream-modell aktiverad", - "autoFetchModelsTooltip": "Hämta och cacha upstream-modeller vid behov", - "overridesUpstreamModel": "Överskrider upstream", - "autoFetchModelsToggleFailed": "Misslyckades med att växla upstream-modellens automatisk hämtning", - "autoFetchModelsPartialFailure": "Vissa anslutningar har uppdaterats, men upstream-modellens automatisk hämtning ändrades inte överallt", - "overridesUpstreamModelHint": "Dina inställningar åsidosätter denna upstream-modell", - "resetToUpstreamDefaults": "Återställ upstream-standarder", - "resetToUpstreamDefaultsSuccess": "Återställda standardinställningar för upstream-modellen", - "resetToUpstreamDefaultsFailed": "Misslyckades med att återställa standardinställningar för upstream-modellen" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Inställningar", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Spärrade nyckelord", "customBannedSignalsDesc": "Ytterligare nyckelord som utlöser upptäckt av permanent kontoavstängning. Inbyggda nyckelord gäller alltid.", "customBannedSignalsPlaceholder": "t.ex. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "konfigurerad", "none": "Ingen", "modelOverrideValuePlaceholder": "Numeriskt värde", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Lägg till nyckelvärde", "noModelOverrides": "Inga åsidosättningar har konfigurerats för denna modell.", "modelOverrideLoadFailed": "Det gick inte att läsa in modellåsidosättningar", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Kortfattad CJK (文言)", "description": "Klassisk kinesisk ultrakortfattad stil (endast tillgänglig för kinesiska)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Round-robin- och slumpmässiga kombinationer roterar till en annan anslutning vid varje anrop istället för att fästa en hel konversation vid en anslutning baserat på hashen för det första meddelandet. Lämna inaktiverat för att bevara prompt-cache-träffar för flerstegschattar. Inställningar per kombination har företräde.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Maskering av autentiseringsuppgifter", "credentialRedactionDesc": "Maskera API-nycklar, tokens och hemligheter från kontext som skickas till leverantörer och från svar.", "enableCredentialRedaction": "Aktivera maskering av autentiseringsuppgifter", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Aktivera motorn", "enableDescription": "Körs sist i stacken (efter att RTK/Caveman rensar texten konverterar OmniGlyph resten till bilder) och körs även fristående via omniglyph-läge. Detta är en förhandsgranskning och förblir inaktiverad som standard tills end-to-end-valideringen är slutförd.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Sparat.", "saveFailed": "Kunde inte spara.", "enableAria": "Aktivera OmniGlyph-motorn", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "månad", "grokAdditionalCredits": "Ytterligare Krediter", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budgethantering", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Första token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 21502538ee..12d19b28c1 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Muda wa ombi la kuona", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "fungua", "close": "funga" }, - "noResults": "Hakuna matokeo", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Hakuna matokeo" }, "webhooks": { "title": "Viboko vya mtandao", @@ -1739,8 +1739,8 @@ "quotaShare": "Mgao wa Quota", "discovery": "Ugunduzi", "freeProviderRankings": "Nafasi za Watoa Huduma Bila Malipo", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Viwango vya Bila Malipo", "gamification": "Uchezeshaji", "leaderboard": "Ubao wa Wanaoongoza", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Mtoa huduma huyu ameacha kutumika", "riskNotice": { "title": "Kabla ya kuendelea", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Mtoa huduma aliye na tahadhari za matumizi — bofya kwa maelezo zaidi", "oauth": "Mtoa huduma huyu anatumia kipindi chako rasmi cha bidhaa/OAuth, ambacho hakijaidhinishwa kwa matumizi ya proksi/ruta. Hatupendekezi matumizi makubwa ya ejenti inayojitegemea (mtindo wa OpenCloud, mtiririko mrefu wa hatua nyingi, makundi makubwa) — upstream inaweza kuchukua hatua kwa kuzuia au kupiga marufuku akaunti. Tumia kwa hatari yako mwenyewe.", "webCookie": "Mtoa huduma huyu anathibitisha kupitia kuki za kipindi chako cha wavuti. Huduma ya upstream inaweza kubatilisha kipindi wakati wowote, ikikuhitaji uingie tena. Haipendekezwi kwa shughuli ndefu zisizosimamiwa. Tumia kwa hatari yako mwenyewe.", @@ -5107,9 +5111,9 @@ "cancel": "Ghairi" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabled", "enableProvider": "Enable provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Skipping {count} existing models", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "Pata modeli za upstream kiotomatiki", + "autoFetchModelsTooltip": "Pata na kuhifadhi mifano ya juu inapohitajika", + "autoFetchModelsEnabled": "Mfano wa upstream auto-fetch umewezeshwa", + "autoFetchModelsDisabled": "Mfano wa upstream auto-fetch umezimwa", + "autoFetchModelsToggleFailed": "Imeshindikana kubadilisha hali ya upakuaji wa mfano wa juu.", + "autoFetchModelsPartialFailure": "Baadhi ya muunganisho yameboreshwa, lakini mfano wa juu wa auto-fetch haukubadilishwa kila mahali", + "overridesUpstreamModel": "Inazidi mwelekeo wa juu", + "overridesUpstreamModelHint": "Mipangilio yako inakataa mfano huu wa juu", + "resetToUpstreamDefaults": "Rejesha mipangilio ya msingi ya upstream", + "resetToUpstreamDefaultsSuccess": "Imerejeshwa mipangilio ya mfano wa upstream", + "resetToUpstreamDefaultsFailed": "Imeshindikana kurejesha mipangilio ya msingi ya upstream", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Andika upya simu za zana asili za web_fetch kwenda kwenye /v1/web/fetch ya OmniRoute.", "interceptionLoadError": "Imeshindwa kupakia mipangilio ya uzuiaji: {error}", "interceptionSaveError": "Imeshindwa kuhifadhi mipangilio ya uzuiaji: {error}", - "ccAliasSectionTitle": "Fichua katika Claude Code (claude/…)", - "ccAliasSectionHint": "Tangaza mifano ya mtoa huduma huyu chini ya claude/<provider>/<model> vitambulisho vya kioo ili kugundua mifano ya lango la Claude Code. Imezimwa kwa default — kuwezesha hii kunaongeza mara mbili orodha za katalogi kwa wateja wote.", - "ccAliasProviderLevelLabel": "Mtoa huduma wa kawaida", - "ccAliasModelOverridesLabel": "Mabadiliko ya kila mfano", - "ccAliasModelOverrideAriaLabel": "Kuzidisha kwa {modelId}", - "ccAliasStateInherit": "Rithi", - "ccAliasStateOn": "Juu", - "ccAliasStateOff": "Zimezimwa", - "ccAliasAddModelPlaceholder": "Kitambulisho cha mfano (mfano: gpt-4o)", - "ccAliasAddModelButton": "Ongeza urekebishaji", - "ccAliasLoadError": "Imeshindikana kupakia mipangilio ya discovery-alias: {error}", - "ccAliasSaveError": "Imeshindikana kuhifadhi mipangilio ya discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Unganisha Galadriel kwa ufunguo wa API.", "predibase": "Salio la majaribio ya bure la $25 (uhalali wa siku 30)", "chenzk": "Lango linalooana na OpenAI lenye orodha ya miundo ya moja kwa moja kwenye chenzk.top.", - "freepik": "Zalisha picha kwa kutumia Mystic API ya Freepik.", + "magnific": "Zalisha picha kwa kutumia Mystic API ya Freepik.", "freetheai": "Lango la bure linalooana na OpenAI lenye usaidizi wa miundo ya passthrough.", "g4f-gemini": "Reverse proxy ya bure isiyo na ufunguo ya g4f.space kwenda Gemini, yenye kikomo cha maombi 5 kwa dakika.", "g4f-groq": "Reverse proxy ya bure isiyo na ufunguo ya g4f.space kwenda Groq, yenye kikomo cha maombi 5 kwa dakika.", @@ -6209,6 +6224,7 @@ "claude": "Unganisha Claude Code kwa mtiririko uliopo wa OAuth.", "cline": "Unganisha Cline kwa mtiririko uliopo wa OAuth.", "cursor": "Unganisha Cursor IDE kwa mtiririko uliopo wa OAuth.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Unganisha GitHub Copilot kwa mtiririko uliopo wa OAuth.", "gitlab-duo": "Programu ya OAuth yenye upeo wa ai_features + read_user. Sanidi GITLAB_DUO_OAUTH_CLIENT_ID na kwa hiari GITLAB_DUO_OAUTH_CLIENT_SECRET kwenye instansi hii ya OmniRoute.", "kilocode": "Unganisha Kilo Code kwa mtiririko uliopo wa OAuth.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Rafiki wa Chanzo Huria", "cheaperInferenceSupporterTooltip": "Cheaper Inference inaunga mkono OmniRoute kama Rafiki wa Chanzo Huria", "kimiPartnerLinkNote": "Kiungo cha mshirika — kinaunga mkono OmniRoute bila gharama ya ziada kwako", + "codexQuotaPools": "Makundi ya mgao wa Codex", + "codexPoolAvailable": "Inapatikana", + "codexPoolPartiallyLimited": "Imewekewa kikomo kwa sehemu", + "codexPoolFullyLimited": "Imewekewa kikomo kikamilifu", + "codexPoolLimited": "{count} zimewekewa kikomo", + "codexPoolQuotaExhausted": "Mgao umeisha", + "codexPoolCoolingDown": "Katika kipindi cha kusubiri", + "codexPoolUsed": "imetumika", + "codexPoolUntil": "Hadi {value}", "anonymousFallbackTitle": "Kurejea kwa Kijakazi", "anonymousFallbackDesc": "Wakati muunganisho wote uliowekwa umepita (kikomo, mikopo, au muda wa kumalizika), tumia muda huu kiwango kisicho na funguo cha mtoa huduma huyu. Zima ili kupuuza mtoa huduma huyu badala ya kutuma maombi yasiyo na utambulisho — inapendekezwa wakati kiwango kisicho na funguo kinapokataa maombi hayo (401).", "anonymousFallbackEnabled": "Fallback isiyojulikana imewezeshwa kwa {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Mipangilio ya mwisho wa mfano uliohifadhiwa", "searchByModelAria": "Tafuta kwa mfano", "selectSupportedEndpoint": "Chagua angalau kiunganishi kimoja kinachoungwa mkono", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsEnabled": "Mfano wa upstream auto-fetch umewezeshwa", - "autoFetchModelsDisabled": "Mfano wa upstream auto-fetch umezimwa", - "autoFetchModels": "Pata modeli za upstream kiotomatiki", - "autoFetchModelsTooltip": "Pata na kuhifadhi mifano ya juu inapohitajika", - "autoFetchModelsToggleFailed": "Imeshindikana kubadilisha hali ya upakuaji wa mfano wa juu.", - "autoFetchModelsPartialFailure": "Baadhi ya muunganisho yameboreshwa, lakini mfano wa juu wa auto-fetch haukubadilishwa kila mahali", - "overridesUpstreamModelHint": "Mipangilio yako inakataa mfano huu wa juu", - "overridesUpstreamModel": "Inazidi mwelekeo wa juu", - "resetToUpstreamDefaults": "Rejesha mipangilio ya msingi ya upstream", - "resetToUpstreamDefaultsSuccess": "Imerejeshwa mipangilio ya mfano wa upstream", - "resetToUpstreamDefaultsFailed": "Imeshindikana kurejesha mipangilio ya msingi ya upstream" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Settings", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Maneno Muhimu Yaliyopigwa Marufuku", "customBannedSignalsDesc": "Maneno muhimu ya ziada yanayosababisha ugunduzi wa kupigwa marufuku kwa akaunti kabisa. Maneno muhimu yaliyojengwa ndani hutumika kila wakati.", "customBannedSignalsPlaceholder": "k.m. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "imesanidiwa", "none": "Hakuna", "modelOverrideValuePlaceholder": "Thamani ya nambari", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Ongeza thamani ya ufunguo", "noModelOverrides": "Hakuna ubatilishaji uliosanidiwa kwa modeli hii.", "modelOverrideLoadFailed": "Imeshindwa kupakia ubatilishaji wa modeli", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK Fupi (文言)", "description": "Mtindo mfupi zaidi wa Kichina cha Kale (inapatikana kwa Kichina pekee)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Mchanganyiko wa round-robin na bila mpangilio huzunguka hadi kwenye muunganisho tofauti kwa kila ombi badala ya kubandika mazungumzo yote kwenye muunganisho mmoja kwa heshi ya ujumbe wa kwanza. Acha ikiwa imezimwa ili kuhifadhi matokeo ya prompt-cache kwa mazungumzo ya zamu nyingi. Ubatilishaji wa kila mchanganyiko una kipaumbele.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Ufichaji wa Vitambulisho", "credentialRedactionDesc": "Ficha funguo za API, tokeni, na siri kutoka kwa muktadha uliotumwa kwa watoa huduma na kutoka kwa majibu.", "enableCredentialRedaction": "Washa ufichaji wa vitambulisho", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Wezesha injini", "enableDescription": "Hufanya kazi mwisho kwenye mrundikano (baada ya RTK/Caveman kusafisha maandishi, OmniGlyph hubadilisha yaliyosalia kuwa picha) na pia hufanya kazi pekee kupitia hali ya omniglyph. Hii ni hakiki na inabaki imezimwa kwa chaguomsingi hadi uthibitishaji wa mwisho hadi mwisho ukamilike.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Imehifadhiwa.", "saveFailed": "Imeshindwa kuhifadhi.", "enableAria": "Wezesha injini ya OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "mwezi", "grokAdditionalCredits": "Mikopo Ya Ziada", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Tokeni ya Kwanza", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 5b8e2e395a..2e80cf7f22 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "காட்சி கோரிக்கை காலக்கெடு", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "திறக்கவும்", "close": "மூடு" }, - "noResults": "எந்த முடிவுகளும் இல்லை", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "எந்த முடிவுகளும் இல்லை" }, "webhooks": { "title": "வெப்ஹூக்ஸ்", @@ -1739,8 +1739,8 @@ "quotaShare": "ஒதுக்கீட்டுப் பகிர்வு", "discovery": "கண்டறிதல்", "freeProviderRankings": "இலவச வழங்குநர் தரவரிசை", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "இலவச அடுக்குகள்", "gamification": "கேமிஃபிகேஷன்", "leaderboard": "லீடர்போர்டு", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "இந்த வழங்குநர் நிராகரிக்கப்பட்டார்", "riskNotice": { "title": "தொடர்வதற்கு முன்", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "பயன்பாட்டு எச்சரிக்கைகளைக் கொண்ட வழங்குநர் — விவரங்களுக்கு கிளிக் செய்யவும்", "oauth": "இந்த வழங்குநர் உங்களது அதிகாரப்பூர்வ தயாரிப்பு அமர்வு/OAuth ஐப் பயன்படுத்துகிறார், இது ப்ராக்ஸி/ரவுட்டர் பயன்பாட்டிற்கு அங்கீகரிக்கப்படவில்லை. தீவிரமான தன்னாட்சி முகவர் பயன்பாட்டை (OpenCloud-பாணி, நீண்ட பல-படி ஓட்டங்கள், பெரிய தொகுதிகள்) நாங்கள் பரிந்துரைக்கவில்லை — அப்ஸ்ட்ரீம் கணக்கைக் கட்டுப்படுத்துவதன் மூலமோ அல்லது தடை செய்வதன் மூலமோ எதிர்வினையாற்றலாம். உங்கள் சொந்த பொறுப்பில் பயன்படுத்தவும்.", "webCookie": "இந்த வழங்குநர் உங்கள் வலை அமர்வு குக்கீகள் மூலம் அங்கீகரிக்கிறார். அப்ஸ்ட்ரீம் சேவை எந்த நேரத்திலும் அமர்வை செல்லாததாக்கலாம், இதனால் நீங்கள் மீண்டும் உள்நுழைய வேண்டியிருக்கும். நீண்ட கவனிக்கப்படாத செயல்பாடுகளுக்கு பரிந்துரைக்கப்படவில்லை. உங்கள் சொந்த பொறுப்பில் பயன்படுத்தவும்.", @@ -5107,9 +5111,9 @@ "cancel": "ரத்துசெய்" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabled", "enableProvider": "Enable provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Skipping {count} existing models", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "உயர்தர மாதிரிகளை தானாகப் பெறவும்", + "autoFetchModelsTooltip": "தேவையான போது மேல்நிலை மாதிரிகளை பெறவும் மற்றும் கச்சே செய்யவும்", + "autoFetchModelsEnabled": "மேல்நிலை மாதிரி தானாகப் பெறுதல் செயல்படுத்தப்பட்டது", + "autoFetchModelsDisabled": "மேல்நிலை மாதிரி தானாகப் பெறுதல் முடக்கப்பட்டது", + "autoFetchModelsToggleFailed": "மேல்தர மாதிரி தானாகப் பெறுதலை மாற்ற முடியவில்லை", + "autoFetchModelsPartialFailure": "சில இணைப்புகள் புதுப்பிக்கப்பட்டன, ஆனால் மேல்மட்ட மாதிரி தானாகப் பெறுதல் எங்கும் மாற்றப்படவில்லை", + "overridesUpstreamModel": "மேல்நிலை மாற்றங்கள்", + "overridesUpstreamModelHint": "உங்கள் அமைப்புகள் இந்த மேல்மட்ட மாதிரியை மீறுகின்றன", + "resetToUpstreamDefaults": "முதன்மை இயல்புகளை மீட்டமைக்கவும்", + "resetToUpstreamDefaultsSuccess": "மீட்டமைக்கப்பட்ட மேல்நிலை மாதிரி இயல்புகள்", + "resetToUpstreamDefaultsFailed": "மேல்நிலை மாதிரி இயல்புகளை மீட்டெடுக்க முடியவில்லை", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "சொந்த web_fetch கருவி அழைப்புகளை OmniRoute இன் /v1/web/fetch க்கு மீண்டும் எழுதவும்.", "interceptionLoadError": "இடைமறிப்பு அமைப்புகளை ஏற்றுவதில் தோல்வி: {error}", "interceptionSaveError": "இடைமறிப்பு அமைப்புகளைச் சேமிப்பதில் தோல்வி: {error}", - "ccAliasSectionTitle": "Claude Code-ல் வெளிப்படுத்தவும் (claude/…)", - "ccAliasSectionHint": "இந்த வழங்குநரின் மாதிரிகளை claude/<provider>/<model> மிரர் அடையாளங்களின் கீழ் விளம்பரம் செய்யவும், எனவே Claude Code இன் கேட்வே மாதிரி கண்டுபிடிப்பு அவற்றைப் பட்டியலிடலாம். இயல்பாக அண்மையில் отключено — இதை இயக்குவது அனைத்து கிளையன்டுகளுக்கான பட்டியல் பதிவுகளை இரட்டிப்பாக்குகிறது.", - "ccAliasProviderLevelLabel": "முதன்மை வழங்குநர்", - "ccAliasModelOverridesLabel": "மாதிரி அடிப்படையில் மீறல்கள்", - "ccAliasModelOverrideAriaLabel": "{modelId} க்கான மீறல்", - "ccAliasStateInherit": "மரபு", - "ccAliasStateOn": "இல்", - "ccAliasStateOff": "ஆஃப்", - "ccAliasAddModelPlaceholder": "மாதிரி ஐடி (எடுத்துக்காட்டு: gpt-4o)", - "ccAliasAddModelButton": "மீட்டமைப்பு சேர்க்கவும்", - "ccAliasLoadError": "கண்டுபிடிப்பு-அலியாஸ் அமைப்புகளை ஏற்றுவதில் தோல்வி: {error}", - "ccAliasSaveError": "கண்டுபிடிப்பு-அலியாஸ் அமைப்பை சேமிக்க முடியவில்லை: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "API key மூலம் Galadriel-ஐ இணைக்கவும்.", "predibase": "$25 இலவச சோதனை கிரெடிட்கள் (30 நாள் செல்லுபடியாகும்)", "chenzk": "chenzk.top இல் நேரடி மாடல் பட்டியலுடன் கூடிய OpenAI-இணக்கமான gateway.", - "freepik": "Freepik-இன் Mystic API மூலம் படங்களை உருவாக்கவும்.", + "magnific": "Freepik-இன் Mystic API மூலம் படங்களை உருவாக்கவும்.", "freetheai": "passthrough மாடல் ஆதரவுடன் கூடிய இலவச OpenAI-இணக்கமான gateway.", "g4f-gemini": "Gemini-க்கான இலவச key இல்லாத g4f.space reverse proxy, நிமிடத்திற்கு 5 கோரிக்கைகள் என வரம்பிடப்பட்டுள்ளது.", "g4f-groq": "Groq-க்கான இலவச key இல்லாத g4f.space reverse proxy, நிமிடத்திற்கு 5 கோரிக்கைகள் என வரம்பிடப்பட்டுள்ளது.", @@ -6209,6 +6224,7 @@ "claude": "ஏற்கனவே உள்ள OAuth flow மூலம் Claude Code-ஐ இணைக்கவும்.", "cline": "ஏற்கனவே உள்ள OAuth flow மூலம் Cline-ஐ இணைக்கவும்.", "cursor": "ஏற்கனவே உள்ள OAuth flow மூலம் Cursor IDE-ஐ இணைக்கவும்.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "ஏற்கனவே உள்ள OAuth flow மூலம் GitHub Copilot-ஐ இணைக்கவும்.", "gitlab-duo": "ai_features + read_user scopes கொண்ட OAuth பயன்பாடு. இந்த OmniRoute instance-இல் GITLAB_DUO_OAUTH_CLIENT_ID மற்றும் விருப்பத்தேர்வாக GITLAB_DUO_OAUTH_CLIENT_SECRET-ஐ உள்ளமைக்கவும்.", "kilocode": "ஏற்கனவே உள்ள OAuth flow மூலம் Kilo Code-ஐ இணைக்கவும்.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "திறந்த மூல நண்பர்", "cheaperInferenceSupporterTooltip": "Cheaper Inference ஒரு திறந்த மூல நண்பராக OmniRoute-ஐ ஆதரிக்கிறது", "kimiPartnerLinkNote": "பங்குதாரர் இணைப்பு — உங்களுக்கு எந்த கூடுதல் கட்டணமும் இன்றி OmniRoute-ஐ ஆதரிக்கிறது", + "codexQuotaPools": "Codex ஒதுக்கீட்டுத் தொகுப்புகள்", + "codexPoolAvailable": "கிடைக்கிறது", + "codexPoolPartiallyLimited": "பகுதியளவு கட்டுப்படுத்தப்பட்டது", + "codexPoolFullyLimited": "முழுமையாகக் கட்டுப்படுத்தப்பட்டது", + "codexPoolLimited": "{count} கட்டுப்படுத்தப்பட்டவை", + "codexPoolQuotaExhausted": "ஒதுக்கீடு தீர்ந்தது", + "codexPoolCoolingDown": "காத்திருப்பு காலத்தில் உள்ளது", + "codexPoolUsed": "பயன்படுத்தப்பட்டது", + "codexPoolUntil": "{value} வரை", "anonymousFallbackTitle": "அறியப்படாத மாற்று", "anonymousFallbackDesc": "எல்லா கட்டமைக்கப்பட்ட இணைப்புகள் முடிந்தால் (கோட்டா, கிரெடிட்கள், அல்லது காலாவதி), இந்த வழங்குநரின் விசையில்லா நிலையை தற்காலிகமாக பயன்படுத்தவும். இந்த வழங்குநரை தவிர்க்க மாறி அனான்மா கோரிக்கைகளை அனுப்பாமல் выключить செய்யவும் — விசையில்லா நிலை அவற்றை நிராகரிக்கும் போது (401) பரிந்துரைக்கப்படுகிறது.", "anonymousFallbackEnabled": "{provider} க்கான அங்கீகாரம் இல்லாத மாற்று செயல்படுத்தப்பட்டது", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "சேமிக்கப்பட்ட மாதிரி முடிவுறுப்பு அமைப்புகள்", "searchByModelAria": "மாதிரியில் தேடு", "selectSupportedEndpoint": "குறைந்தது ஒரு ஆதரிக்கப்படும் முடிவுகளைத் தேர்ந்தெடுக்கவும்", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsTooltip": "தேவையான போது மேல்நிலை மாதிரிகளை பெறவும் மற்றும் கச்சே செய்யவும்", - "autoFetchModelsDisabled": "மேல்நிலை மாதிரி தானாகப் பெறுதல் முடக்கப்பட்டது", - "autoFetchModels": "உயர்தர மாதிரிகளை தானாகப் பெறவும்", - "autoFetchModelsEnabled": "மேல்நிலை மாதிரி தானாகப் பெறுதல் செயல்படுத்தப்பட்டது", - "overridesUpstreamModel": "மேல்நிலை மாற்றங்கள்", - "autoFetchModelsPartialFailure": "சில இணைப்புகள் புதுப்பிக்கப்பட்டன, ஆனால் மேல்மட்ட மாதிரி தானாகப் பெறுதல் எங்கும் மாற்றப்படவில்லை", - "autoFetchModelsToggleFailed": "மேல்தர மாதிரி தானாகப் பெறுதலை மாற்ற முடியவில்லை", - "overridesUpstreamModelHint": "உங்கள் அமைப்புகள் இந்த மேல்மட்ட மாதிரியை மீறுகின்றன", - "resetToUpstreamDefaults": "முதன்மை இயல்புகளை மீட்டமைக்கவும்", - "resetToUpstreamDefaultsSuccess": "மீட்டமைக்கப்பட்ட மேல்நிலை மாதிரி இயல்புகள்", - "resetToUpstreamDefaultsFailed": "மேல்நிலை மாதிரி இயல்புகளை மீட்டெடுக்க முடியவில்லை" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Settings", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "தடைசெய்யப்பட்ட முக்கிய வார்த்தைகள்", "customBannedSignalsDesc": "நிரந்தரக் கணக்குத் தடை கண்டறிதலைத் தூண்டும் கூடுதல் முக்கிய வார்த்தைகள். உள்ளமைக்கப்பட்ட முக்கிய வார்த்தைகள் எப்போதும் பொருந்தும்.", "customBannedSignalsPlaceholder": "எ.கா. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "கட்டமைக்கப்பட்டது", "none": "ஏதுமில்லை", "modelOverrideValuePlaceholder": "எண் மதிப்பு", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "விசை மதிப்பைச் சேர்", "noModelOverrides": "இந்த மாதிரிக்கு மேலெழுதல்கள் எதுவும் கட்டமைக்கப்படவில்லை.", "modelOverrideLoadFailed": "மாதிரி மேலெழுதல்களை ஏற்றுவதில் தோல்வி", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "சுருக்கமான CJK (文言)", "description": "செம்மொழி-சீன மிகச் சுருக்கமான நடை (சீன மொழிக்கு மட்டுமே கிடைக்கும்)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "முதல்-செய்தி ஹாஷ் மூலம் முழு உரையாடலையும் ஒரே இணைப்பில் நிலைநிறுத்துவதற்குப் பதிலாக, Round-robin மற்றும் random சேர்க்கைகள் ஒவ்வொரு கோரிக்கையிலும் வேறுபட்ட இணைப்புக்கு மாறுகின்றன. பல-முறை அரட்டைகளுக்கான prompt-cache ஹிட்களைப் பாதுகாக்க இதை முடக்கியே வைக்கவும். Per-combo மேலெழுதல்களுக்கு முன்னுரிமை உண்டு.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "கிரெடென்ஷியல் மறைத்தல்", "credentialRedactionDesc": "வழங்குநர்களுக்கு அனுப்பப்படும் சூழல் மற்றும் பதில்களில் இருந்து API விசைகள், டோக்கன்கள் மற்றும் ரகசியங்களை மறைக்கவும்.", "enableCredentialRedaction": "கிரெடென்ஷியல் மறைத்தலை இயக்கு", @@ -8600,6 +8623,27 @@ }, "enableTitle": "இயந்திரத்தை இயக்கு", "enableDescription": "அடுக்கில் கடைசியாக இயங்குகிறது (RTK/Caveman உரையைச் சுத்தப்படுத்திய பிறகு, OmniGlyph எஞ்சியிருப்பதை படங்களாக மாற்றுகிறது) மேலும் omniglyph பயன்முறை மூலமாகவும் தனித்து இயங்குகிறது. இது ஒரு முன்னோட்டமாகும், மேலும் இறுதி-முதல்-இறுதி சரிபார்ப்பு முடியும் வரை இயல்பாகவே முடக்கப்பட்டிருக்கும்.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "சேமிக்கப்பட்டது.", "saveFailed": "சேமிக்க முடியவில்லை.", "enableAria": "OmniGlyph இயந்திரத்தை இயக்கு", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "அதிகतम", "grokAutoTopUpMonth": "மாதம்", "grokAdditionalCredits": "கூடுதல் நிதிகள்", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "முதல் டோக்கன்", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index a9a45379d1..73032e0d13 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "దృశ్య అభ్యర్థన కాలరేఖ", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "తిరిగి తెరువు", "close": "మూసివేయండి" }, - "noResults": "ఫలితాలు లేవు", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "ఫలితాలు లేవు" }, "webhooks": { "title": "వెబ్‌బూక్స్", @@ -1739,8 +1739,8 @@ "quotaShare": "కోటా షేర్", "discovery": "అన్వేషణ", "freeProviderRankings": "ఉచిత ప్రొవైడర్ ర్యాంకింగ్‌లు", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "ఉచిత శ్రేణులు", "gamification": "గేమిఫికేషన్", "leaderboard": "లీడర్‌బోర్డ్", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "ఈ ప్రొవైడర్ నిలిపివేయబడింది", "riskNotice": { "title": "కొనసాగడానికి ముందు", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "వినియోగ హెచ్చరికలు ఉన్న ప్రొవైడర్ — వివరాల కోసం క్లిక్ చేయండి", "oauth": "ఈ ప్రొవైడర్ మీ అధికారిక ప్రోడక్ట్ సెషన్/OAuthని ఉపయోగిస్తుంది, ఇది ప్రాక్సీ/రూటర్ వినియోగానికి అనుమతించబడలేదు. మేము తీవ్రమైన అటానమస్ ఏజెంట్ వినియోగాన్ని (OpenCloud-శైలి, సుదీర్ఘ బహుళ-దశల ఫ్లోలు, పెద్ద బ్యాచ్‌లు) సిఫార్సు చేయము — అప్‌స్ట్రీమ్ ఖాతాను పరిమితం చేయడం లేదా నిషేధించడం ద్వారా ప్రతిస్పందించవచ్చు. మీ స్వంత పూచీకత్తుపై ఉపయోగించండి.", "webCookie": "ఈ ప్రొవైడర్ మీ వెబ్ సెషన్ కుకీల ద్వారా ప్రామాణీకరిస్తుంది. అప్‌స్ట్రీమ్ సేవ ఎప్పుడైనా సెషన్‌ను చెల్లనిదిగా చేయవచ్చు, దీని వలన మీరు మళ్లీ లాగిన్ అవ్వాల్సి ఉంటుంది. ఎక్కువసేపు పర్యవేక్షణ లేని ఆపరేషన్ల కోసం సిఫార్సు చేయబడదు. మీ స్వంత పూచీకత్తుపై ఉపయోగించండి.", @@ -5107,9 +5111,9 @@ "cancel": "రద్దు చేయండి" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabled", "enableProvider": "Enable provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Skipping {count} existing models", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "ఆటో-ఫెచ్ అప్‌స్ట్రీమ్ మోడల్స్", + "autoFetchModelsTooltip": "అవసరమైనప్పుడు అప్‌స్ట్రీమ్ మోడల్స్‌ను పొందండి మరియు కాష్ చేయండి", + "autoFetchModelsEnabled": "అప్‌స్ట్రీమ్ మోడల్ ఆటో-ఫెచ్ ప్రారంభించబడింది", + "autoFetchModelsDisabled": "అప్‌స్ట్రీమ్ మోడల్ ఆటో-ఫెచ్ నిలిపివేయబడింది", + "autoFetchModelsToggleFailed": "అప్‌స్ట్రీమ్ మోడల్ ఆటో-ఫెచ్‌ను టోగుల్ చేయడంలో విఫలమైంది", + "autoFetchModelsPartialFailure": "కొన్ని కనెక్షన్లు నవీకరించబడ్డాయి, కానీ అప్‌స్ట్రీమ్ మోడల్ ఆటో-ఫెచ్ ప్రతి చోట మారలేదు", + "overridesUpstreamModel": "అప్‌స్ట్రీమ్‌ను ఓవర్‌రైడ్ చేయండి", + "overridesUpstreamModelHint": "మీ సెట్టింగ్స్ ఈ అప్‌స్ట్రీమ్ మోడల్‌ను అధిగమిస్తాయి", + "resetToUpstreamDefaults": "అప్‌స్ట్రీమ్ డిఫాల్ట్స్‌ను పునరుద్ధరించండి", + "resetToUpstreamDefaultsSuccess": "అప్‌స్ట్రీమ్ మోడల్ డిఫాల్ట్స్ పునరుద్ధరించబడ్డాయి", + "resetToUpstreamDefaultsFailed": "అప్‌స్ట్రీమ్ మోడల్ డిఫాల్ట్స్‌ను పునరుద్ధరించడంలో విఫలమైంది", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "స్థానిక web_fetch టూల్ కాల్‌లను OmniRoute యొక్క /v1/web/fetch కి రీరైట్ చేయండి.", "interceptionLoadError": "ఇంటర్‌సెప్షన్ సెట్టింగ్‌లను లోడ్ చేయడం విఫలమైంది: {error}", "interceptionSaveError": "ఇంటర్‌సెప్షన్ సెట్టింగ్‌లను సేవ్ చేయడం విఫలమైంది: {error}", - "ccAliasSectionTitle": "Claude కోడ్‌లో ఎక్స్‌పోజ్ చేయండి (claude/…)", - "ccAliasSectionHint": "ఈ ప్రొవైడర్ యొక్క మోడళ్లను claude/<provider>/<model> మిర్రర్ ఐడీల క్రింద ప్రచారం చేయండి, కాబట్టి Claude Code యొక్క గేట్వే మోడల్ డిస్కవరీ వాటిని జాబితా చేయగలదు. డిఫాల్ట్‌గా ఆఫ్ - దీన్ని ప్రారంభించడం అన్ని క్లయింట్ల కోసం కాటలాగ్ ఎంట్రీలను రెండింతలు చేస్తుంది.", - "ccAliasProviderLevelLabel": "ప్రదాత డిఫాల్ట్", - "ccAliasModelOverridesLabel": "ప్రతి మోడల్ కోసం ఓవర్‌రైడ్స్", - "ccAliasModelOverrideAriaLabel": "{modelId} కోసం ఓవర్‌రైడ్", - "ccAliasStateInherit": "వారసత్వం", - "ccAliasStateOn": "పై", - "ccAliasStateOff": "ఆఫ్", - "ccAliasAddModelPlaceholder": "మోడల్ ఐడి (ఉదాహరణకు gpt-4o)", - "ccAliasAddModelButton": "ఓవర్‌రైడ్ జోడించండి", - "ccAliasLoadError": "డిస్కవరీ-అలియాస్ సెట్టింగ్స్ లోడ్ చేయడంలో విఫలమైంది: {error}", - "ccAliasSaveError": "discovery-alias సెట్టింగ్‌ను సేవ్ చేయడంలో విఫలమైంది: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "API కీతో Galadrielని కనెక్ట్ చేయండి.", "predibase": "$25 ఉచిత ట్రయల్ క్రెడిట్‌లు (30 రోజుల చెల్లుబాటు)", "chenzk": "chenzk.top వద్ద లైవ్ మోడల్ కేటలాగ్‌తో కూడిన OpenAI-అనుకూల గేట్‌వే.", - "freepik": "Freepik యొక్క Mystic APIతో చిత్రాలను రూపొందించండి.", + "magnific": "Freepik యొక్క Mystic APIతో చిత్రాలను రూపొందించండి.", "freetheai": "పాస్‌త్రూ మోడల్ మద్దతుతో కూడిన ఉచిత OpenAI-అనుకూల గేట్‌వే.", "g4f-gemini": "Geminiకి ఉచిత నో-కీ g4f.space రివర్స్ ప్రాక్సీ, నిమిషానికి 5 అభ్యర్థనలకు పరిమితం చేయబడింది.", "g4f-groq": "Groqకి ఉచిత నో-కీ g4f.space రివర్స్ ప్రాక్సీ, నిమిషానికి 5 అభ్యర్థనలకు పరిమితం చేయబడింది.", @@ -6209,6 +6224,7 @@ "claude": "ఇప్పటికే ఉన్న OAuth ఫ్లోతో Claude Codeని కనెక్ట్ చేయండి.", "cline": "ఇప్పటికే ఉన్న OAuth ఫ్లోతో Clineని కనెక్ట్ చేయండి.", "cursor": "ఇప్పటికే ఉన్న OAuth ఫ్లోతో Cursor IDEని కనెక్ట్ చేయండి.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "ఇప్పటికే ఉన్న OAuth ఫ్లోతో GitHub Copilotని కనెక్ట్ చేయండి.", "gitlab-duo": "ai_features + read_user స్కోప్‌లతో కూడిన OAuth అప్లికేషన్. ఈ OmniRoute ఇన్‌స్టాన్స్‌లో GITLAB_DUO_OAUTH_CLIENT_ID మరియు ఐచ్ఛికంగా GITLAB_DUO_OAUTH_CLIENT_SECRETని కాన్ఫిగర్ చేయండి.", "kilocode": "ఇప్పటికే ఉన్న OAuth ఫ్లోతో Kilo Codeని కనెక్ట్ చేయండి.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "ఓపెన్ సోర్స్ స్నేహితుడు", "cheaperInferenceSupporterTooltip": "Cheaper Inference ఓపెన్ సోర్స్ స్నేహితురాలిగా OmniRoute కు మద్దతు ఇస్తోంది", "kimiPartnerLinkNote": "భాగస్వామి లింక్ — మీకు ఎటువంటి అదనపు ఖర్చు లేకుండా OmniRouteకు మద్దతు ఇస్తుంది", + "codexQuotaPools": "Codex కోటా పూల్‌లు", + "codexPoolAvailable": "అందుబాటులో ఉంది", + "codexPoolPartiallyLimited": "పాక్షికంగా పరిమితం", + "codexPoolFullyLimited": "పూర్తిగా పరిమితం", + "codexPoolLimited": "{count} పరిమితం", + "codexPoolQuotaExhausted": "కోటా అయిపోయింది", + "codexPoolCoolingDown": "నిరీక్షణ వ్యవధిలో ఉంది", + "codexPoolUsed": "ఉపయోగించబడింది", + "codexPoolUntil": "{value} వరకు", "anonymousFallbackTitle": "అనామక ఫాల్బ్యాక్", "anonymousFallbackDesc": "అన్ని కాన్ఫిగర్ చేసిన కనెక్షన్లు ముగిసినప్పుడు (కోటా, క్రెడిట్స్, లేదా కాలం ముగిసినప్పుడు), తాత్కాలికంగా ఈ ప్రొవైడర్ యొక్క కీ లెస్ టియర్‌ను ఉపయోగించండి. అనామక అభ్యర్థనలను పంపించకుండా ఈ ప్రొవైడర్‌ను దాటించడానికి ఆపివేయండి — కీ లెస్ టియర్ వాటిని తిరస్కరించినప్పుడు (401) సిఫారసు చేయబడింది.", "anonymousFallbackEnabled": "{provider} కోసం అనామక ఫాల్బ్యాక్ ప్రారంభించబడింది", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "సేవ్ చేసిన మోడల్ ఎండ్‌పాయింట్ సెట్టింగ్స్", "searchByModelAria": "మోడల్ ద్వారా శోధించండి", "selectSupportedEndpoint": "కమిషన్ చేయబడిన కనెక్ట్ చేయబడిన ఎండ్‌పాయింట్‌లలో కనీసం ఒకటి ఎంచుకోండి", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsTooltip": "అవసరమైనప్పుడు అప్‌స్ట్రీమ్ మోడల్స్‌ను పొందండి మరియు కాష్ చేయండి", - "autoFetchModelsEnabled": "అప్‌స్ట్రీమ్ మోడల్ ఆటో-ఫెచ్ ప్రారంభించబడింది", - "autoFetchModels": "ఆటో-ఫెచ్ అప్‌స్ట్రీమ్ మోడల్స్", - "autoFetchModelsDisabled": "అప్‌స్ట్రీమ్ మోడల్ ఆటో-ఫెచ్ నిలిపివేయబడింది", - "overridesUpstreamModelHint": "మీ సెట్టింగ్స్ ఈ అప్‌స్ట్రీమ్ మోడల్‌ను అధిగమిస్తాయి", - "overridesUpstreamModel": "అప్‌స్ట్రీమ్‌ను ఓవర్‌రైడ్ చేయండి", - "autoFetchModelsToggleFailed": "అప్‌స్ట్రీమ్ మోడల్ ఆటో-ఫెచ్‌ను టోగుల్ చేయడంలో విఫలమైంది", - "autoFetchModelsPartialFailure": "కొన్ని కనెక్షన్లు నవీకరించబడ్డాయి, కానీ అప్‌స్ట్రీమ్ మోడల్ ఆటో-ఫెచ్ ప్రతి చోట మారలేదు", - "resetToUpstreamDefaultsSuccess": "అప్‌స్ట్రీమ్ మోడల్ డిఫాల్ట్స్ పునరుద్ధరించబడ్డాయి", - "resetToUpstreamDefaults": "అప్‌స్ట్రీమ్ డిఫాల్ట్స్‌ను పునరుద్ధరించండి", - "resetToUpstreamDefaultsFailed": "అప్‌స్ట్రీమ్ మోడల్ డిఫాల్ట్స్‌ను పునరుద్ధరించడంలో విఫలమైంది" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Settings", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "నిషేధించబడిన కీలకపదాలు", "customBannedSignalsDesc": "శాశ్వత ఖాతా నిషేధ గుర్తింపును ప్రేరేపించే అదనపు కీలకపదాలు. అంతర్నిర్మిత కీలకపదాలు ఎల్లప్పుడూ వర్తిస్తాయి.", "customBannedSignalsPlaceholder": "ఉదా. api key revoked", @@ -6724,7 +6738,7 @@ "global": "గ్లోబల్", "rule": "నియమం", "enabled": "ప్రారంభించబడింది", - "disabled": "వికలాంగుడు", + "disabled": "నిలిపివేయబడింది", "nodeCount": "నోడ్స్: {count}", "needsCoreCount": "{count}కి లోకల్ కోర్ అవసరం", "lastSynced": "చివరిగా సమకాలీకరించబడినది: {time}", @@ -7189,6 +7203,7 @@ "configured": "కాన్ఫిగర్ చేయబడింది", "none": "ఏదీ లేదు", "modelOverrideValuePlaceholder": "సంఖ్యా విలువ", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "కీ విలువను జోడించండి", "noModelOverrides": "ఈ మోడల్ కోసం ఎటువంటి ఓవర్‌రైడ్‌లు కాన్ఫిగర్ చేయబడలేదు.", "modelOverrideLoadFailed": "మోడల్ ఓవర్‌రైడ్‌లను లోడ్ చేయడం విఫలమైంది", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "సంక్షిప్త CJK (文言)", "description": "క్లాసికల్-చైనీస్ అల్ట్రా-సంక్షిప్త శైలి (చైనీస్ కోసం మాత్రమే అందుబాటులో ఉంది)." @@ -7919,7 +7938,7 @@ "triggerLabel": "ట్రిగ్గర్", "effectLabel": "ప్రభావం", "statusEnabled": "ప్రారంభించబడింది", - "statusDisabled": "వికలాంగుడు", + "statusDisabled": "నిలిపివేయబడింది", "resilienceRequestQueueScope": "ప్రతి అభ్యర్థన క్యూ", "resilienceRequestQueueTrigger": "అప్‌స్ట్రీమ్‌కు పంపే ముందు", "resilienceRequestQueueEffect": "క్యూల అభ్యర్థనలు, సమ్మతిని పరిమితం చేస్తుంది మరియు కాల్‌లను ఖాళీ చేస్తుంది", @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "రౌండ్-రాబిన్ మరియు యాదృచ్ఛిక కాంబోలు మొదటి-సందేశం హ్యాష్ ద్వారా మొత్తం సంభాషణను ఒకే కనెక్షన్‌కు పిన్ చేయడానికి బదులుగా ప్రతి అభ్యర్థనపై వేరే కనెక్షన్‌కు మారుతాయి. మల్టీ-టర్న్ చాట్‌ల కోసం ప్రాంప్ట్-క్యాచీ హిట్‌లను అలాగే ఉంచడానికి దీనిని నిలిపివేయండి. ప్రతి కాంబో ఓవర్‌రైడ్‌లు ప్రాధాన్యతను కలిగి ఉంటాయి.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "క్రెడెన్షియల్ రెడాక్షన్", "credentialRedactionDesc": "ప్రొవైడర్‌లకు పంపిన సందర్భం నుండి మరియు ప్రతిస్పందనల నుండి API కీలు, టోకెన్‌లు మరియు రహస్యాలను తొలగించండి.", "enableCredentialRedaction": "క్రెడెన్షియల్ రెడాక్షన్‌ను ప్రారంభించండి", @@ -8600,6 +8623,27 @@ }, "enableTitle": "ఇంజిన్‌ను ఎనేబుల్ చేయండి", "enableDescription": "స్టాక్‌లో చివరిగా రన్ అవుతుంది (RTK/Caveman వచనాన్ని క్లీన్ చేసిన తర్వాత, OmniGlyph మిగిలిన భాగాన్ని చిత్రాలుగా మారుస్తుంది) మరియు omniglyph మోడ్ ద్వారా స్వతంత్రంగా కూడా రన్ అవుతుంది. ఇది ప్రివ్యూ మరియు ఎండ్-టు-ఎండ్ ధ్రువీకరణ పూర్తయ్యే వరకు డిఫాల్ట్‌గా ఆఫ్‌లోనే ఉంటుంది.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "సేవ్ చేయబడింది.", "saveFailed": "సేవ్ చేయడం సాధ్యపడలేదు.", "enableAria": "OmniGlyph ఇంజిన్‌ను ఎనేబుల్ చేయండి", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "గరిష్టం", "grokAutoTopUpMonth": "మాసం", "grokAdditionalCredits": "అదనపు క్రెడిట్స్", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "మొదటి టోకెన్", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 95b09b243b..4b004659a9 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "ไทม์ไลน์คำขอภาพ", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "เปิด", "close": "ปิด" }, - "noResults": "ไม่มีผลลัพธ์", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "ไม่มีผลลัพธ์" }, "webhooks": { "title": "เว็บฮุค", @@ -1739,8 +1739,8 @@ "quotaShare": "การแชร์โควตา", "discovery": "การค้นพบ", "freeProviderRankings": "อันดับผู้ให้บริการฟรี", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "แพ็กเกจฟรี", "gamification": "เกมมิฟิเคชัน", "leaderboard": "กระดานผู้นำ", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "ผู้ให้บริการรายนี้เลิกใช้แล้ว", "riskNotice": { "title": "ก่อนดำเนินการต่อ", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "ผู้ให้บริการที่มีข้อควรระวังในการใช้งาน — คลิกเพื่อดูรายละเอียด", "oauth": "ผู้ให้บริการรายนี้ใช้เซสชันผลิตภัณฑ์อย่างเป็นทางการ/OAuth ของคุณ ซึ่งไม่ได้รับอนุญาตให้ใช้กับพร็อกซี/เราเตอร์ เราไม่แนะนำให้ใช้งานเอเจนต์อัตโนมัติอย่างหนักหน่วง (เช่น สไตล์ OpenCloud, โฟลว์หลายขั้นตอนที่ยาวนาน, การประมวลผลแบบกลุ่มขนาดใหญ่) — ต้นทางอาจตอบสนองโดยการจำกัดหรือแบนบัญชี ใช้งานโดยยอมรับความเสี่ยงด้วยตนเอง", "webCookie": "ผู้ให้บริการรายนี้ยืนยันตัวตนผ่านคุกกี้เซสชันเว็บของคุณ บริการต้นทางอาจทำให้เซสชันหมดอายุเมื่อใดก็ได้ ซึ่งจะทำให้คุณต้องเข้าสู่ระบบใหม่อีกครั้ง ไม่แนะนำสำหรับการทำงานระยะยาวที่ไม่มีการเฝ้าดูแล ใช้งานโดยยอมรับความเสี่ยงด้วยตนเอง", @@ -5107,9 +5111,9 @@ "cancel": "ยกเลิก" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "ปิดการใช้งาน", "enableProvider": "เปิดใช้งานผู้ให้บริการ", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "ข้าม {count} โมเดลที่มีอยู่", "autoSync": "ซิงค์อัตโนมัติ", "autoSyncShort": "ซิงค์", + "autoFetchModels": "ดึงโมเดลจาก upstream อัตโนมัติ", + "autoFetchModelsTooltip": "ดึงและเก็บโมเดลจากต้นทางเมื่อจำเป็น", + "autoFetchModelsEnabled": "เปิดใช้งานการดึงข้อมูลโมเดลจากต้นทางอัตโนมัติ", + "autoFetchModelsDisabled": "การดึงข้อมูลโมเดลจากต้นทางถูกปิดใช้งาน", + "autoFetchModelsToggleFailed": "ไม่สามารถเปลี่ยนการดึงข้อมูลอัตโนมัติของโมเดล upstream ได้", + "autoFetchModelsPartialFailure": "การเชื่อมต่อบางรายการได้รับการอัปเดต แต่การดึงข้อมูลโมเดลต้นน้ำอัตโนมัติไม่ได้เปลี่ยนแปลงในทุกที่", + "overridesUpstreamModel": "เขียนทับต้นทาง", + "overridesUpstreamModelHint": "การตั้งค่าของคุณจะมีผลเหนือโมเดลต้นทางนี้", + "resetToUpstreamDefaults": "คืนค่าการตั้งค่าเริ่มต้นของ upstream", + "resetToUpstreamDefaultsSuccess": "กู้คืนค่าเริ่มต้นของโมเดลต้นทาง", + "resetToUpstreamDefaultsFailed": "ไม่สามารถกู้คืนค่าเริ่มต้นของโมเดลต้นน้ำได้", "autoSyncTooltip": "รีเฟรชรายการโมเดลโดยอัตโนมัติทุกๆ 24 ชั่วโมง (กำหนดค่าได้ผ่าน MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "เปิดใช้งานการซิงค์อัตโนมัติ — โมเดลจะรีเฟรชเป็นระยะ", "autoSyncDisabled": "ปิดใช้งานการซิงค์อัตโนมัติแล้ว", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "เขียนทับการเรียกใช้เครื่องมือ web_fetch ดั้งเดิมไปยัง /v1/web/fetch ของ OmniRoute", "interceptionLoadError": "โหลดการตั้งค่าการสกัดกั้นล้มเหลว: {error}", "interceptionSaveError": "บันทึกการตั้งค่าการสกัดกั้นล้มเหลว: {error}", - "ccAliasSectionTitle": "เปิดเผยใน Claude Code (claude/…)", - "ccAliasSectionHint": "โฆษณาโมเดลของผู้ให้บริการนี้ภายใต้ claude/<provider>/<model> mirror ids เพื่อให้การค้นหาโมเดลของ Claude Code สามารถแสดงรายการได้ ปิดโดยค่าเริ่มต้น — การเปิดใช้งานนี้จะทำให้รายการในแคตตาล็อกเพิ่มเป็นสองเท่าสำหรับลูกค้าทุกคน.", - "ccAliasProviderLevelLabel": "ผู้ให้บริการเริ่มต้น", - "ccAliasModelOverridesLabel": "การเขียนทับต่อโมเดลแต่ละตัว", - "ccAliasModelOverrideAriaLabel": "การแทนที่สำหรับ {modelId}", - "ccAliasStateInherit": "สืบทอด", - "ccAliasStateOn": "เปิด", - "ccAliasStateOff": "ปิด", - "ccAliasAddModelPlaceholder": "รหัสโมเดล (เช่น gpt-4o)", - "ccAliasAddModelButton": "เพิ่มการเขียนทับ", - "ccAliasLoadError": "ไม่สามารถโหลดการตั้งค่า discovery-alias ได้: {error}", - "ccAliasSaveError": "ไม่สามารถบันทึกการตั้งค่า discovery-alias ได้: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "เชื่อมต่อ Galadriel ด้วยคีย์ API", "predibase": "เครดิตทดลองใช้ฟรี $25 (มีอายุ 30 วัน)", "chenzk": "เกตเวย์ที่เข้ากันได้กับ OpenAI พร้อมแคตตาล็อกโมเดลแบบสดที่ chenzk.top", - "freepik": "สร้างรูปภาพด้วย Mystic API ของ Freepik", + "magnific": "สร้างรูปภาพด้วย Mystic API ของ Freepik", "freetheai": "เกตเวย์ฟรีที่เข้ากันได้กับ OpenAI พร้อมการรองรับโมเดลแบบ passthrough", "g4f-gemini": "รีเวิร์สพร็อกซี g4f.space ฟรีแบบไม่ต้องใช้คีย์ไปยัง Gemini จำกัด 5 คำขอต่อนาที", "g4f-groq": "รีเวิร์สพร็อกซี g4f.space ฟรีแบบไม่ต้องใช้คีย์ไปยัง Groq จำกัด 5 คำขอต่อนาที", @@ -6209,6 +6224,7 @@ "claude": "เชื่อมต่อ Claude Code ด้วยโฟลว์ OAuth ที่มีอยู่", "cline": "เชื่อมต่อ Cline ด้วยโฟลว์ OAuth ที่มีอยู่", "cursor": "เชื่อมต่อ Cursor IDE ด้วยโฟลว์ OAuth ที่มีอยู่", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "เชื่อมต่อ GitHub Copilot ด้วยโฟลว์ OAuth ที่มีอยู่", "gitlab-duo": "แอปพลิเคชัน OAuth ที่มีขอบเขต ai_features + read_user กำหนดค่า GITLAB_DUO_OAUTH_CLIENT_ID และ GITLAB_DUO_OAUTH_CLIENT_SECRET (ไม่บังคับ) บนอินสแตนซ์ OmniRoute นี้", "kilocode": "เชื่อมต่อ Kilo Code ด้วยโฟลว์ OAuth ที่มีอยู่", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "เพื่อนโอเพนซอร์ส", "cheaperInferenceSupporterTooltip": "Cheaper Inference สนับสนุน OmniRoute ในฐานะเพื่อนโอเพนซอร์ส", "kimiPartnerLinkNote": "ลิงก์พันธมิตร — สนับสนุน OmniRoute โดยไม่มีค่าใช้จ่ายเพิ่มเติมสำหรับคุณ", + "codexQuotaPools": "พูลโควตา Codex", + "codexPoolAvailable": "พร้อมใช้งาน", + "codexPoolPartiallyLimited": "ถูกจำกัดบางส่วน", + "codexPoolFullyLimited": "ถูกจำกัดทั้งหมด", + "codexPoolLimited": "ถูกจำกัด {count} รายการ", + "codexPoolQuotaExhausted": "โควตาหมดแล้ว", + "codexPoolCoolingDown": "อยู่ในช่วงพัก", + "codexPoolUsed": "ใช้แล้ว", + "codexPoolUntil": "จนถึง {value}", "anonymousFallbackTitle": "การสำรองข้อมูลแบบไม่ระบุชื่อ", "anonymousFallbackDesc": "เมื่อการเชื่อมต่อที่กำหนดทั้งหมดหมดลง (โควตา, เครดิต, หรือหมดอายุ) ให้ใช้ชั้นที่ไม่มีคีย์ของผู้ให้บริการนี้ชั่วคราว ปิดเพื่อข้ามผู้ให้บริการนี้แทนที่จะส่งคำขอแบบไม่ระบุชื่อ — แนะนำเมื่อชั้นที่ไม่มีคีย์ปฏิเสธคำขอเหล่านั้น (401).", "anonymousFallbackEnabled": "เปิดใช้งานการสำรองข้อมูลแบบไม่ระบุชื่อสำหรับ {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "การตั้งค่า endpoint ของโมเดลที่บันทึกไว้", "searchByModelAria": "ค้นหาตามรุ่น", "selectSupportedEndpoint": "เลือกจุดสิ้นสุดที่รองรับอย่างน้อยหนึ่งจุด", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsTooltip": "ดึงและเก็บโมเดลจากต้นทางเมื่อจำเป็น", - "autoFetchModels": "ดึงโมเดลจาก upstream อัตโนมัติ", - "autoFetchModelsEnabled": "เปิดใช้งานการดึงข้อมูลโมเดลจากต้นทางอัตโนมัติ", - "autoFetchModelsDisabled": "การดึงข้อมูลโมเดลจากต้นทางถูกปิดใช้งาน", - "overridesUpstreamModel": "เขียนทับต้นทาง", - "overridesUpstreamModelHint": "การตั้งค่าของคุณจะมีผลเหนือโมเดลต้นทางนี้", - "autoFetchModelsToggleFailed": "ไม่สามารถเปลี่ยนการดึงข้อมูลอัตโนมัติของโมเดล upstream ได้", - "autoFetchModelsPartialFailure": "การเชื่อมต่อบางรายการได้รับการอัปเดต แต่การดึงข้อมูลโมเดลต้นน้ำอัตโนมัติไม่ได้เปลี่ยนแปลงในทุกที่", - "resetToUpstreamDefaults": "คืนค่าการตั้งค่าเริ่มต้นของ upstream", - "resetToUpstreamDefaultsSuccess": "กู้คืนค่าเริ่มต้นของโมเดลต้นทาง", - "resetToUpstreamDefaultsFailed": "ไม่สามารถกู้คืนค่าเริ่มต้นของโมเดลต้นน้ำได้" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "การตั้งค่า", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "คีย์เวิร์ดที่ถูกแบน", "customBannedSignalsDesc": "คีย์เวิร์ดเพิ่มเติมที่ทริกเกอร์การตรวจจับการแบนบัญชีถาวร คีย์เวิร์ดในตัวจะมีผลเสมอ", "customBannedSignalsPlaceholder": "เช่น api key revoked", @@ -7189,6 +7203,7 @@ "configured": "กำหนดค่าแล้ว", "none": "ไม่มี", "modelOverrideValuePlaceholder": "ค่าตัวเลข", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "เพิ่มคีย์-ค่า", "noModelOverrides": "ไม่มีการกำหนดค่าการเขียนทับสำหรับโมเดลนี้", "modelOverrideLoadFailed": "โหลดการเขียนทับโมเดลล้มเหลว", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "CJK แบบกระชับ (文言)", "description": "สไตล์ภาษาจีนคลาสสิกแบบกระชับอย่างยิ่ง (ใช้ได้เฉพาะภาษาจีนเท่านั้น)" @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "คอมโบแบบ Round-robin และแบบสุ่มจะสลับไปยังการเชื่อมต่ออื่นในทุกคำขอ แทนที่จะตรึงการสนทนาทั้งหมดไว้กับการเชื่อมต่อเดียวตามแฮชของข้อความแรก ปิดไว้เพื่อรักษา prompt-cache hits สำหรับการแชทแบบหลายรอบ การแทนที่ระดับคอมโบจะมีผลเหนือกว่า", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "การปกปิดข้อมูลรับรอง", "credentialRedactionDesc": "ปกปิดคีย์ API, โทเค็น และข้อมูลลับจากบริบทที่ส่งไปยังผู้ให้บริการและจากการตอบกลับ", "enableCredentialRedaction": "เปิดใช้งานการปกปิดข้อมูลรับรอง", @@ -8600,6 +8623,27 @@ }, "enableTitle": "เปิดใช้งานเอนจิน", "enableDescription": "ทำงานเป็นลำดับสุดท้ายในสแตก (หลังจาก RTK/Caveman คลีนข้อความ และ OmniGlyph แปลงส่วนที่เหลือเป็นรูปภาพ) และยังทำงานแบบสแตนด์อโลนผ่านโหมด omniglyph นี่เป็นเวอร์ชันพรีวิวและจะยังคงปิดใช้งานไว้เป็นค่าเริ่มต้นจนกว่าการตรวจสอบความถูกต้องแบบครบวงจร (end-to-end) จะเสร็จสิ้น", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "บันทึกแล้ว", "saveFailed": "ไม่สามารถบันทึกได้", "enableAria": "เปิดใช้งานเอนจิน OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "สูงสุด", "grokAutoTopUpMonth": "เดือน", "grokAdditionalCredits": "เครดิตเพิ่มเติม", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "คนตัดไม้", "proxyTab": "หนังสือมอบฉันทะ", "budgetManagement": "การจัดการงบประมาณ", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "โทเค็นแรก", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 4b0086bc69..abd8bfc297 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Görsel istek zaman çizelgesi", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "açık", "close": "kapat" }, - "noResults": "Sonuç yok", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Sonuç yok" }, "webhooks": { "title": "Web kancaları", @@ -1739,8 +1739,8 @@ "quotaShare": "Kota Payı", "discovery": "Keşif", "freeProviderRankings": "Ücretsiz Sağlayıcı Sıralamaları", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Ücretsiz Katmanlar", "gamification": "Oyunlaştırma", "leaderboard": "Liderlik Tablosu", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Bu sağlayıcı kullanımdan kaldırıldı", "riskNotice": { "title": "Devam etmeden önce", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Kullanım uyarıları olan sağlayıcı — ayrıntılar için tıklayın", "oauth": "Bu sağlayıcı, proxy/yönlendirici kullanımı için yetkilendirilmemiş resmi ürün oturumunuzu/OAuth'unuzu kullanır. Yoğun otonom ajan kullanımını (OpenCloud tarzı, uzun çok adımlı akışlar, büyük toplu işlemler) önermiyoruz — üst sağlayıcı hesabı kısıtlayarak veya yasaklayarak tepki verebilir. Kullanım riski size aittir.", "webCookie": "Bu sağlayıcı, web oturumu çerezleriniz aracılığıyla kimlik doğrulaması yapar. Üst servis oturumu istediği zaman geçersiz kılabilir ve tekrar giriş yapmanızı gerektirebilir. Uzun süreli gözetimsiz işlemler için önerilmez. Kullanım riski size aittir.", @@ -5107,9 +5111,9 @@ "cancel": "İptal" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Devre dışı", "enableProvider": "Sağlayıcıyı etkinleştir", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "{count} mevcut model atlanıyor", "autoSync": "Otomatik Senkronizasyon", "autoSyncShort": "Senkronize Et", + "autoFetchModels": "Otomatik olarak üst akış modellerini al", + "autoFetchModelsTooltip": "Gerekli olduğunda yukarı akış modellerini al ve önbelleğe al", + "autoFetchModelsEnabled": "Üst akış modeli otomatik alma etkinleştirildi", + "autoFetchModelsDisabled": "Üst akış modeli otomatik alma devre dışı bırakıldı", + "autoFetchModelsToggleFailed": "Yukarı akış model otomatik alımını değiştirme başarısız oldu", + "autoFetchModelsPartialFailure": "Bazı bağlantılar güncellendi, ancak yukarı akış modeli otomatik alımı her yerde değişmedi.", + "overridesUpstreamModel": "Üst akışı geçersiz kılar", + "overridesUpstreamModelHint": "Ayarlarınız bu üst modelin üzerine yazıyor.", + "resetToUpstreamDefaults": "Varsayılan ayarları geri yükle", + "resetToUpstreamDefaultsSuccess": "Yukarı akış model varsayılanları geri yüklendi", + "resetToUpstreamDefaultsFailed": "Üst akış model varsayılanlarını geri yükleme başarısız oldu", "autoSyncTooltip": "Model listesini her 24 saatte bir otomatik olarak yenileyin (MODEL_SYNC_INTERVAL_HOURS aracılığıyla yapılandırılabilir)", "autoSyncEnabled": "Otomatik senkronizasyon etkin — modeller periyodik olarak yenilenecek", "autoSyncDisabled": "Otomatik senkronizasyon devre dışı bırakıldı", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Yerel web_fetch araç çağrılarını OmniRoute'un /v1/web/fetch uç noktasına yeniden yazar.", "interceptionLoadError": "Yakalama ayarları yüklenemedi: {error}", "interceptionSaveError": "Yakalama ayarları kaydedilemedi: {error}", - "ccAliasSectionTitle": "Claude Kodu'nda Açığa Çıkar (claude/…)", - "ccAliasSectionHint": "Bu sağlayıcının modellerini claude/<provider>/<model> ayna kimlikleri altında tanıtın, böylece Claude Code'un geçiş modeli keşfi bunları listeleyebilir. Varsayılan olarak kapalı — bunu etkinleştirmek, tüm müşteriler için katalog girişlerini iki katına çıkarır.", - "ccAliasProviderLevelLabel": "Sağlayıcı varsayılan", - "ccAliasModelOverridesLabel": "Model başına geçersiz kılmalar", - "ccAliasModelOverrideAriaLabel": "{modelId} için geçersiz kılma", - "ccAliasStateInherit": "Devralmak", - "ccAliasStateOn": "Açık", - "ccAliasStateOff": "Kapalı", - "ccAliasAddModelPlaceholder": "Model kimliği (ör. gpt-4o)", - "ccAliasAddModelButton": "Override ekle", - "ccAliasLoadError": "Keşif-alias ayarlarını yüklemek başarısız oldu: {error}", - "ccAliasSaveError": "discovery-alias ayarını kaydetme başarısız oldu: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Ek yukarı akış başlıkları", "compatUpstreamHeadersHint": "Yüksek ayrıcalıklı ayar: sağlayıcı API kimlik bilgilerini düzenlemekle aynı güven düzeyine sahiptir; yalnızca güvenilir yöneticiler kullanmalıdır. OmniRoute, sağlayıcı API anahtarından kimlik doğrulama başlığını ekledikten sonra bu başlıklar birleştirilir. Özel bir başlık mevcut bir başlıkla aynı adı kullanıyorsa (ör. Authorization), verdiğiniz değer otomatik oluşturulan başlığın (Bearer token dahil) tamamının yerini alır; yukarı akış yalnızca sizin yazdığınız değeri görür. Hatalı yapılandırma 401 hatalarına veya bozuk yukarı akış kimlik doğrulamasına yol açabilir. Her başlık için bir satır kullanın (ör. bazı geçitler için ek Authentication başlığı). Önizlemek için değerin üzerine gelin veya odaklayın. Bu panelde bulanıklaştırma, dışarı tıklama veya paneli kapatma sırasında otomatik kaydedilir.", "compatUpstreamHeaderName": "Başlık adı", @@ -6194,7 +6209,7 @@ "galadriel": "Galadriel'i bir API anahtarı ile bağlayın.", "predibase": "25$ ücretsiz deneme kredisi (30 gün geçerli)", "chenzk": "chenzk.top adresinde canlı model kataloğuna sahip OpenAI uyumlu ağ geçidi.", - "freepik": "Freepik'in Mystic API'si ile görseller oluşturun.", + "magnific": "Freepik'in Mystic API'si ile görseller oluşturun.", "freetheai": "Doğrudan geçişli (passthrough) model destekli, ücretsiz OpenAI uyumlu ağ geçidi.", "g4f-gemini": "Gemini için anahtarsız, ücretsiz g4f.space ters proxy'si, dakikada 5 istek ile sınırlıdır.", "g4f-groq": "Groq için anahtarsız, ücretsiz g4f.space ters proxy'si, dakikada 5 istek ile sınırlıdır.", @@ -6209,6 +6224,7 @@ "claude": "Mevcut OAuth akışıyla Claude Code'u bağlayın.", "cline": "Mevcut OAuth akışıyla Cline'ı bağlayın.", "cursor": "Mevcut OAuth akışıyla Cursor IDE'yi bağlayın.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Mevcut OAuth akışıyla GitHub Copilot'ı bağlayın.", "gitlab-duo": "ai_features + read_user kapsamlarına sahip OAuth uygulaması. Bu OmniRoute örneğinde GITLAB_DUO_OAUTH_CLIENT_ID ve isteğe bağlı olarak GITLAB_DUO_OAUTH_CLIENT_SECRET yapılandırın.", "kilocode": "Mevcut OAuth akışıyla Kilo Code'u bağlayın.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Açık Kaynak Dostu", "cheaperInferenceSupporterTooltip": "Cheaper Inference, OmniRoute'u Açık Kaynak Dostu olarak destekliyor", "kimiPartnerLinkNote": "Ortaklık bağlantısı — size hiçbir ek ücret ödetmeden OmniRoute'u destekler", + "codexQuotaPools": "Codex kota havuzları", + "codexPoolAvailable": "Kullanılabilir", + "codexPoolPartiallyLimited": "Kısmen sınırlı", + "codexPoolFullyLimited": "Tamamen sınırlı", + "codexPoolLimited": "{count} sınırlı", + "codexPoolQuotaExhausted": "Kota tükendi", + "codexPoolCoolingDown": "Bekleme süresinde", + "codexPoolUsed": "kullanıldı", + "codexPoolUntil": "{value} tarihine kadar", "anonymousFallbackTitle": "Anonim yedekleme", "anonymousFallbackDesc": "Tüm yapılandırılmış bağlantılar tükendiğinde (kota, kredi veya süresi dolmuş), bu sağlayıcının anahtarsız katmanını geçici olarak kullanın. Anahtarsız katmanın bunları reddettiği (401) durumlarda, anonim istek göndermek yerine bu sağlayıcıyı atlamak için kapatın — önerilir.", "anonymousFallbackEnabled": "{provider} için anonim geri dönüş etkinleştirildi", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Kaydedilmiş model uç noktası ayarları", "searchByModelAria": "Model ile ara", "selectSupportedEndpoint": "En az bir desteklenen uç noktayı seçin", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsDisabled": "Üst akış modeli otomatik alma devre dışı bırakıldı", - "autoFetchModels": "Otomatik olarak üst akış modellerini al", - "autoFetchModelsTooltip": "Gerekli olduğunda yukarı akış modellerini al ve önbelleğe al", - "autoFetchModelsEnabled": "Üst akış modeli otomatik alma etkinleştirildi", - "autoFetchModelsToggleFailed": "Yukarı akış model otomatik alımını değiştirme başarısız oldu", - "autoFetchModelsPartialFailure": "Bazı bağlantılar güncellendi, ancak yukarı akış modeli otomatik alımı her yerde değişmedi.", - "overridesUpstreamModel": "Üst akışı geçersiz kılar", - "overridesUpstreamModelHint": "Ayarlarınız bu üst modelin üzerine yazıyor.", - "resetToUpstreamDefaults": "Varsayılan ayarları geri yükle", - "resetToUpstreamDefaultsSuccess": "Yukarı akış model varsayılanları geri yüklendi", - "resetToUpstreamDefaultsFailed": "Üst akış model varsayılanlarını geri yükleme başarısız oldu" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Ayarlar", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Yasaklı Anahtar Kelimeler", "customBannedSignalsDesc": "Kalıcı hesap engelleme algılamasını tetikleyen ek anahtar kelimeler. Yerleşik anahtar kelimeler her zaman geçerlidir.", "customBannedSignalsPlaceholder": "örn. api key revoked", @@ -7189,6 +7203,7 @@ "configured": "yapılandırıldı", "none": "Yok", "modelOverrideValuePlaceholder": "Sayısal değer", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Anahtar değer ekle", "noModelOverrides": "Bu model için yapılandırılmış geçersiz kılma yok.", "modelOverrideLoadFailed": "Model geçersiz kılmaları yüklenemedi", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Kısa ve öz CJK (文言)", "description": "Klasik Çince ultra kısa ve öz stil (yalnızca Çince için kullanılabilir)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Round-robin ve rastgele kombinasyonlar, tüm bir konuşmayı ilk mesaj karmasıyla tek bir bağlantıya sabitlemek yerine her istekte farklı bir bağlantıya döner. Çok turlu sohbetlerde prompt-cache isabetlerini korumak için kapalı bırakın. Kombinasyon bazlı geçersiz kılmalar önceliklidir.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Kimlik Bilgisi Karartma", "credentialRedactionDesc": "Sağlayıcılara gönderilen bağlamdan ve yanıtlardan API anahtarlarını, belirteçleri ve sırları karartın.", "enableCredentialRedaction": "Kimlik bilgisi karartmayı etkinleştir", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Motoru etkinleştir", "enableDescription": "Yığında en son çalışır (RTK/Caveman metni temizledikten sonra OmniGlyph geri kalanını görüntülere dönüştürür) ve ayrıca omniglyph modu aracılığıyla bağımsız olarak çalışır. Bu bir önizlemedir ve uçtan uca doğrulama tamamlanana kadar varsayılan olarak kapalı kalır.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Kaydedildi.", "saveFailed": "Kaydedilemedi.", "enableAria": "OmniGlyph motorunu etkinleştir", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "maksimum", "grokAutoTopUpMonth": "ay", "grokAdditionalCredits": "Ekstra Krediler", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Günlükler", "proxyTab": "Proxy", "budgetManagement": "Bütçe Yönetimi", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "İlk Token", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 581b3a2ba2..ce9ede8d17 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Логи консолі", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Візуальний графік запитів", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Глобальна маршрутизація", "mitmProxy": "MITM-проксі", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "відкрити", "close": "закрити" }, - "noResults": "Немає результатів", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "Немає результатів" }, "webhooks": { "title": "Веб-хуки", @@ -1739,8 +1739,8 @@ "quotaShare": "Частка квоти", "discovery": "Дослідження", "freeProviderRankings": "Рейтинги безкоштовних провайдерів", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Безкоштовні тарифи", "gamification": "Гейміфікація", "leaderboard": "Таблиця лідерів", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Перевірте та збережіть", "wizardStep4Desc": "Перегляньте конфігурацію та активуйте комбо", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "Цей постачальник більше не підтримується", "riskNotice": { "title": "Перед тим, як продовжити", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "Провайдер із застереженнями щодо використання — натисніть для подробиць", "oauth": "Цей провайдер використовує вашу офіційну продуктову сесію/OAuth, які не авторизовані для використання у проксі/маршрутизаторі. Ми не рекомендуємо інтенсивне автономне використання агентами (стиль OpenCloud, довгі багатокрокові потоки, великі пакети) — провайдер може у відповідь обмежити або заблокувати акаунт. Використовуйте на власний ризик.", "webCookie": "Цей провайдер автентифікується через cookie вашої веб-сесії. Сервіс може в будь-який момент анулювати сесію, що вимагатиме повторного входу. Не рекомендовано для довгих автоматизованих операцій. Використовуйте на власний ризик.", @@ -5107,9 +5111,9 @@ "cancel": "Скасувати" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Вимкнено", "enableProvider": "Увімкнути провайдера", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Пропуск {count} наявних моделей", "autoSync": "Автоматична синхронізація", "autoSyncShort": "Синхронізувати", + "autoFetchModels": "Автоматичне отримання моделей з upstream", + "autoFetchModelsTooltip": "Отримати та кешувати моделі з upstream за потреби", + "autoFetchModelsEnabled": "Увімкнено автоматичне отримання моделі з upstream", + "autoFetchModelsDisabled": "Автоматичне отримання моделі з upstream вимкнено", + "autoFetchModelsToggleFailed": "Не вдалося перемкнути автоматичне отримання моделі upstream", + "autoFetchModelsPartialFailure": "Деякі з'єднання оновлено, але автоматичне отримання моделі з верхнього рівня не було змінено скрізь", + "overridesUpstreamModel": "Перезаписує upstream", + "overridesUpstreamModelHint": "Ваші налаштування переважають цю модель вгору за течією", + "resetToUpstreamDefaults": "Відновити значення за замовчуванням з upstream", + "resetToUpstreamDefaultsSuccess": "Відновлено значення за замовчуванням моделі з upstream", + "resetToUpstreamDefaultsFailed": "Не вдалося відновити значення за замовчуванням моделі upstream", "autoSyncTooltip": "Автоматично оновлювати список моделей кожні 24 години (налаштовується через MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Автоматична синхронізація ввімкнена — моделі періодично оновлюватимуться", "autoSyncDisabled": "Автоматична синхронізація вимкнена", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "Перенаправляти виклики нативного інструмента web_fetch на /v1/web/fetch в OmniRoute.", "interceptionLoadError": "Не вдалося завантажити налаштування перехоплення: {error}", "interceptionSaveError": "Не вдалося зберегти налаштування перехоплення: {error}", - "ccAliasSectionTitle": "Відкрити в Claude Code (claude/…)", - "ccAliasSectionHint": "Рекламуйте моделі цього постачальника під claude/<provider>/<model> mirror ids, щоб модель виявлення шлюзу Claude Code могла їх перерахувати. Вимкнено за замовчуванням — увімкнення цього подвоює записи каталогу для всіх клієнтів.", - "ccAliasProviderLevelLabel": "Постачальник за замовчуванням", - "ccAliasModelOverridesLabel": "Перемикання за моделлю", - "ccAliasModelOverrideAriaLabel": "Перезапис для {modelId}", - "ccAliasStateInherit": "Успадкувати", - "ccAliasStateOn": "Увімкнено", - "ccAliasStateOff": "Вимкнено", - "ccAliasAddModelPlaceholder": "Ідентифікатор моделі (наприклад, gpt-4o)", - "ccAliasAddModelButton": "Додати перевизначення", - "ccAliasLoadError": "Не вдалося завантажити налаштування discovery-alias: {error}", - "ccAliasSaveError": "Не вдалося зберегти налаштування discovery-alias: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Connect Galadriel with an API key.", "predibase": "$25 free trial credits (30-day validity)", "chenzk": "OpenAI-compatible gateway with a live model catalog at chenzk.top.", - "freepik": "Generate images with Freepik's Mystic API.", + "magnific": "Generate images with Freepik's Mystic API.", "freetheai": "Free OpenAI-compatible gateway with passthrough model support.", "g4f-gemini": "Free no-key g4f.space reverse proxy to Gemini, limited to 5 requests per minute.", "g4f-groq": "Free no-key g4f.space reverse proxy to Groq, limited to 5 requests per minute.", @@ -6209,6 +6224,7 @@ "claude": "Connect Claude Code with the existing OAuth flow.", "cline": "Connect Cline with the existing OAuth flow.", "cursor": "Connect Cursor IDE with the existing OAuth flow.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Connect GitHub Copilot with the existing OAuth flow.", "gitlab-duo": "OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance.", "kilocode": "Connect Kilo Code with the existing OAuth flow.", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Друг відкритого коду", "cheaperInferenceSupporterTooltip": "Cheaper Inference підтримує OmniRoute як друг відкритого коду", "kimiPartnerLinkNote": "Партнерське посилання — підтримує OmniRoute без додаткових витрат для вас", + "codexQuotaPools": "Пули квот Codex", + "codexPoolAvailable": "Доступний", + "codexPoolPartiallyLimited": "Частково обмежений", + "codexPoolFullyLimited": "Повністю обмежений", + "codexPoolLimited": "Обмежено: {count}", + "codexPoolQuotaExhausted": "Квоту вичерпано", + "codexPoolCoolingDown": "У періоді очікування", + "codexPoolUsed": "використано", + "codexPoolUntil": "До {value}", "anonymousFallbackTitle": "Анонімний резервний варіант", "anonymousFallbackDesc": "Коли всі налаштовані з'єднання вичерпані (квота, кредити або термін дії), тимчасово використовуйте безключовий рівень цього постачальника. Вимкніть, щоб пропустити цього постачальника замість надсилання анонімних запитів — рекомендовано, коли безключовий рівень їх відхиляє (401).", "anonymousFallbackEnabled": "Анонімний резервний варіант увімкнено для {provider}", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "Налаштування кінцевої точки збереженої моделі", "searchByModelAria": "Пошук за моделлю", "selectSupportedEndpoint": "Виберіть принаймні одну підтримувану точку доступу", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Автоматичне отримання моделей з upstream", - "autoFetchModelsDisabled": "Автоматичне отримання моделі з upstream вимкнено", - "autoFetchModelsTooltip": "Отримати та кешувати моделі з upstream за потреби", - "autoFetchModelsEnabled": "Увімкнено автоматичне отримання моделі з upstream", - "autoFetchModelsToggleFailed": "Не вдалося перемкнути автоматичне отримання моделі upstream", - "overridesUpstreamModel": "Перезаписує upstream", - "overridesUpstreamModelHint": "Ваші налаштування переважають цю модель вгору за течією", - "autoFetchModelsPartialFailure": "Деякі з'єднання оновлено, але автоматичне отримання моделі з верхнього рівня не було змінено скрізь", - "resetToUpstreamDefaultsSuccess": "Відновлено значення за замовчуванням моделі з upstream", - "resetToUpstreamDefaults": "Відновити значення за замовчуванням з upstream", - "resetToUpstreamDefaultsFailed": "Не вдалося відновити значення за замовчуванням моделі upstream" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Налаштування", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "Banned Keywords", "customBannedSignalsDesc": "Додаткові ключові слова, які запускають виявлення постійного блокування акаунта. Вбудовані ключові слова застосовуються завжди.", "customBannedSignalsPlaceholder": "наприклад, api key revoked", @@ -7189,6 +7203,7 @@ "configured": "налаштовано", "none": "Немає", "modelOverrideValuePlaceholder": "Числове значення", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Додати ключ-значення", "noModelOverrides": "Для цієї моделі не налаштовано перевизначень.", "modelOverrideLoadFailed": "Не вдалося завантажити перевизначення моделі", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "Стисла CJK (文言)", "description": "Класичний китайський ультрастислий стиль (доступно тільки для китайської)." @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "Комбінації Round-robin та random перемикаються на інше з'єднання при кожному запиті замість закріплення всієї розмови за одним з'єднанням за хешем першого повідомлення. Залиште вимкненим, щоб зберегти влучання в кеш підказок (prompt-cache) для багатокрокових чатів. Перевизначення для конкретних комбінацій мають пріоритет.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "Вилучення облікових даних", "credentialRedactionDesc": "Вилучати API-ключі, токени та секрети з контексту, що надсилається провайдерам, та з відповідей.", "enableCredentialRedaction": "Увімкнути вилучення облікових даних", @@ -8600,6 +8623,27 @@ }, "enableTitle": "Увімкнути рушій", "enableDescription": "Запускається останнім у стеку (після того, як RTK/Caveman очищає текст, а OmniGlyph конвертує решту в зображення), а також працює автономно в режимі omniglyph. Це попередня версія, яка залишається вимкненою за замовчуванням до завершення наскрізної перевірки.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Збережено.", "saveFailed": "Не вдалося зберегти.", "enableAria": "Увімкнути рушій OmniGlyph", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "макс", "grokAutoTopUpMonth": "місяць", "grokAdditionalCredits": "Додаткові Кредити", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Лісоруб", "proxyTab": "Проксі", "budgetManagement": "Управління бюджетом", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "Перший токен", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 091968e447..b5c237a7d5 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Console Logs", "logsTimeline": "Timeline", "logsTimelineSubtitle": "بصری درخواست کا وقت لائن", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Global Routing", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "کھولیں", "close": "بند کریں" }, - "noResults": "کوئی نتائج نہیں", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "کوئی نتائج نہیں" }, "webhooks": { "title": "ویب ہکس", @@ -1739,8 +1739,8 @@ "quotaShare": "کوٹہ شیئر", "discovery": "دریافت", "freeProviderRankings": "مفت فراہم کنندگان کی درجہ بندی", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "مفت ٹیئرز", "gamification": "گیمیفیکیشن", "leaderboard": "لیڈر بورڈ", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", "wizardStep4Desc": "Review your configuration and activate the combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "On", "emailVisibilityStateOff": "Off", "reorderHandle": "Drag to reorder", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "اس فراہم کنندہ کو فرسودہ کر دیا گیا ہے۔", "riskNotice": { "title": "جاری رکھنے سے پہلے", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "استعمال کے انتباہات والا فراہم کنندہ — تفصیلات کے لیے کلک کریں", "oauth": "یہ فراہم کنندہ آپ کے آفیشل پروڈکٹ سیشن/OAuth کا استعمال کرتا ہے، جو پراکسی/راؤٹر کے استعمال کے لیے مجاز نہیں ہے۔ ہم خود مختار ایجنٹ کے زیادہ استعمال (OpenCloud طرز، طویل کثیر مرحلہ جاتی فلو، بڑے بیچز) کی سفارش نہیں کرتے ہیں — اپ اسٹریم اکاؤنٹ کو محدود یا بین کر کے ردعمل ظاہر کر سکتا ہے۔ اپنے خطرے پر استعمال کریں۔", "webCookie": "یہ فراہم کنندہ آپ کے ویب سیشن کوکیز کے ذریعے توثیق کرتا ہے۔ اپ اسٹریم سروس کسی بھی وقت سیشن کو باطل کر سکتی ہے، جس کے لیے آپ کو دوبارہ لاگ ان کرنے کی ضرورت ہوگی۔ طویل غیر حاضر کارروائیوں کے لیے تجویز نہیں کی جاتی ہے۔ اپنے خطرے پر استعمال کریں۔", @@ -5107,9 +5111,9 @@ "cancel": "منسوخ کریں" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "Disabled", "enableProvider": "Enable provider", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "Skipping {count} existing models", "autoSync": "Auto-Sync", "autoSyncShort": "Sync", + "autoFetchModels": "خودکار طور پر اپ اسٹریم ماڈلز حاصل کریں", + "autoFetchModelsTooltip": "جب ضرورت ہو تو اوپر کے ماڈلز کو حاصل کریں اور کیش کریں", + "autoFetchModelsEnabled": "اپ اسٹریم ماڈل خودکار حاصل کرنا فعال ہے", + "autoFetchModelsDisabled": "اپ اسٹریم ماڈل خودکار حاصل کرنا غیر فعال ہے", + "autoFetchModelsToggleFailed": "اپ اسٹریم ماڈل خودکار حاصل کرنے کو تبدیل کرنے میں ناکامی", + "autoFetchModelsPartialFailure": "کچھ کنکشنز کو اپ ڈیٹ کیا گیا، لیکن اوپر کی طرف ماڈل خودکار طور پر ہر جگہ تبدیل نہیں ہوا", + "overridesUpstreamModel": "اوپر والے کو اووررائیڈ کرتا ہے", + "overridesUpstreamModelHint": "آپ کی ترتیبات اس اوپر کی ماڈل کو اووررائیڈ کرتی ہیں", + "resetToUpstreamDefaults": "اپ اسٹریم ڈیفالٹس بحال کریں", + "resetToUpstreamDefaultsSuccess": "اپ اسٹریم ماڈل کے ڈیفالٹس بحال کر دیے گئے ہیں", + "resetToUpstreamDefaultsFailed": "اپ اسٹریم ماڈل کے ڈیفالٹس کو بحال کرنے میں ناکامی", "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically", "autoSyncDisabled": "Auto-sync disabled", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "نیٹو web_fetch ٹول کالز کو OmniRoute کے /v1/web/fetch پر دوبارہ لکھیں۔", "interceptionLoadError": "انٹرسیپشن کی ترتیبات لوڈ کرنے میں ناکامی: {error}", "interceptionSaveError": "انٹرسیپشن کی ترتیبات محفوظ کرنے میں ناکامی: {error}", - "ccAliasSectionTitle": "Claude کو کوڈ میں ظاہر کریں (claude/…)", - "ccAliasSectionHint": "اس فراہم کنندہ کے ماڈلز کو claude/<provider>/<model> آئینہ شناختوں کے تحت اشتہار دیں تاکہ Claude Code کے گیٹ وے ماڈل کی دریافت انہیں درج کر سکے۔ ڈیفالٹ کے طور پر بند — اس کو فعال کرنے سے تمام کلائنٹس کے لیے کیٹلاگ کی اندراجات دوگنا ہو جاتی ہیں۔", - "ccAliasProviderLevelLabel": "فراہم کنندہ ڈیفالٹ", - "ccAliasModelOverridesLabel": "فی ماڈل اووررائیڈز", - "ccAliasModelOverrideAriaLabel": "{modelId} کے لیے اووررائیڈ", - "ccAliasStateInherit": "وراثت", - "ccAliasStateOn": "پر", - "ccAliasStateOff": "بند", - "ccAliasAddModelPlaceholder": "ماڈل آئی ڈی (جیسے gpt-4o)", - "ccAliasAddModelButton": "اووررائیڈ شامل کریں", - "ccAliasLoadError": "ڈسکوری-ایلیاس سیٹنگز لوڈ کرنے میں ناکامی: {error}", - "ccAliasSaveError": "ڈسکوری-ایلیاس سیٹنگ کو محفوظ کرنے میں ناکامی: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", @@ -6194,7 +6209,7 @@ "galadriel": "Galadriel کو ایک API کلید کے ساتھ منسلک کریں۔", "predibase": "$25 کے مفت ٹرائل کریڈٹس (30 دن کی میعاد)", "chenzk": "chenzk.top پر لائیو ماڈل کیٹلاگ کے ساتھ OpenAI سے مطابقت رکھنے والا گیٹ وے۔", - "freepik": "Freepik کے Mystic API کے ساتھ تصاویر تیار کریں۔", + "magnific": "Freepik کے Mystic API کے ساتھ تصاویر تیار کریں۔", "freetheai": "passthrough ماڈل سپورٹ کے ساتھ مفت OpenAI سے مطابقت رکھنے والا گیٹ وے۔", "g4f-gemini": "Gemini کے لیے مفت بغیر کلید والا g4f.space ریورس پراکسی، فی منٹ 5 درخواستوں تک محدود۔", "g4f-groq": "Groq کے لیے مفت بغیر کلید والا g4f.space ریورس پراکسی، فی منٹ 5 درخواستوں تک محدود۔", @@ -6209,6 +6224,7 @@ "claude": "Claude Code کو موجودہ OAuth فلو کے ساتھ منسلک کریں۔", "cline": "Cline کو موجودہ OAuth فلو کے ساتھ منسلک کریں۔", "cursor": "Cursor IDE کو موجودہ OAuth فلو کے ساتھ منسلک کریں۔", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "GitHub Copilot کو موجودہ OAuth فلو کے ساتھ منسلک کریں۔", "gitlab-duo": "ai_features + read_user اسکوپس کے ساتھ OAuth ایپلیکیشن۔ اس OmniRoute انسٹنس پر GITLAB_DUO_OAUTH_CLIENT_ID اور اختیاری طور پر GITLAB_DUO_OAUTH_CLIENT_SECRET کنفیگر کریں۔", "kilocode": "Kilo Code کو موجودہ OAuth فلو کے ساتھ منسلک کریں۔", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "اوپن سورس دوست", "cheaperInferenceSupporterTooltip": "‏Cheaper Inference اوپن سورس دوست کے طور پر OmniRoute کی حمایت کرتی ہے", "kimiPartnerLinkNote": "پارٹنر لنک — آپ کے لیے بغیر کسی اضافی قیمت کے OmniRoute کو سپورٹ کرتا ہے", + "codexQuotaPools": "Codex کوٹا پولز", + "codexPoolAvailable": "دستیاب", + "codexPoolPartiallyLimited": "جزوی طور پر محدود", + "codexPoolFullyLimited": "مکمل طور پر محدود", + "codexPoolLimited": "{count} محدود", + "codexPoolQuotaExhausted": "کوٹا ختم ہو گیا", + "codexPoolCoolingDown": "وقفۂ انتظار میں", + "codexPoolUsed": "استعمال شدہ", + "codexPoolUntil": "{value} تک", "anonymousFallbackTitle": "نامعلوم متبادل", "anonymousFallbackDesc": "جب تمام کنفیگر کردہ کنکشنز ختم ہو جائیں (کوٹہ، کریڈٹس، یا میعاد)، اس فراہم کنندہ کی بغیر کلید کی سطح کو عارضی طور پر استعمال کریں۔ اس فراہم کنندہ کو چھوڑنے کے لیے بند کریں بجائے اس کے کہ گمنام درخواستیں بھیجیں — جب بغیر کلید کی سطح انہیں مسترد کرتی ہے (401) تو یہ تجویز کردہ ہے۔", "anonymousFallbackEnabled": "{provider} کے لیے نامعلوم متبادل فعال ہے", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "محفوظ شدہ ماڈل اینڈپوائنٹ کی ترتیبات", "searchByModelAria": "ماڈل کے ذریعے تلاش کریں", "selectSupportedEndpoint": "کم از کم ایک سپورٹ کردہ اینڈپوائنٹ منتخب کریں", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModelsDisabled": "اپ اسٹریم ماڈل خودکار حاصل کرنا غیر فعال ہے", - "autoFetchModelsEnabled": "اپ اسٹریم ماڈل خودکار حاصل کرنا فعال ہے", - "autoFetchModelsTooltip": "جب ضرورت ہو تو اوپر کے ماڈلز کو حاصل کریں اور کیش کریں", - "autoFetchModels": "خودکار طور پر اپ اسٹریم ماڈلز حاصل کریں", - "autoFetchModelsToggleFailed": "اپ اسٹریم ماڈل خودکار حاصل کرنے کو تبدیل کرنے میں ناکامی", - "overridesUpstreamModel": "اوپر والے کو اووررائیڈ کرتا ہے", - "autoFetchModelsPartialFailure": "کچھ کنکشنز کو اپ ڈیٹ کیا گیا، لیکن اوپر کی طرف ماڈل خودکار طور پر ہر جگہ تبدیل نہیں ہوا", - "overridesUpstreamModelHint": "آپ کی ترتیبات اس اوپر کی ماڈل کو اووررائیڈ کرتی ہیں", - "resetToUpstreamDefaultsFailed": "اپ اسٹریم ماڈل کے ڈیفالٹس کو بحال کرنے میں ناکامی", - "resetToUpstreamDefaultsSuccess": "اپ اسٹریم ماڈل کے ڈیفالٹس بحال کر دیے گئے ہیں", - "resetToUpstreamDefaults": "اپ اسٹریم ڈیفالٹس بحال کریں" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Settings", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.", "autoDisableThreshold": "Ban Threshold", "autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "ممنوعہ الفاظ", "customBannedSignalsDesc": "اضافی الفاظ جو مستقل اکاؤنٹ پر پابندی کی شناخت کو متحرک کرتے ہیں۔ پہلے سے موجود الفاظ ہمیشہ لاگو ہوتے ہیں۔", "customBannedSignalsPlaceholder": "مثال کے طور پر api key revoked", @@ -6724,7 +6738,7 @@ "global": "عالمی", "rule": "قاعدہ", "enabled": "فعال", - "disabled": "معذور", + "disabled": "غیر فعال", "nodeCount": "نوڈس: {count}", "needsCoreCount": "{count} کو مقامی کور کی ضرورت ہے۔", "lastSynced": "آخری بار مطابقت پذیری: {time}", @@ -7189,6 +7203,7 @@ "configured": "کنفیگر شدہ", "none": "کوئی نہیں", "modelOverrideValuePlaceholder": "عددی قدر", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "کی ویلیو شامل کریں", "noModelOverrides": "اس ماڈل کے لیے کوئی اوور رائیڈز کنفیگر نہیں کیے گئے۔", "modelOverrideLoadFailed": "ماڈل اوور رائیڈز لوڈ کرنے میں ناکامی", @@ -7760,6 +7775,10 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, + "i-have-adhd": { + "label": "I have ADHD (action-first)", + "description": "Action-first output: next action leads, steps numbered, one concrete next step, no preamble." + }, "terse-cjk": { "label": "مختصر CJK (文言)", "description": "کلاسیکی چینی انتہائی مختصر انداز (صرف چینی زبان کے لیے دستیاب ہے)۔" @@ -7919,7 +7938,7 @@ "triggerLabel": "محرک", "effectLabel": "اثر", "statusEnabled": "فعال", - "statusDisabled": "معذور", + "statusDisabled": "غیر فعال", "resilienceRequestQueueScope": "فی درخواست کی قطار", "resilienceRequestQueueTrigger": "اپ اسٹریم پر بھیجنے سے پہلے", "resilienceRequestQueueEffect": "درخواستوں کو قطار میں لگاتا ہے، ہم آہنگی کو محدود کرتا ہے، اور کالوں کو ختم کرتا ہے۔", @@ -8040,6 +8059,10 @@ "disableSessionStickinessDesc": "راؤنڈ رابن اور رینڈم کمبوز پہلے پیغام کے ہیش کے ذریعے پوری گفتگو کو ایک کنکشن سے منسلک کرنے کے بجائے ہر درخواست پر ایک مختلف کنکشن پر منتقل ہو جاتے ہیں۔ ملٹی ٹرن چیٹس کے لیے پرامپٹ کیش ہٹس کو برقرار رکھنے کے لیے اسے بند رہنے دیں۔ فی کمبو اوور رائیڈز کو ترجیح حاصل ہوگی۔", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Reasoning token buffer", + "reasoningTokenBufferDesc": "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap.", + "zeroLatencyOptimizations": "Zero-latency optimizations", + "zeroLatencyOptimizationsDesc": "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests.", "credentialRedaction": "اسناد کی ریڈیکشن", "credentialRedactionDesc": "فراہم کنندگان کو بھیجے گئے سیاق و سباق اور جوابات سے API کیز، ٹوکنز اور خفیہ معلومات کو سنسر کریں۔", "enableCredentialRedaction": "اسناد کی سنسرشپ کو فعال کریں", @@ -8600,6 +8623,27 @@ }, "enableTitle": "انجن فعال کریں", "enableDescription": "اسٹیک میں سب سے آخر میں چلتا ہے (RTK/Caveman کے متن صاف کرنے کے بعد، OmniGlyph باقی ماندہ کو تصاویر میں تبدیل کرتا ہے) اور omniglyph موڈ کے ذریعے اسٹینڈ الون بھی چلتا ہے۔ یہ ایک پیش نظارہ ہے اور اینڈ ٹو اینڈ توثیق مکمل ہونے تک ڈیفالٹ طور پر بند رہتا ہے۔", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "محفوظ ہو گیا۔", "saveFailed": "محفوظ نہیں ہو سکا۔", "enableAria": "OmniGlyph انجن فعال کریں", @@ -9069,6 +9113,16 @@ "grokAutoTopUpMax": "زیادہ سے زیادہ", "grokAutoTopUpMonth": "مہینہ", "grokAdditionalCredits": "اضافی کریڈٹس", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", @@ -12467,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "پہلا ٹوکن", @@ -13192,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13732,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 16dece1256..9cf64c0d2f 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -1165,6 +1165,8 @@ "consoleLogs": "Nhật ký bảng điều khiển", "logsTimeline": "Timeline", "logsTimelineSubtitle": "Dòng thời gian yêu cầu trực quan", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "Định tuyến toàn cục", "mitmProxy": "MITM Proxy", "oneProxy": "1Proxy", @@ -1282,8 +1284,6 @@ "resilienceConnectionsSubtitle": "Cooldown, cầu dao, trạng thái khóa", "settingsModalityBridge": "Cầu Kết Nối Modality", "settingsModalityBridgeSubtitle": "Chuyển đổi hình ảnh/âm thanh → văn bản cho các mô hình chỉ văn bản", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations", "commandPalette": { "title": "Bảng lệnh", "searchPlaceholder": "Tìm kiếm trang, cài đặt, công cụ...", @@ -1800,6 +1800,11 @@ "updateStarted": "Đã bắt đầu cập nhật...", "reloadingPageAutomatically": "Đang tự động tải lại trang...", "providerTopology": "Cấu trúc liên kết nhà cung cấp", + "recentRequests": "Yêu cầu gần đây", + "recentRequestsEmpty": "Chưa có yêu cầu nào.", + "recentRequestsModel": "Mô hình", + "recentRequestsTokens": "Vào / Ra", + "recentRequestsWhen": "Khi", "downloadDmg": "Tải xuống DMG (macOS)", "downloadDmgDescription": "Đã có phiên bản mới của ứng dụng máy tính OmniRoute. Vui lòng tải xuống và cài đặt trình cài đặt DMG cho macOS để cập nhật (hiện tại: v{version}).", "downloadExe": "Tải xuống EXE (Windows)", @@ -3606,6 +3611,10 @@ "wizardStep3Desc": "Chọn cách phân phối các yêu cầu giữa các mô hình của bạn - hiện có 14 chiến lược", "wizardStep4Title": "Xem lại & Lưu", "wizardStep4Desc": "Xem lại cấu hình của bạn và kích hoạt combo", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "Bật", "emailVisibilityStateOff": "Tắt", "reorderHandle": "Kéo để sắp xếp lại", @@ -5233,6 +5242,17 @@ "skippingExistingModels": "Bỏ qua {count} mô hình đã tồn tại", "autoSync": "Tự động đồng bộ hóa", "autoSyncShort": "Đồng bộ", + "autoFetchModels": "Tự động lấy các mô hình upstream", + "autoFetchModelsTooltip": "Lấy và lưu trữ các mô hình upstream khi cần thiết", + "autoFetchModelsEnabled": "Mô hình upstream tự động lấy dữ liệu đã được kích hoạt", + "autoFetchModelsDisabled": "Tự động lấy mô hình upstream đã bị vô hiệu hóa", + "autoFetchModelsToggleFailed": "Không thể chuyển đổi chế độ tự động lấy mô hình upstream", + "autoFetchModelsPartialFailure": "Một số kết nối đã được cập nhật, nhưng mô hình upstream auto-fetch không được thay đổi ở mọi nơi", + "overridesUpstreamModel": "Ghi đè lên upstream", + "overridesUpstreamModelHint": "Cài đặt của bạn ghi đè lên mô hình upstream này", + "resetToUpstreamDefaults": "Khôi phục mặc định của upstream", + "resetToUpstreamDefaultsSuccess": "Đã khôi phục các giá trị mặc định của mô hình upstream", + "resetToUpstreamDefaultsFailed": "Không thể khôi phục mặc định mô hình upstream", "autoSyncTooltip": "Tự động làm mới danh sách mô hình sau mỗi 24 giờ (có thể cấu hình qua MODEL_SYNC_INTERVAL_HOURS)", "autoSyncEnabled": "Đã bật tự động đồng bộ hóa — các mô hình sẽ được làm mới định kỳ", "autoSyncDisabled": "Đã tắt tự động đồng bộ hóa", @@ -6194,7 +6214,7 @@ "galadriel": "Kết nối Galadriel bằng khóa API.", "predibase": "25 USD tín dụng dùng thử miễn phí (có hiệu lực 30 ngày)", "chenzk": "Gateway tương thích OpenAI với danh mục mô hình trực tiếp tại chenzk.top.", - "freepik": "Tạo hình ảnh bằng API Mystic của Freepik.", + "magnific": "Tạo hình ảnh bằng API Mystic của Freepik.", "freetheai": "Gateway miễn phí tương thích OpenAI, hỗ trợ chuyển tiếp mô hình.", "g4f-gemini": "Proxy ngược g4f.space miễn phí, không cần khóa cho Gemini, giới hạn 5 yêu cầu mỗi phút.", "g4f-groq": "Proxy ngược g4f.space miễn phí, không cần khóa cho Groq, giới hạn 5 yêu cầu mỗi phút.", @@ -6209,6 +6229,7 @@ "claude": "Kết nối Claude Code bằng luồng OAuth hiện có.", "cline": "Kết nối Cline bằng luồng OAuth hiện có.", "cursor": "Kết nối Cursor IDE bằng luồng OAuth hiện có.", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "Kết nối GitHub Copilot bằng luồng OAuth hiện có.", "gitlab-duo": "Ứng dụng OAuth với phạm vi ai_features + read_user. Cấu hình GITLAB_DUO_OAUTH_CLIENT_ID và tùy chọn GITLAB_DUO_OAUTH_CLIENT_SECRET trên instance OmniRoute này.", "kilocode": "Kết nối Kilo Code bằng luồng OAuth hiện có.", @@ -6271,6 +6292,15 @@ "cheaperInferenceSupporterBadge": "Người bạn mã nguồn mở", "cheaperInferenceSupporterTooltip": "Cheaper Inference hỗ trợ OmniRoute với tư cách là người bạn mã nguồn mở", "kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you", + "codexQuotaPools": "Nhóm hạn mức Codex", + "codexPoolAvailable": "Khả dụng", + "codexPoolPartiallyLimited": "Bị giới hạn một phần", + "codexPoolFullyLimited": "Bị giới hạn hoàn toàn", + "codexPoolLimited": "{count} mục bị giới hạn", + "codexPoolQuotaExhausted": "Đã hết hạn mức", + "codexPoolCoolingDown": "Đang trong thời gian chờ", + "codexPoolUsed": "đã dùng", + "codexPoolUntil": "Đến {value}", "anonymousFallbackTitle": "Dự phòng ẩn danh", "anonymousFallbackDesc": "Khi tất cả kết nối đã cấu hình đều cạn kiệt (hạn ngạch, tín dụng hoặc hết hạn), hãy tạm thời sử dụng tầng không cần khóa của nhà cung cấp này. Tắt tùy chọn này để bỏ qua nhà cung cấp thay vì gửi yêu cầu ẩn danh — khuyến nghị khi tầng không cần khóa từ chối các yêu cầu đó (401).", "anonymousFallbackEnabled": "Đã bật dự phòng ẩn danh cho {provider}", @@ -6346,18 +6376,7 @@ "savedModelEndpointSettings": "Đã lưu cài đặt endpoint mô hình", "searchByModelAria": "Tìm kiếm theo mô hình", "selectSupportedEndpoint": "Chọn ít nhất một endpoint được hỗ trợ", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "Tự động lấy các mô hình upstream", - "autoFetchModelsDisabled": "Tự động lấy mô hình upstream đã bị vô hiệu hóa", - "autoFetchModelsEnabled": "Mô hình upstream tự động lấy dữ liệu đã được kích hoạt", - "autoFetchModelsTooltip": "Lấy và lưu trữ các mô hình upstream khi cần thiết", - "overridesUpstreamModel": "Ghi đè lên upstream", - "autoFetchModelsPartialFailure": "Một số kết nối đã được cập nhật, nhưng mô hình upstream auto-fetch không được thay đổi ở mọi nơi", - "overridesUpstreamModelHint": "Cài đặt của bạn ghi đè lên mô hình upstream này", - "autoFetchModelsToggleFailed": "Không thể chuyển đổi chế độ tự động lấy mô hình upstream", - "resetToUpstreamDefaults": "Khôi phục mặc định của upstream", - "resetToUpstreamDefaultsSuccess": "Đã khôi phục các giá trị mặc định của mô hình upstream", - "resetToUpstreamDefaultsFailed": "Không thể khôi phục mặc định mô hình upstream" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "Cài đặt", @@ -7189,6 +7208,7 @@ "configured": "đã định cấu hình", "none": "Không có", "modelOverrideValuePlaceholder": "Giá trị số", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "Thêm cặp khóa-giá trị", "noModelOverrides": "Không có ghi đè nào được định cấu hình cho mô hình này.", "modelOverrideLoadFailed": "Tải ghi đè mô hình thất bại", @@ -7760,13 +7780,13 @@ "label": "Ponytail (lazy senior dev)", "description": "Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff." }, - "terse-cjk": { - "label": "CJK súc tích (文言)", - "description": "Văn phong Hán cổ cực kỳ súc tích (chỉ khả dụng với tiếng Trung)." - }, "i-have-adhd": { "label": "Tôi bị ADHD (ưu tiên hành động)", "description": "Đầu ra ưu tiên hành động: nêu hành động kế tiếp trước, các bước được đánh số, một bước tiếp theo cụ thể, không mở đầu dài dòng." + }, + "terse-cjk": { + "label": "CJK súc tích (文言)", + "description": "Văn phong Hán cổ cực kỳ súc tích (chỉ khả dụng với tiếng Trung)." } }, "resilienceWaitForCooldown": "Chờ thời gian hồi", @@ -8044,6 +8064,10 @@ "disableSessionStickinessDesc": "Các tổ hợp luân phiên và ngẫu nhiên sẽ chuyển sang một kết nối khác với mỗi yêu cầu, thay vì gắn toàn bộ cuộc trò chuyện với một kết nối dựa trên mã băm của tin nhắn đầu tiên. Hãy để tùy chọn này tắt để duy trì các lần trúng cache prompt cho các cuộc trò chuyện nhiều lượt. Các thiết lập ghi đè theo từng tổ hợp được ưu tiên.", "promptCacheAffinity": "Prompt-cache locality routing", "promptCacheAffinityDesc": "Prefer the same provider account for matching prompt-cache keys while preserving health and quota failover.", + "reasoningTokenBuffer": "Bộ đệm token suy luận", + "reasoningTokenBufferDesc": "Cho phép định tuyến combo thêm khoảng dư max_tokens chỉ với các mô hình suy luận đã biết, khi toàn bộ bộ đệm vẫn nằm trong giới hạn đầu ra đã biết.", + "zeroLatencyOptimizations": "Tối ưu hóa zero-latency", + "zeroLatencyOptimizationsDesc": "Bật hedging, bỏ qua TTFT theo dự đoán và nén dự phòng chủ động. Để tắt nếu bạn không muốn các tính năng độ trễ này chạy đua giữa các đích hoặc nén các yêu cầu dự phòng.", "credentialRedaction": "Credential Redaction", "credentialRedactionDesc": "Redact API keys, tokens, and secrets from context sent to providers and from responses.", "enableCredentialRedaction": "Enable credential redaction", @@ -8199,11 +8223,7 @@ "cliproxyapiHealth": "Sức Khỏe", "cliproxyapiPort": "Cổng", "qdrantHost": "Máy chủ", - "qdrantCollection": "Bộ Sưu Tập", - "reasoningTokenBuffer": "Bộ đệm token suy luận", - "reasoningTokenBufferDesc": "Cho phép định tuyến combo thêm khoảng dư max_tokens chỉ với các mô hình suy luận đã biết, khi toàn bộ bộ đệm vẫn nằm trong giới hạn đầu ra đã biết.", - "zeroLatencyOptimizations": "Tối ưu hóa zero-latency", - "zeroLatencyOptimizationsDesc": "Bật hedging, bỏ qua TTFT theo dự đoán và nén dự phòng chủ động. Để tắt nếu bạn không muốn các tính năng độ trễ này chạy đua giữa các đích hoặc nén các yêu cầu dự phòng." + "qdrantCollection": "Bộ Sưu Tập" }, "contextRtk": { "title": "RTK Engine", @@ -9098,6 +9118,16 @@ "grokAutoTopUpMax": "tối đa", "grokAutoTopUpMonth": "tháng", "grokAdditionalCredits": "Tín dụng bổ sung", + "kimiExtraUsageCredits": "Tín dụng sử dụng bổ sung", + "kimiExtraUsage": "Sử dụng bổ sung", + "kimiExtraUsageEnabled": "Đã bật", + "kimiExtraUsageDisabled": "Đã tắt", + "kimiExtraUsageFrozen": "Đã đóng băng", + "kimiExtraUsageUnavailable": "Không khả dụng", + "kimiMonthlyUsed": "Đã dùng trong tháng này", + "kimiMonthlyLimit": "Giới hạn hàng tháng", + "kimiMonthlyLimitUnlimited": "Không giới hạn", + "kimiAdditionalCredits": "Tín dụng bổ sung", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Quản lý ngân sách", @@ -12724,6 +12754,10 @@ "ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE": { "description": "Cho phép nhiều kết nối trên mỗi node tương thích." }, + "DISABLE_CONTEXT_WINDOW_CHECKS": { + "label": "Tắt kiểm tra cửa sổ ngữ cảnh", + "description": "Bỏ qua kiểm tra cục bộ của OmniRoute về cửa sổ ngữ cảnh và giới hạn token đầu vào tối đa cho yêu cầu trực tiếp đến một mô hình đơn lẻ. Nhà cung cấp thượng nguồn vẫn áp dụng các giới hạn thực tế. Tính năng nén prompt và giới hạn token đầu ra vẫn hoạt động." + }, "RESPONSES_PASSTHROUGH_DROP_COMMENTARY": { "description": "Loại các mục đầu ra thuộc giai đoạn commentary nội bộ khỏi luồng chuyển tiếp Responses API trước khi gửi tới ứng dụng khách. Tắt cờ này để nhận nguyên dữ liệu commentary từ thượng nguồn." }, @@ -13364,6 +13398,7 @@ } }, "featureFlagCapabilityFilterEnabledDescription": "Từ chối yêu cầu trước khi gửi đi khi mô hình đích thiếu các khả năng bắt buộc (thị giác, công cụ, đầu ra có cấu trúc, cửa sổ ngữ cảnh). Bảo vệ các yêu cầu trực tiếp đến một nhà cung cấp khi chúng bỏ qua bộ lọc tương thích của combo.", + "featureFlagDisableContextWindowChecksDescription": "Bỏ qua kiểm tra cục bộ của OmniRoute về cửa sổ ngữ cảnh và giới hạn token đầu vào tối đa cho yêu cầu trực tiếp đến một mô hình đơn lẻ. Nhà cung cấp thượng nguồn vẫn áp dụng các giới hạn thực tế. Tính năng nén prompt và giới hạn token đầu ra vẫn hoạt động.", "publicSystem": { "notFound": { "title": "Không tìm thấy trang", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f4b62520a6..b4b6bad59b 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1165,6 +1165,8 @@ "consoleLogs": "控制台日志", "logsTimeline": "时间线", "logsTimelineSubtitle": "可视化请求时间线", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "全局路由", "mitmProxy": "MITM 代理", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "打开", "close": "关闭" }, - "noResults": "没有结果", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "没有结果" }, "webhooks": { "title": "Webhook", @@ -1739,8 +1739,8 @@ "quotaShare": "配额共享", "discovery": "发现", "freeProviderRankings": "免费服务商排行", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "免费层级", "gamification": "游戏化", "leaderboard": "排行榜", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "选择请求在模型之间的分发方式 — 提供 13 种策略", "wizardStep4Title": "审查并保存", "wizardStep4Desc": "审查您的配置并激活组合", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "开启", "emailVisibilityStateOff": "关闭", "reorderHandle": "拖拽排序", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "此提供者已弃用", "riskNotice": { "title": "继续之前", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "该提供者有使用注意事项 —— 点击查看详情", "oauth": "此提供者使用你官方产品的会话 / OAuth,这并未被授权用于代理或路由用途。 不建议进行高强度的自主代理使用(OpenCloud 风格、长链路多步流程、大批量请求)—— 上游可能因此限制甚至封禁账号。 使用风险自负。", "webCookie": "此提供者通过你的网页会话 Cookie 进行鉴权。上游服务可能随时让会话失效,届时你需要重新登录。不建议用于长时间无人值守的操作。 使用风险自负。", @@ -5107,9 +5111,9 @@ "cancel": "取消" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "已禁用", "enableProvider": "启用提供者", @@ -5449,18 +5453,18 @@ "interceptFetchHint": "将原生的 web_fetch 工具调用重写为 OmniRoute 的 /v1/web/fetch。", "interceptionLoadError": "加载拦截设置失败:{error}", "interceptionSaveError": "保存拦截设置失败:{error}", - "ccAliasSectionTitle": "在 Claude Code 中暴露 (claude/…)", - "ccAliasSectionHint": "将该提供者的模型以 claude/<provider>/<model> 镜像 ID 发布,让 Claude Code 的网关模型可以发现它们。默认关闭 — 启用会使所有客户端的目录条目翻倍。", - "ccAliasProviderLevelLabel": "提供者默认值", - "ccAliasModelOverridesLabel": "按模型覆盖", - "ccAliasModelOverrideAriaLabel": "覆盖 {modelId}", - "ccAliasStateInherit": "继承", - "ccAliasStateOn": "开启", - "ccAliasStateOff": "关闭", - "ccAliasAddModelPlaceholder": "模型 ID (例如 gpt-4o)", - "ccAliasAddModelButton": "添加覆盖", - "ccAliasLoadError": "加载发现别名设置失败: {error}", - "ccAliasSaveError": "保存发现别名设置失败: {error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "上游额外请求头", "compatUpstreamHeadersHint": "与修改厂商连接/API 配置同属高权限能力,仅可信管理员应使用。这些头会在 OmniRoute 按厂商 API Key 自动加好鉴权头之后再合并。若「名称」与系统已加的头相同(例如都叫 Authorization),则以你填的值为准,会整段替换自动那条(含 Bearer 令牌),上游请求里不再使用面板里保存的密钥来生成 Authorization。填错可能导致 401,请谨慎。每个请求头单独一行;部分网关需要额外 Authentication 等可在此加。鼠标移入或聚焦「值」可暂时看明文。点空白处、关闭本面板或切走焦点即保存。", "compatUpstreamHeaderName": "请求头名称", @@ -6205,7 +6209,7 @@ "galadriel": "使用 API 密钥连接 Galadriel。", "predibase": "$25 免费试用额度(30 天有效期)", "chenzk": "兼容 OpenAI 的网关,在 chenzk.top 提供实时模型目录。", - "freepik": "使用 Freepik 的 Mystic API 生成图像。", + "magnific": "使用 Freepik 的 Mystic API 生成图像。", "freetheai": "免费的 OpenAI 兼容网关,支持直通模型。", "g4f-gemini": "免费免密钥的 g4f.space Gemini 反向代理,限制为每分钟 5 次请求。", "g4f-groq": "免费免密钥的 g4f.space Groq 反向代理,限制为每分钟 5 次请求。", @@ -6220,6 +6224,7 @@ "claude": "使用现有的 OAuth 流程连接 Claude Code。", "cline": "使用现有的 OAuth 流程连接 Cline。", "cursor": "使用现有的 OAuth 流程连接 Cursor IDE。", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "使用现有的 OAuth 流程连接 GitHub Copilot。", "gitlab-duo": "具有 ai_features + read_user 作用域的 OAuth 应用程序。在此 OmniRoute 实例上配置 GITLAB_DUO_OAUTH_CLIENT_ID 以及可选的 GITLAB_DUO_OAUTH_CLIENT_SECRET。", "kilocode": "使用现有的 OAuth 流程连接 Kilo Code。", @@ -6282,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "开源好友", "cheaperInferenceSupporterTooltip": "Cheaper Inference 作为开源好友支持 OmniRoute", "kimiPartnerLinkNote": "合作伙伴链接 — 支持 OmniRoute,您无需承担额外费用", + "codexQuotaPools": "Codex 配额池", + "codexPoolAvailable": "可用", + "codexPoolPartiallyLimited": "部分受限", + "codexPoolFullyLimited": "全部受限", + "codexPoolLimited": "{count} 个受限", + "codexPoolQuotaExhausted": "配额已用尽", + "codexPoolCoolingDown": "冷却中", + "codexPoolUsed": "已使用", + "codexPoolUntil": "截至 {value}", "anonymousFallbackTitle": "匿名回退", "anonymousFallbackDesc": "当所有配置的连接耗尽(配额、积分或到期)时,临时使用此提供者的无密钥层。关闭以跳过此提供者,而不是发送匿名请求 — 当无密钥层拒绝它们时(401)建议使用。", "anonymousFallbackEnabled": "为 {provider} 启用匿名回退", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "若提供者连接返回特定的永久封禁信号(如 HTTP 403\"请验证您的账户\"),则将其永久标记为停用。这会将其从组合轮换中移除。", "autoDisableThreshold": "封禁阈值", "autoDisableThresholdDesc": "触发永久停用所需的连续封禁信号次数。", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "封禁关键词", "customBannedSignalsDesc": "触发永久封号检测的附加关键词。内置关键词始终生效。", "customBannedSignalsPlaceholder": "例如 api key revoked", @@ -7189,6 +7203,7 @@ "configured": "已配置", "none": "无", "modelOverrideValuePlaceholder": "数字值", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "添加键值", "noModelOverrides": "该模型尚未配置覆盖。", "modelOverrideLoadFailed": "加载模型覆盖失败", @@ -7416,7 +7431,7 @@ "qdrantDesc": "可选。在外部向量数据库中索引语义记忆以加快检索速度。", "qdrantStatusActive": "活跃", "qdrantStatusError": "错误", - "qdrantStatusDisabled": "残疾人", + "qdrantStatusDisabled": "已禁用", "qdrantEnable": "启用 Qdrant", "qdrantEnableDesc": "启用后,语义/混合策略可以使用 Qdrant 来检索记忆。", "qdrantTesting": "测试...", @@ -7923,7 +7938,7 @@ "triggerLabel": "触发", "effectLabel": "效果", "statusEnabled": "启用", - "statusDisabled": "残疾人", + "statusDisabled": "已禁用", "resilienceRequestQueueScope": "每个请求队列", "resilienceRequestQueueTrigger": "发送到上游之前", "resilienceRequestQueueEffect": "对请求进行排队、限制并发并间隔调用", @@ -8608,6 +8623,27 @@ }, "enableTitle": "启用引擎", "enableDescription": "在堆栈中最后运行(在 RTK/Caveman 清理文本、OmniGlyph 将剩余部分转换为图像之后),也可以通过 omniglyph 模式独立运行。此功能为预览版,在端到端验证完成前默认保持关闭。", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "已保存。", "saveFailed": "无法保存。", "enableAria": "启用 OmniGlyph 引擎", @@ -9077,6 +9113,16 @@ "grokAutoTopUpMax": "最大", "grokAutoTopUpMonth": "月", "grokAdditionalCredits": "额外的致谢", + "kimiExtraUsageCredits": "加油包余额", + "kimiExtraUsage": "额度加油包", + "kimiExtraUsageEnabled": "已开启", + "kimiExtraUsageDisabled": "已关闭", + "kimiExtraUsageFrozen": "已冻结", + "kimiExtraUsageUnavailable": "不可用", + "kimiMonthlyUsed": "本月已用", + "kimiMonthlyLimit": "每月限额", + "kimiMonthlyLimitUnlimited": "无限制", + "kimiAdditionalCredits": "充值加油包", "loggerTab": "记录器", "proxyTab": "代理", "budgetManagement": "预算管理", @@ -12475,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "首个 Token", @@ -13200,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13740,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index a7ac344ae8..b2d9679086 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1165,6 +1165,8 @@ "consoleLogs": "控制台日誌", "logsTimeline": "Timeline", "logsTimelineSubtitle": "視覺請求時間線", + "conversations": "Conversations", + "conversationsSubtitle": "Multi-turn agentic conversations", "globalRouting": "全域性路由", "mitmProxy": "MITM 代理", "oneProxy": "1Proxy", @@ -1291,9 +1293,7 @@ "open": "打開", "close": "關閉" }, - "noResults": "沒有結果", - "conversations": "Conversations", - "conversationsSubtitle": "Multi-turn agentic conversations" + "noResults": "沒有結果" }, "webhooks": { "title": "Webhook", @@ -1739,8 +1739,8 @@ "quotaShare": "配額分享", "discovery": "探索", "freeProviderRankings": "免費提供者排名", - "radar": "__MISSING__:Radar", - "setup": "__MISSING__:Setup", + "radar": "Radar", + "setup": "Setup", "freeTiers": "免費方案", "gamification": "遊戲化", "leaderboard": "排行榜", @@ -3606,6 +3606,10 @@ "wizardStep3Desc": "選擇請求在模型之間的分發方式 — 提供 13 種策略", "wizardStep4Title": "審查並儲存", "wizardStep4Desc": "審查您的設定並啟用組合", + "usageGuideInvokeTitle": "How to call this combo", + "usageGuideInvokeDesc": "Send the combo's exact name as the model, e.g. model: \"my-combo\" (or combo/my-combo).", + "usageGuideInvokeAutoNote": "auto and auto/* are a separate zero-config router that does not use your combos (unless a combo is literally named auto).", + "usageGuideInvokeOpenrouterNote": "openrouter/auto is a real paid OpenRouter product (Auto Best Available), not an OmniRoute alias — exclude it via Settings → Routing → Hide paid models.", "emailVisibilityStateOn": "開啟", "emailVisibilityStateOff": "關閉", "reorderHandle": "拖拽排序", @@ -5097,7 +5101,7 @@ "deprecatedProvider": "此提供者已棄用", "riskNotice": { "title": "繼續之前", - "detailsTitle": "__MISSING__:Usage caveats", + "detailsTitle": "Usage caveats", "tooltip": "該提供者有使用注意事項 —— 點選檢視詳情", "oauth": "此提供者使用你官方產品的會話 / OAuth,這並未被授權用於代理或路由用途。 不建議進行高強度的自主代理使用(OpenCloud 風格、長鏈路多步流程、大批次請求)—— 上游可能因此限制甚至封禁帳號。 使用風險自負。", "webCookie": "此提供者通過你的網頁會話 Cookie 進行鑑權。上游服務可能隨時讓會話失效,屆時你需要重新登入。不建議用於長時間無人值守的操作。 使用風險自負。", @@ -5107,9 +5111,9 @@ "cancel": "取消" }, "warningNotice": { - "tooltip": "__MISSING__:{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", - "lastFailureSuffix": "__MISSING__: (last failure {time})", - "ariaLabel": "__MISSING__:View connection health details, {count} warning(s)" + "tooltip": "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + "lastFailureSuffix": " (last failure {time})", + "ariaLabel": "View connection health details, {count} warning(s)" }, "disabled": "已停用", "enableProvider": "啟用提供者", @@ -5233,6 +5237,17 @@ "skippingExistingModels": "跳過 {count} 個已有模型", "autoSync": "自動同步", "autoSyncShort": "同步", + "autoFetchModels": "自動獲取上游模型", + "autoFetchModelsTooltip": "在需要時獲取並快取上游模型", + "autoFetchModelsEnabled": "上游模型自動獲取已啟用", + "autoFetchModelsDisabled": "上游模型自動獲取已禁用", + "autoFetchModelsToggleFailed": "無法切換上游模型自動獲取", + "autoFetchModelsPartialFailure": "某些連接已更新,但上游模型自動獲取並未在所有地方更改", + "overridesUpstreamModel": "覆蓋上游", + "overridesUpstreamModelHint": "您的設定覆蓋了此上游模型", + "resetToUpstreamDefaults": "恢復上游預設值", + "resetToUpstreamDefaultsSuccess": "已恢復上游模型預設值", + "resetToUpstreamDefaultsFailed": "無法恢復上游模型的預設值", "autoSyncTooltip": "每 24 小時自動重新整理模型列表(可通過 MODEL_SYNC_INTERVAL_HOURS 設定)", "autoSyncEnabled": "自動同步已啟用 — 模型將定期重新整理", "autoSyncDisabled": "自動同步已停用", @@ -5438,18 +5453,18 @@ "interceptFetchHint": "將原生的 web_fetch 工具呼叫重新導向至 OmniRoute 的 /v1/web/fetch。", "interceptionLoadError": "載入攔截設定失敗:{error}", "interceptionSaveError": "儲存攔截設定失敗:{error}", - "ccAliasSectionTitle": "在 Claude Code (claude/…) 中公開", - "ccAliasSectionHint": "在 claude/<provider>/<model> 鏡像 ID 下廣告此提供者的模型,以便 Claude Code 的網關模型發現可以列出它們。預設為關閉 — 啟用此功能會使所有客戶的目錄條目加倍。", - "ccAliasProviderLevelLabel": "提供者預設", - "ccAliasModelOverridesLabel": "每個模型的覆蓋設定", - "ccAliasModelOverrideAriaLabel": "{modelId} 的覆蓋設定", - "ccAliasStateInherit": "繼承", - "ccAliasStateOn": "開啟", - "ccAliasStateOff": "關閉", - "ccAliasAddModelPlaceholder": "模型 ID(例如:gpt-4o)", - "ccAliasAddModelButton": "新增覆蓋", - "ccAliasLoadError": "無法加載 discovery-alias 設定:{error}", - "ccAliasSaveError": "無法保存 discovery-alias 設定:{error}", + "ccAliasSectionTitle": "Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "Provider default", + "ccAliasModelOverridesLabel": "Per-model overrides", + "ccAliasModelOverrideAriaLabel": "Override for {modelId}", + "ccAliasStateInherit": "Inherit", + "ccAliasStateOn": "On", + "ccAliasStateOff": "Off", + "ccAliasAddModelPlaceholder": "Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "Add override", + "ccAliasLoadError": "Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "Failed to save discovery-alias setting: {error}", "compatUpstreamHeadersLabel": "上游額外請求頭", "compatUpstreamHeadersHint": "與修改廠商連線/API 設定同屬高許可權能力,僅可信管理員應使用。這些頭會在 OmniRoute 按廠商 API Key 自動加好鑑權頭之後再合併。若「名稱」與系統已加的頭相同(例如都叫 Authorization),則以你填的值為準,會整段替換自動那條(含 Bearer 權杖),上游請求裡不再使用面板裡儲存的金鑰來生成 Authorization。填錯可能導致 401,請謹慎。每個請求頭單獨一行;部分閘道器需要額外 Authentication 等可在此加。滑鼠移入或聚焦「值」可暫時看明文。點空白處、關閉本面板或切走焦點即儲存。", "compatUpstreamHeaderName": "請求頭名稱", @@ -6194,7 +6209,7 @@ "galadriel": "使用 API 金鑰連線 Galadriel。", "predibase": "$25 美元免費試用額度(30 天有效期)", "chenzk": "OpenAI 相容閘道,在 chenzk.top 提供即時模型目錄。", - "freepik": "使用 Freepik 的 Mystic API 生成圖片。", + "magnific": "使用 Freepik 的 Mystic API 生成圖片。", "freetheai": "免費的 OpenAI 相容閘道,支援透傳模型。", "g4f-gemini": "免費無需金鑰的 g4f.space Gemini 反向代理,每分鐘限制 5 次請求。", "g4f-groq": "免費無需金鑰的 g4f.space Groq 反向代理,每分鐘限制 5 次請求。", @@ -6209,6 +6224,7 @@ "claude": "使用現有的 OAuth 流程連線 Claude Code。", "cline": "使用現有的 OAuth 流程連線 Cline。", "cursor": "使用現有的 OAuth 流程連線 Cursor IDE。", + "cursor-api": "Connect Cursor with a user API key (crsr_...) from cursor.com/dashboard/api; no IDE session needed.", "github": "使用現有的 OAuth 流程連線 GitHub Copilot。", "gitlab-duo": "具有 ai_features + read_user 範圍的 OAuth 應用程式。在此 OmniRoute 實例上設定 GITLAB_DUO_OAUTH_CLIENT_ID 和可選的 GITLAB_DUO_OAUTH_CLIENT_SECRET。", "kilocode": "使用現有的 OAuth 流程連線 Kilo Code。", @@ -6271,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "開源好友", "cheaperInferenceSupporterTooltip": "Cheaper Inference 作為開源好友支持 OmniRoute", "kimiPartnerLinkNote": "合作夥伴連結 — 支援 OmniRoute,您無需額外付費", + "codexQuotaPools": "Codex 配額集區", + "codexPoolAvailable": "可用", + "codexPoolPartiallyLimited": "部分受限", + "codexPoolFullyLimited": "全部受限", + "codexPoolLimited": "{count} 個受限", + "codexPoolQuotaExhausted": "配額已用盡", + "codexPoolCoolingDown": "冷卻中", + "codexPoolUsed": "已使用", + "codexPoolUntil": "截至 {value}", "anonymousFallbackTitle": "匿名後備", "anonymousFallbackDesc": "當所有配置的連接耗盡(配額、積分或到期)時,暫時使用此提供者的無密鑰層級。關閉以跳過此提供者,而不是發送匿名請求 — 當無密鑰層級拒絕它們(401)時建議這樣做。", "anonymousFallbackEnabled": "為 {provider} 啟用匿名後備", @@ -6346,18 +6371,7 @@ "savedModelEndpointSettings": "已儲存的模型端點設定", "searchByModelAria": "按型號搜尋", "selectSupportedEndpoint": "請選擇至少一個受支持的端點", - "antigravityClientProfileHarness": "Harness / CLI", - "autoFetchModels": "自動獲取上游模型", - "autoFetchModelsDisabled": "上游模型自動獲取已禁用", - "autoFetchModelsEnabled": "上游模型自動獲取已啟用", - "autoFetchModelsTooltip": "在需要時獲取並快取上游模型", - "autoFetchModelsToggleFailed": "無法切換上游模型自動獲取", - "autoFetchModelsPartialFailure": "某些連接已更新,但上游模型自動獲取並未在所有地方更改", - "overridesUpstreamModel": "覆蓋上游", - "overridesUpstreamModelHint": "您的設定覆蓋了此上游模型", - "resetToUpstreamDefaults": "恢復上游預設值", - "resetToUpstreamDefaultsSuccess": "已恢復上游模型預設值", - "resetToUpstreamDefaultsFailed": "無法恢復上游模型的預設值" + "antigravityClientProfileHarness": "Harness / CLI" }, "settings": { "title": "設定", @@ -6576,12 +6590,12 @@ "autoDisableDescription": "若提供者連線返回特定的永久封禁訊號(如 HTTP 403\"請驗證您的帳戶\"),則將其永久標記為停用。這會將其從組合輪換中移除。", "autoDisableThreshold": "封禁閾值", "autoDisableThresholdDesc": "觸發永久停用所需的連續封禁訊號次數。", - "autoDisableBannedScope": "__MISSING__:Apply auto-disable to", - "autoDisableBannedScopeDesc": "__MISSING__:Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", - "autoDisableBannedScopeAll": "__MISSING__:All connections", - "autoDisableBannedScopeAllDesc": "__MISSING__:Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", - "autoDisableBannedScopeSubscription": "__MISSING__:Login / subscription accounts only", - "autoDisableBannedScopeSubscriptionDesc": "__MISSING__:Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", + "autoDisableBannedScope": "Apply auto-disable to", + "autoDisableBannedScopeDesc": "Login seats (paid subscriptions and free accounts) can be locked by the upstream if OmniRoute keeps retrying after a ban. Paid prepaid API keys do not have that risk — leave them in the pool so fill-first can use the next key.", + "autoDisableBannedScopeAll": "All connections", + "autoDisableBannedScopeAllDesc": "Deactivate every connection that returns a permanent ban signal, including prepaid API keys.", + "autoDisableBannedScopeSubscription": "Login / subscription accounts only", + "autoDisableBannedScopeSubscriptionDesc": "Deactivate OAuth, cookie, session, and web-login seats (paid or free). Prepaid API keys stay active so routing can fail over.", "customBannedSignals": "禁止關鍵字", "customBannedSignalsDesc": "觸發永久帳戶封鎖偵測的額外關鍵字。內建關鍵字始終適用。", "customBannedSignalsPlaceholder": "例如:api key revoked", @@ -6724,7 +6738,7 @@ "global": "全球", "rule": "規則", "enabled": "啟用", - "disabled": "殘障人士", + "disabled": "已停用", "nodeCount": "節點:{count}", "needsCoreCount": "{count} 需要本地核心", "lastSynced": "上次同步:{time}", @@ -7189,6 +7203,7 @@ "configured": "已設定", "none": "無", "modelOverrideValuePlaceholder": "數值", + "modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high", "addKeyValue": "新增鍵值", "noModelOverrides": "此模型未設定任何覆寫。", "modelOverrideLoadFailed": "載入模型覆寫設定失敗", @@ -7416,7 +7431,7 @@ "qdrantDesc": "可選。在外部向量資料庫中索引語義記憶以加快檢索速度。", "qdrantStatusActive": "活躍", "qdrantStatusError": "錯誤", - "qdrantStatusDisabled": "殘疾人", + "qdrantStatusDisabled": "已停用", "qdrantEnable": "啟用 Qdrant", "qdrantEnableDesc": "啟用後,語義/混合策略可以使用 Qdrant 來檢索記憶。", "qdrantTesting": "測試...", @@ -7923,7 +7938,7 @@ "triggerLabel": "觸發", "effectLabel": "效果", "statusEnabled": "啟用", - "statusDisabled": "殘疾人", + "statusDisabled": "已停用", "resilienceRequestQueueScope": "每個請求佇列", "resilienceRequestQueueTrigger": "傳送到上游之前", "resilienceRequestQueueEffect": "對請求進行排隊、限制併發並間隔呼叫", @@ -8608,6 +8623,27 @@ }, "enableTitle": "啟用引擎", "enableDescription": "在堆疊中最後執行(RTK/Caveman 清理文字後,OmniGlyph 將剩餘部分轉換為圖片),也可透過 omniglyph 模式獨立執行。此為預覽功能,預設為關閉,待端到端驗證完成後才會預設開啟。", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "已儲存。", "saveFailed": "無法儲存。", "enableAria": "啟用 OmniGlyph 引擎", @@ -9077,6 +9113,16 @@ "grokAutoTopUpMax": "最大", "grokAutoTopUpMonth": "月份", "grokAdditionalCredits": "額外的致謝", + "kimiExtraUsageCredits": "加油包餘額", + "kimiExtraUsage": "額度加油包", + "kimiExtraUsageEnabled": "已開啟", + "kimiExtraUsageDisabled": "已關閉", + "kimiExtraUsageFrozen": "已凍結", + "kimiExtraUsageUnavailable": "無法使用", + "kimiMonthlyUsed": "本月已用", + "kimiMonthlyLimit": "每月限額", + "kimiMonthlyLimitUnlimited": "無限制", + "kimiAdditionalCredits": "儲值加油包", "loggerTab": "記錄器", "proxyTab": "代理", "budgetManagement": "預算管理", @@ -12475,9 +12521,9 @@ }, "badges": { "radar-supporter": { - "name": "__MISSING__:Radar Supporter", - "description": "__MISSING__:Verified a live OmniRoute Radar supporter feed", - "criteria": "__MISSING__:Verify a signed live Radar supporter feed." + "name": "Radar Supporter", + "description": "Verified a live OmniRoute Radar supporter feed", + "criteria": "Verify a signed live Radar supporter feed." }, "first-token": { "name": "第一個代幣", @@ -13200,7 +13246,7 @@ "localStateSaveFailed": "Failed to save local Radar settings", "guidedCombos": "Guided combos", "offers": "Offers", - "intel": "__MISSING__:Intel" + "intel": "Intel" }, "radarCombosPage": { "title": "Radar guided combos", @@ -13740,36 +13786,36 @@ "trialDays": "{days, plural, one {# day} other {# days}}" }, "radarIntelPage": { - "title": "__MISSING__:Radar Intel", - "subtitle": "__MISSING__:Radar-owned ELO rankings and factual catalog movement.", - "backToRadar": "__MISSING__:Back to Radar", - "loading": "__MISSING__:Loading Intel...", - "refresh": "__MISSING__:Refresh Intel", - "syncing": "__MISSING__:Refreshing...", - "loadFailed": "__MISSING__:We could not refresh Intel. The last verified local cache is kept.", - "empty": "__MISSING__:No verified Intel snapshot is available yet.", - "supporterBadge": "__MISSING__:Radar Supporter", - "methodology": "__MISSING__:Methodology", - "eloMethod": "__MISSING__:ELO, initial {initial}, K={factor}", - "freshness": "__MISSING__:Catalog freshness", - "ageDays": "__MISSING__:{days, plural, one {# day old} other {# days old}}", - "trend": "__MISSING__:Catalog trend", - "modelDelta": "__MISSING__:{current} models, +{added} / -{removed}", - "ranking": "__MISSING__:Model ranking", - "noRankings": "__MISSING__:No confirmed comparisons are available yet.", - "model": "__MISSING__:Model", - "category": "__MISSING__:Category", - "rating": "__MISSING__:Rating", - "matches": "__MISSING__:Matches", + "title": "Radar Intel", + "subtitle": "Radar-owned ELO rankings and factual catalog movement.", + "backToRadar": "Back to Radar", + "loading": "Loading Intel...", + "refresh": "Refresh Intel", + "syncing": "Refreshing...", + "loadFailed": "We could not refresh Intel. The last verified local cache is kept.", + "empty": "No verified Intel snapshot is available yet.", + "supporterBadge": "Radar Supporter", + "methodology": "Methodology", + "eloMethod": "ELO, initial {initial}, K={factor}", + "freshness": "Catalog freshness", + "ageDays": "{days, plural, one {# day old} other {# days old}}", + "trend": "Catalog trend", + "modelDelta": "{current} models, +{added} / -{removed}", + "ranking": "Model ranking", + "noRankings": "No confirmed comparisons are available yet.", + "model": "Model", + "category": "Category", + "rating": "Rating", + "matches": "Matches", "freshnessValues": { - "fresh": "__MISSING__:Fresh", - "aging": "__MISSING__:Aging", - "stale": "__MISSING__:Stale" + "fresh": "Fresh", + "aging": "Aging", + "stale": "Stale" }, "trendValues": { - "growing": "__MISSING__:Growing", - "stable": "__MISSING__:Stable", - "shrinking": "__MISSING__:Shrinking" + "growing": "Growing", + "stable": "Stable", + "shrinking": "Shrinking" } }, "capabilityFilter": { diff --git a/src/lib/agentSkills/generator.ts b/src/lib/agentSkills/generator.ts index dd106b42ba..8f5599622f 100644 --- a/src/lib/agentSkills/generator.ts +++ b/src/lib/agentSkills/generator.ts @@ -76,6 +76,7 @@ function extractCustomBlock(content: string): string | null { function buildApiBody(skill: AgentSkill, sources: BuildSources): string { const areaMap = sources.openapi.areas; const ops = areaMap.get(skill.area as Parameters[0]) ?? []; + const usesDashboardSession = skill.id === "omni-auth"; const lines: string[] = []; @@ -84,10 +85,17 @@ function buildApiBody(skill: AgentSkill, sources: BuildSources): string { lines.push(""); lines.push("## Authentication\n"); - lines.push( - "All requests require a valid Bearer token or session cookie. " + - "Obtain a token via `POST /api/auth/login` or configure `REQUIRE_API_KEY=false` for local development." - ); + if (usesDashboardSession) { + lines.push( + "Remote API requests use a Bearer credential. Dashboard login is different: " + + "`POST /api/auth/login` accepts a management password and returns an `auth_token` session cookie." + ); + } else { + lines.push( + "All requests require a valid Bearer token or session cookie. " + + "Obtain a token via `POST /api/auth/login` or configure `REQUIRE_API_KEY=false` for local development." + ); + } lines.push(""); lines.push("## Endpoints\n"); @@ -105,14 +113,41 @@ function buildApiBody(skill: AgentSkill, sources: BuildSources): string { lines.push(op.description); lines.push(""); } - // Minimal curl example - const curlMethod = op.method === "GET" ? "" : `-X ${op.method} `; + // Minimal curl example. Only omni-auth establishes and consumes a dashboard + // session; generic API skills use independently usable Bearer examples. lines.push("```bash"); - lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`); - lines.push(' -H "Authorization: Bearer $OMNIROUTE_TOKEN"'); - if (["POST", "PUT", "PATCH"].includes(op.method)) { + if (usesDashboardSession && op.path === "/api/auth/login" && op.method === "POST") { + lines.push(`curl -X POST https://localhost:20128${op.path} \\`); lines.push(' -H "Content-Type: application/json" \\'); - lines.push(" -d '{}'"); + lines.push(" -c cookie.jar \\"); + lines.push(' -d \'{"password":""}\''); + } else if (usesDashboardSession) { + const curlMethod = op.method === "GET" ? "" : `-X ${op.method} `; + if (op.method === "GET") { + lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`); + lines.push(" -b cookie.jar"); + } else { + lines.push( + "CSRF_TOKEN=$(curl -s https://localhost:20128/api/auth/csrf -b cookie.jar | jq -r .token)" + ); + lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`); + lines.push(" -b cookie.jar \\"); + const hasJsonBody = ["POST", "PUT", "PATCH"].includes(op.method); + lines.push(` -H "x-omniroute-csrf: $CSRF_TOKEN"${hasJsonBody ? " \\" : ""}`); + if (hasJsonBody) { + lines.push(' -H "Content-Type: application/json" \\'); + lines.push(" -d '{}'"); + } + } + } else { + const curlMethod = op.method === "GET" ? "" : `-X ${op.method} `; + const hasJsonBody = ["POST", "PUT", "PATCH"].includes(op.method); + lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`); + lines.push(` -H "Authorization: Bearer $OMNIROUTE_TOKEN"${hasJsonBody ? " \\" : ""}`); + if (hasJsonBody) { + lines.push(' -H "Content-Type: application/json" \\'); + lines.push(" -d '{}'"); + } } lines.push("```"); lines.push(""); diff --git a/src/lib/api/modelTestRunner.ts b/src/lib/api/modelTestRunner.ts index f68454f6f5..c72248f55a 100644 --- a/src/lib/api/modelTestRunner.ts +++ b/src/lib/api/modelTestRunner.ts @@ -8,7 +8,7 @@ import { extractComboTestResponseText, extractComboTestStreamResult, } from "@/lib/combos/testHealth"; -import { getCustomModels } from "@/lib/localDb"; +import { getCustomModels } from "@/lib/db/models"; import { getProviderNodeById } from "@/lib/db/providers"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { withRateLimit } from "@omniroute/open-sse/services/rateLimitManager"; @@ -18,6 +18,7 @@ import { } from "@omniroute/open-sse/services/accountFallback"; import { looksLikeQuotaExhausted } from "@/shared/utils/classify429"; import { getTrustedLocalRateLimitError } from "@omniroute/open-sse/services/rateLimitManager/errors"; +import { runAsProbe } from "@/shared/utils/probeOrigin"; import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; const INTERNAL_ORIGIN = "http://omniroute.internal"; @@ -482,11 +483,14 @@ export async function runSingleModelTest( providerId, connectionId, fullModelStr, - (signal) => runInner(signal), + // T-PROBE: wrap the scheduled fn, not the withRateLimit call — a + // queued Bottleneck job executes from its own async resource and + // would otherwise run outside the probe context below. + (signal) => runAsProbe(() => runInner(signal)), controller.signal ); } else { - res = await runInner(controller.signal); + res = await runAsProbe(() => runInner(controller.signal)); } } catch (error: unknown) { clearTimeout(timeoutHandle); @@ -566,9 +570,14 @@ export async function runSingleModelTest( let responseText = ""; let streamError: ModelTestResponseText["error"]; try { - const parsedResponse = await extractModelTestResponseText( - res, - !isEmbedding && !isRerank && streamChat + // T-PROBE: consume the stream inside the probe context too — the SSE + // body is transformed by chatCore/chatHelpers generator code that + // resumes in the CONSUMER's async context. Without this wrapper, an + // error frame inside a 200 stream (Sentinel blocks, "account + // deactivated") would run outside runAsProbe and could still reach + // markAccountUnavailable (#9817). + const parsedResponse = await runAsProbe(() => + extractModelTestResponseText(res, !isEmbedding && !isRerank && streamChat) ); responseText = parsedResponse.text; streamError = parsedResponse.error; diff --git a/src/lib/api/requireManagementAuth.ts b/src/lib/api/requireManagementAuth.ts index eb0dc99239..34ebc25cbf 100644 --- a/src/lib/api/requireManagementAuth.ts +++ b/src/lib/api/requireManagementAuth.ts @@ -5,6 +5,7 @@ import { getApiKeyMetadata } from "@/lib/db/apiKeys"; import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; import { evaluateAccessTokenAuth } from "@/server/authz/accessTokenAuth"; import { isTrustedLoopbackInternalServiceRequest } from "@/lib/api/internalServiceAuth"; +import { AUTHZ_HEADER_AUTH_KIND, AUTHZ_HEADER_AUTH_LABEL } from "@/server/authz/headers"; import { MANAGE_SCOPE, hasManageScope as hasManageScopeShared, @@ -52,7 +53,17 @@ export async function requireManagementAuth( return null; } - // CLI machine-id token allows localhost CLI access without an explicit API key. + // The authz pipeline strips the raw machine-token header after it validates it + // and forwards this trusted subject stamp to route handlers. + if ( + request.headers.get(AUTHZ_HEADER_AUTH_KIND) === "management_key" && + request.headers.get(AUTHZ_HEADER_AUTH_LABEL) === "local-cli-token" + ) { + return null; + } + + // Direct/raw-Node callers without the central pipeline can still validate the + // CLI token here, including the trusted peer-locality stamp path. if (await isCliTokenAuthValid(request)) { return null; } diff --git a/src/lib/arenaEloSync.ts b/src/lib/arenaEloSync.ts index 75584296b0..fa4284b2c5 100644 --- a/src/lib/arenaEloSync.ts +++ b/src/lib/arenaEloSync.ts @@ -539,7 +539,7 @@ export function getArenaEloSyncStatus(): SyncStatus { }; } -// ─── Init (called from server-init.ts) ─────────────────── +// ─── Init (called from instrumentation-node.ts) ─────────────────── /** * Initialize Arena ELO sync if enabled via feature flag configuration. diff --git a/src/lib/catalog/openrouterProviderStats.ts b/src/lib/catalog/openrouterProviderStats.ts index 1f3d8472ca..5c1d836842 100644 --- a/src/lib/catalog/openrouterProviderStats.ts +++ b/src/lib/catalog/openrouterProviderStats.ts @@ -332,7 +332,7 @@ function startPeriodicSync(intervalMs?: number): void { } /** - * Boot entry point — call once from server-init.ts. + * Boot entry point — called once from instrumentation-node.ts. * On by default; opt out via OPENROUTER_PROVIDER_STATS_ENABLED=false. */ export function initOpenRouterProviderStatsSync(): boolean { diff --git a/src/lib/cli-helper/tool-detector.ts b/src/lib/cli-helper/tool-detector.ts index 8a584123ab..38a2b5d289 100644 --- a/src/lib/cli-helper/tool-detector.ts +++ b/src/lib/cli-helper/tool-detector.ts @@ -104,7 +104,8 @@ async function detectBinaryWindows( const { stdout } = await execFileImpl(located.commandPath, ["--version"], { timeout: 5000, env, - ...(useShell ? { shell: true } : {}), + windowsHide: true, + ...(useShell ? { shell: true, windowsVerbatimArguments: true } : {}), }); return { installed: true, version: stdout.trim().replace(/^v/, "") }; } catch { diff --git a/src/lib/combos/comboContext.ts b/src/lib/combos/comboContext.ts index 0c7744cc41..ff1709c0ae 100644 --- a/src/lib/combos/comboContext.ts +++ b/src/lib/combos/comboContext.ts @@ -11,7 +11,7 @@ import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo"; import { getCanonicalModelMetadata } from "@/lib/modelMetadataRegistry"; -import { getTokenLimit } from "@omniroute/open-sse/services/contextManager"; +import { getSourcedTokenLimit } from "@omniroute/open-sse/services/contextManager"; import { buildAliasMaps, getComboTargetModelId } from "@/app/api/v1/models/catalogProviderMaps"; /* ─── helpers ───────────────────────────────────────────────── */ @@ -96,10 +96,7 @@ export function computeComboContextLength( const providerId = canonicalMeta.provider || resolvedTarget.providerId; const modelId = canonicalMeta.model || resolvedTarget.modelId; - const targetCtx = - (isPositiveFiniteNumber(canonicalMeta.limits.contextWindow) - ? canonicalMeta.limits.contextWindow - : undefined) ?? getTokenLimit(providerId, modelId); + const targetCtx = getSourcedTokenLimit(providerId, modelId, canonicalMeta.limits.contextWindow); if (isPositiveFiniteNumber(targetCtx)) { contextValues.push(targetCtx); diff --git a/src/lib/consoleInterceptor.ts b/src/lib/consoleInterceptor.ts index 19e245217e..9a56f2a550 100644 --- a/src/lib/consoleInterceptor.ts +++ b/src/lib/consoleInterceptor.ts @@ -12,6 +12,7 @@ import { appendFileSync, existsSync, mkdirSync } from "fs"; import { dirname, resolve } from "path"; +import { format } from "util"; import { getAppLogFilePath, getAppLogToFile } from "./logEnv"; const logToFile = getAppLogToFile(); @@ -91,18 +92,55 @@ function ensureDir() { } } +// Level tokens the in-repo tagged logger puts in front of the component. Keep in sync with +// LEVELS in open-sse/utils/logger.ts — that module keeps the type internal, so the list +// cannot be imported today. +const LEVEL_TOKENS = new Set(["DEBUG", "INFO", "WARN", "WARNING", "ERROR", "FATAL", "TRACE"]); + /** * Try to extract component name from message patterns like [COMPONENT] or [component]. + * + * The tagged logger emits `[LEVEL] [TAG] message` (open-sse/utils/logger.ts), so taking the + * first bracket recorded the level as the component and dropped the real one — the log stopped + * being filterable by component, which is the point of the field. Level tokens are skipped; the + * level already travels in the entry's own `level` field. */ function extractComponent(msg: string): string { - const match = msg.match(/^\[([^\]]+)\]/); - return match ? match[1] : "app"; + let rest = msg; + // Bounded: a message never legitimately carries more than a level plus a tag. + for (let depth = 0; depth < 3; depth++) { + const match = rest.match(/^\s*\[([^\]]+)\]/); + if (!match) break; + const token = match[1].trim(); + if (!LEVEL_TOKENS.has(token.toUpperCase())) return token; + rest = rest.slice(match[0].length); + } + return "app"; } /** * Convert arguments to a string message, handling objects and errors. + * + * `console.*` takes a printf-style format string, and first-party callers rely on it: + * src/server/ws/liveServer.ts passes `%s`/`%d` deliberately, to keep client-supplied values out + * of the format slot (CWE-134). Joining the arguments instead of formatting them left the + * placeholders literal and the values trailing without their labels, so a reader had to open the + * source to know which value was which. `util.format` appends surplus arguments exactly like the + * join below, so calls without a format string keep their current output. + * + * Guarded against an Error in `rest`: many call sites build the first argument from dynamic, + * non-format-string content (e.g. `` `[TAG] Failed to compile hook "${row.name}":` ``) that can + * coincidentally contain a `%s`/`%d`-like substring. If a trailing arg is an Error, util.format + * would silently consume it as a substitution value and drop its stack — skip the printf path + * so that Error still gets the full `message\nstack` treatment below. */ function argsToMessage(args: unknown[]): string { + const [first, ...rest] = args; + const hasFormatString = typeof first === "string" && /%[sdifjoOc%]/.test(first); + const restHasError = rest.some((arg) => arg instanceof Error); + if (hasFormatString && !restHasError) { + return format(first, ...rest); + } return args .map((arg) => { if (arg instanceof Error) return `${arg.message}\n${arg.stack || ""}`; diff --git a/src/lib/copilot/tools.ts b/src/lib/copilot/tools.ts index a4bc59321c..095f7cf4cd 100644 --- a/src/lib/copilot/tools.ts +++ b/src/lib/copilot/tools.ts @@ -121,11 +121,7 @@ export const COPILOT_TOOLS: CopilotTool[] = [ let output = `**${combos.length} combo(s) configured**\n\n`; for (const c of combos as any[]) { const active = c.isActive ? "✅" : "⛔"; - const targets = c.targets - ? typeof c.targets === "string" - ? JSON.parse(c.targets).length - : c.targets.length - : 0; + const targets = Array.isArray(c.models) ? c.models.length : 0; output += `${active} **${c.name}** — strategy: \`${c.strategy}\` — ${targets} target(s)\n`; } return output; @@ -165,7 +161,7 @@ export const COPILOT_TOOLS: CopilotTool[] = [ const combo = await createCombo({ name, strategy, - targets: JSON.stringify(targets), + models: targets, isActive: true, }); const anyCombo = combo as any; diff --git a/src/lib/credentialHealth/probePolicy.ts b/src/lib/credentialHealth/probePolicy.ts new file mode 100644 index 0000000000..6b4d387e36 --- /dev/null +++ b/src/lib/credentialHealth/probePolicy.ts @@ -0,0 +1,23 @@ +const DEFAULT_SWEEP_INTERVAL_MS = 300_000; +const INCONCLUSIVE_RECHECK_MIN_MS = 30 * 60_000; +const INCONCLUSIVE_RECHECK_MULTIPLIER = 6; +const INCONCLUSIVE_WARNING_MARKER = "credential validity is inconclusive"; + +export function isCredentialProbeInconclusive(result: { + valid?: boolean; + warning?: unknown; +}): boolean { + return ( + result.valid === true && + typeof result.warning === "string" && + result.warning.toLowerCase().includes(INCONCLUSIVE_WARNING_MARKER) + ); +} + +export function resolveInconclusiveProbeRecheckDelayMs(sweepIntervalMs: number): number { + const interval = + Number.isFinite(sweepIntervalMs) && sweepIntervalMs > 0 + ? sweepIntervalMs + : DEFAULT_SWEEP_INTERVAL_MS; + return Math.max(INCONCLUSIVE_RECHECK_MIN_MS, interval * INCONCLUSIVE_RECHECK_MULTIPLIER); +} diff --git a/src/lib/credentialHealth/scheduler.ts b/src/lib/credentialHealth/scheduler.ts index 580111b9a1..a407f6a326 100644 --- a/src/lib/credentialHealth/scheduler.ts +++ b/src/lib/credentialHealth/scheduler.ts @@ -11,7 +11,8 @@ * Schedule: * - Initial delay: 30s after server boot (allows DB migrations to complete) * - Interval: configurable via CREDENTIAL_HEALTH_CHECK_INTERVAL (default 5 min) - * - OAuth connections: tested less frequently (2x interval) + * - Per-connection override: provider_connections.healthCheckInterval (minutes, + * 0 = never test this connection) paces each connection individually * - Backoff on failure: 5min -> 10min -> 30min -> max 2h * - Resets to default on success */ @@ -23,6 +24,10 @@ import { removeCredentialHealth, initCredentialCache, } from "@/lib/credentialHealth/cache"; +import { + isCredentialProbeInconclusive, + resolveInconclusiveProbeRecheckDelayMs, +} from "@/lib/credentialHealth/probePolicy"; import { emit } from "@/lib/events/eventBus"; import { isAutomatedTestProcess } from "@/shared/utils/testProcess"; import { SEARCH_VALIDATOR_CONFIGS } from "@/lib/providers/validation/searchProviders"; @@ -31,7 +36,6 @@ import { SEARCH_VALIDATOR_CONFIGS } from "@/lib/providers/validation/searchProvi const BACKOFF_SCHEDULE = [300_000, 600_000, 1_800_000, 7_200_000]; // 5min, 10min, 30min, 2h const INITIAL_DELAY_MS = 30_000; // Wait for server boot -const OAUTH_INTERVAL_MULTIPLIER = 2; // OAuth tested 2x less frequently const CONCURRENCY_LIMIT = 5; // Max simultaneous connection tests const LOG_PREFIX = "[CredentialHealth]"; const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); @@ -90,30 +94,37 @@ function getSweepInterval(): number { return 300_000; // default 5 min } +/** + * Resolve the per-connection sweep interval (ms). + * - `healthCheckInterval > 0` → minutes × 60 000 (per-connection override) + * - `healthCheckInterval <= 0` → null (never test this connection — opt-out) + * - absent → global env interval (getSweepInterval()) + */ +function getConnIntervalMs(conn: { healthCheckInterval?: number | null }): number | null { + const minutes = conn.healthCheckInterval; + if (minutes === null || minutes === undefined) return getSweepInterval(); + if (minutes <= 0) return null; + return minutes * 60_000; +} + function getNextBackoff(connectionId: string): number { const state = getSchedulerState(); const failures = state.failureCounts.get(connectionId) ?? 0; return BACKOFF_SCHEDULE[Math.min(failures, BACKOFF_SCHEDULE.length - 1)]; } -function getMaxFailuresAcrossConnections(): number { - const state = getSchedulerState(); - let max = 0; - for (const count of state.failureCounts.values()) { - if (count > max) max = count; - } - return max; -} - // ── Core Sweep Logic ───────────────────────────────────────────────────── async function testConnection( connectionId: string, provider: string, - isOAuth: boolean + intervalMs: number | null ): Promise { const startTime = Date.now(); + // Per-connection opt-out: healthCheckInterval <= 0 → never test. + if (intervalMs === null) return; + let oldStatus: string | undefined; try { const { getCredentialHealth } = await import("@/lib/credentialHealth/cache"); @@ -123,16 +134,44 @@ async function testConnection( try { const result = await testSingleConnection(connectionId); - // A deliberate lease skip must not rewrite the health cache. - if (result.skipped === true) return; + // Deliberate skips never rewrite credential health or failure state. + // Unsupported validation capability is stable enough to honor the + // connection's configured interval; an exclusive-lease skip intentionally + // remains due on the next global sweep so recovery is not delayed. + if (result.skipped === true) { + const diagnosis = result.diagnosis as { code?: string } | undefined; + if (diagnosis?.code === "unsupported") { + getSchedulerState().perConnTiming.set(connectionId, { + lastAttemptAt: startTime, + nextAttemptAt: startTime + intervalMs, + }); + } + return; + } const latencyMs = Date.now() - startTime; const state = getSchedulerState(); if (result.valid) { - // Success — reset failure count + timing, update cache + // Success resets failure state. Credential-inconclusive probes remain + // active but are checked less often because repeating an expensive probe + // does not add authentication evidence; an ordinary success is paced by + // the per-connection interval (absent → global sweep interval). state.failureCounts.delete(connectionId); - state.perConnTiming.delete(connectionId); + + if (isCredentialProbeInconclusive(result)) { + const recheckDelayMs = resolveInconclusiveProbeRecheckDelayMs(getSweepInterval()); + state.perConnTiming.set(connectionId, { + lastAttemptAt: startTime, + nextAttemptAt: Date.now() + recheckDelayMs, + }); + } else { + state.perConnTiming.set(connectionId, { + lastAttemptAt: startTime, + nextAttemptAt: startTime + intervalMs, + }); + } + setCredentialHealth( connectionId, provider, @@ -228,6 +267,7 @@ export async function sweep(): Promise { id: string; provider: string; authType?: string; + healthCheckInterval?: number | null; }>; try { @@ -244,6 +284,7 @@ export async function sweep(): Promise { id: string; provider: string; authType?: string; + healthCheckInterval?: number | null; }>; } catch (err) { console.error(LOG_PREFIX, "Failed to load provider connections:", err); @@ -254,12 +295,14 @@ export async function sweep(): Promise { // Compute backoff per connection — skip connections that aren't due yet const now = Date.now(); - const interval = getSweepInterval(); const dueConnections = connections.filter((conn) => { + const intervalMs = getConnIntervalMs(conn); + // Per-connection opt-out: never tested. + if (intervalMs === null) return false; const state_ = getSchedulerState(); const timing = state_.perConnTiming.get(conn.id); - // No timing entry = never tested or healthy → due now + // No timing entry = never tested since boot → due now if (!timing) return true; // Time-based: due when the current time has passed the next attempt time return now >= timing.nextAttemptAt; @@ -280,7 +323,7 @@ export async function sweep(): Promise { for (const batch of batches) { await Promise.allSettled( - batch.map((conn) => testConnection(conn.id, conn.provider, conn.authType === "oauth")) + batch.map((conn) => testConnection(conn.id, conn.provider, getConnIntervalMs(conn))) ); } } finally { diff --git a/src/lib/db/adapters/driverFactory.ts b/src/lib/db/adapters/driverFactory.ts index d696d1388d..134e984c28 100644 --- a/src/lib/db/adapters/driverFactory.ts +++ b/src/lib/db/adapters/driverFactory.ts @@ -1,5 +1,6 @@ import { runtimeRequire as _require } from "./runtimeRequire"; import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { createBetterSqliteAdapter } from "./betterSqliteAdapter"; import { createBunSqliteAdapter, type BunSqliteDatabaseLike } from "./bunSqliteAdapter"; import { @@ -11,6 +12,70 @@ import type { SqliteAdapter } from "./types"; type DriverLoader = (moduleName: string) => unknown; +type SpawnSyncLike = ( + command: string, + args: string[], + options: { timeout: number; stdio: "ignore"; cwd: string; windowsHide: boolean } +) => { status: number | null }; + +/** Returns whether better-sqlite3 may be loaded in this process. */ +export type DriverProbe = () => boolean; + +/** + * #10627 — Windows driver-hang guard. + * + * The sync cascade's try/catch only covers drivers that THROW on load + * (ERR_DLOPEN_FAILED, "Module did not self-register", ...). On Windows, a + * mismatched-ABI native addon can HANG inside DllMain (loader lock) instead of + * throwing — a hang never reaches the catch, so the fallback to node:sqlite / + * sql.js never runs and the first DB touch in a runtime stalls forever at ~0% + * CPU (the exact #10627 symptom: every request hangs, 0 bytes, no logs). + * + * The probe answers "can better-sqlite3 load AND open a database?" by loading + * it in a CHILD PROCESS with a bounded timeout, so a hang becomes a timed-out + * probe (verdict "bad") instead of a process-level deadlock. The verdict is + * cached per process — the child spawn happens at most once. + * + * On POSIX this is a no-op returning true: broken addons throw there, which + * the existing cascade already handles, and we don't want to pay a subprocess + * spawn on every Linux/CI boot. + */ +export function createBetterSqliteProbe(options: { + platform?: string; + execPath?: string; + spawn?: SpawnSyncLike; + timeoutMs?: number; +}): DriverProbe { + const { + platform = process.platform, + execPath = process.execPath, + spawn = spawnSync as unknown as SpawnSyncLike, + timeoutMs = 5_000, + } = options; + + let verdict: boolean | null = null; + return () => { + if (verdict !== null) return verdict; + if (platform !== "win32") { + verdict = true; + return verdict; + } + try { + const result = spawn(execPath, ["-e", "require('better-sqlite3')(':memory:')"], { + timeout: timeoutMs, + stdio: "ignore", + cwd: process.cwd(), + windowsHide: true, + }); + // status === null means the child was killed by the timeout — a hang. + verdict = result.status === 0; + } catch { + verdict = false; + } + return verdict; + }; +} + /** * The production loader for the sync driver cascade. * @@ -137,7 +202,12 @@ function getSqlJsPendingCache(): Map> { * Builds the synchronous driver cascade. Keeping the loader injectable makes * the real node:sqlite branch testable without changing the public adapter API. */ -export function createSyncDriverFactory(load: DriverLoader) { +export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?: DriverProbe) { + // #10627: when a probe is supplied, the better-sqlite3 branch is gated on it + // so a Windows DllMain hang (which never throws, so never hits the catch) + // cannot stall the request path. Default: no probe — existing callers/tests + // keep the historical throw-only behavior. + const mayLoadBetterSqlite = betterSqliteProbe ?? (() => true); return function tryOpenSync( filePath: string, options?: Record @@ -164,7 +234,7 @@ export function createSyncDriverFactory(load: DriverLoader) { } // better-sqlite3: rápido, nativo — skip em Bun - if (!process.versions.bun) { + if (!process.versions.bun && mayLoadBetterSqlite()) { try { const BetterSqlite = load("better-sqlite3") as { new (p: string, o?: object): import("better-sqlite3").Database; @@ -204,7 +274,10 @@ export function createSyncDriverFactory(load: DriverLoader) { }; } -const openSyncDriver = createSyncDriverFactory(requireSqliteDriver); +// Production wiring: the real probe (child-process, timed, cached) guards the +// better-sqlite3 branch so a hang on Windows degrades to a failover instead of +// a request-path deadlock (#10627). +const openSyncDriver = createSyncDriverFactory(requireSqliteDriver, createBetterSqliteProbe({})); /** * The installed-tarball smoke uses this paired marker to exercise the sql.js tier diff --git a/src/lib/db/backup.ts b/src/lib/db/backup.ts index 3317ddb9fe..effbaf09c0 100644 --- a/src/lib/db/backup.ts +++ b/src/lib/db/backup.ts @@ -28,7 +28,7 @@ type CountRow = { cnt?: number }; // ──────────────── Backup Config ──────────────── let _lastBackupAt = 0; -const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes +const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes — high-churn pre-write (models.dev pricing) must not copy the whole SQLite file every call (#10351) const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); // #3834: the "Keep latest backups" UI value is persisted here so it survives a page diff --git a/src/lib/db/callLogStats.ts b/src/lib/db/callLogStats.ts index e238215e66..7df9908bd1 100644 --- a/src/lib/db/callLogStats.ts +++ b/src/lib/db/callLogStats.ts @@ -239,3 +239,34 @@ export function getFallbackStats( .get(params) as FallbackStatsRow | undefined; return row ?? { total: 0, with_requested: 0, fallback_eligible: 0, fallbacks: 0 }; } + +/** + * Failure-family breakdown over `call_logs` for the usage analytics endpoint. + * Failures are rows with status >= 400 or a non-empty error summary; successes + * are excluded in SQL. Pre-migration rows and failures the classifier does not + * recognize (null family) land in the explicit `unclassified` bucket. + * + * @param whereClause - SQL WHERE clause (may be empty string) using the same + * named params as the usage_history queries. + * @param params - Named params object (string values). + */ +export function getErrorTypeBreakdown( + whereClause: string, + params: Record +): Array<{ errorType: string; count: number }> { + const db = getDbInstance(); + const rows = db + .prepare( + ` + SELECT + COALESCE(error_type, 'unclassified') AS errorType, + COUNT(*) AS count + FROM call_logs + ${whereClause} ${whereClause ? "AND" : "WHERE"} (status >= 400 OR error_summary IS NOT NULL) + GROUP BY 1 + ORDER BY count DESC, errorType ASC + ` + ) + .all(params) as Array<{ errorType: string; count: number }>; + return rows.map((row) => ({ errorType: String(row.errorType), count: Number(row.count) })); +} diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 202d76be9f..b7021be80e 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -962,6 +962,51 @@ function startDbHealthCheckScheduler(db: SqliteDatabase) { dbHealthCheckTimer.unref?.(); } +let walTruncateTimer: NodeJS.Timeout | null = null; + +function getWalTruncateIntervalMs(): number { + const rawValue = process.env.OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS; + if (typeof rawValue === "string" && rawValue.trim().length > 0) { + const parsed = Number(rawValue); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + return 6 * 60 * 60 * 1000; +} + +function clearWalTruncateScheduler() { + if (walTruncateTimer) { + clearInterval(walTruncateTimer); + walTruncateTimer = null; + } +} + +// Auto-checkpoint moves WAL pages back into the main DB file but never shrinks the WAL +// file itself; only wal_checkpoint(TRUNCATE) does, and a long-running server never closes its DB. +function startWalTruncateScheduler(db: SqliteDatabase) { + clearWalTruncateScheduler(); + if (isCloud || isBuildPhase || isAutomatedTestProcess()) return; + + const intervalMs = getWalTruncateIntervalMs(); + if (intervalMs <= 0) return; + + walTruncateTimer = setInterval(() => { + try { + if (!db.open) return; + // TRUNCATE waits for readers; under concurrent write load it can no-op without + // shrinking the file. That is expected — it retries on the next tick. + if (checkpointDb(db, "TRUNCATE")) { + console.log("[DB] Periodic SQLite WAL checkpoint completed (TRUNCATE)."); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[DB] Periodic WAL truncate failed:", message); + } + }, intervalMs); + walTruncateTimer.unref?.(); +} + export function runManagedDbHealthCheck(options?: { autoRepair?: boolean }) { const db = getDbInstance(); return runDbHealthCheck(db, { @@ -1308,6 +1353,7 @@ export function getDbInstance(): SqliteDatabase { } startDbHealthCheckScheduler(db); + startWalTruncateScheduler(db); // Log the resolved absolute DATA_DIR + SQLITE_FILE once at init so a // multi-replica / Docker volume-topology mismatch (each replica opening a // different on-disk DB → "phantom"/missing combos & connections) is @@ -1335,6 +1381,7 @@ export function pingDb(): boolean { export function closeDbInstance(options?: { checkpointMode?: CheckpointMode | null }): boolean { clearDbHealthCheckScheduler(); + clearWalTruncateScheduler(); const db = getDb(); if (!db) return false; diff --git a/src/lib/db/healthCheck.ts b/src/lib/db/healthCheck.ts index be0a9ee45e..3916580036 100644 --- a/src/lib/db/healthCheck.ts +++ b/src/lib/db/healthCheck.ts @@ -5,10 +5,7 @@ type SqliteDatabase = SqliteAdapter; type JsonRecord = Record; export type DbHealthIssueType = - | "integrity_check_failed" - | "broken_reference" - | "stale_snapshot" - | "invalid_state"; + "integrity_check_failed" | "broken_reference" | "stale_snapshot" | "invalid_state"; export interface DbHealthIssue { type: DbHealthIssueType; @@ -17,6 +14,20 @@ export interface DbHealthIssue { count: number; } +/** Derived from the adapter contract so a new driver cannot drift out of sync here. */ +export type DbDriverName = SqliteAdapter["driver"]; + +export interface DbDriverHealth { + name: DbDriverName; + /** + * True when writes are not durably backed by the database file: the `sql.js` WASM + * fallback, or an in-memory database — which the cloud/build path opens through the + * NATIVE cascade, so the driver name alone would read as healthy. + * Informative only; `isHealthy` stays defined by `issues`. + */ + degraded: boolean; +} + export interface DbHealthCheckResult { isHealthy: boolean; issues: DbHealthIssue[]; @@ -24,6 +35,17 @@ export interface DbHealthCheckResult { backupCreated: boolean; autoRepair: boolean; checkedAt: string; + driver: DbDriverHealth; +} + +const IN_MEMORY_DB_NAME = ":memory:"; + +/** PURE: describe the driver serving `db`, and whether its writes survive a crash. */ +export function describeDbDriver(db: Pick): DbDriverHealth { + return { + name: db.driver, + degraded: db.driver === "sql.js" || db.name === IN_MEMORY_DB_NAME, + }; } interface RunDbHealthCheckOptions { @@ -383,8 +405,7 @@ function repairInvalidJsonRows( function getSchemaVersionIssueCount(db: SqliteDatabase, expectedSchemaVersion: string): number { if (!hasRows(db, "db_meta")) return 0; const row = db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get() as - | { value?: string | null } - | undefined; + { value?: string | null } | undefined; const current = typeof row?.value === "string" ? row.value : null; return current === expectedSchemaVersion ? 0 : 1; } @@ -561,5 +582,6 @@ export function runDbHealthCheck( backupCreated, autoRepair, checkedAt, + driver: describeDbDriver(db), }; } diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 251ec8cda2..2b1b6269e1 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -505,6 +505,18 @@ function isSchemaAlreadyApplied( // Retroactive guard for 143_radar_local_model_state -> 153. A database // that already created the table must not execute or track it twice. return hasTable(db, "radar_local_model_state"); + case "159": + // Renumbered from 158 (collided with 158_call_logs_error_type on + // release/v3.8.50). Idempotent freepik->magnific slug rewrite: skip + // when provider_connections has no remaining freepik rows (already + // applied under 158, or a DB that never stored Freepik). + if (migration.name !== "rename_freepik_to_magnific") return false; + if (!hasTable(db, "provider_connections")) return false; + return ( + db + .prepare("SELECT 1 FROM provider_connections WHERE provider = 'freepik' LIMIT 1") + .get() == null + ); default: return false; } diff --git a/src/lib/db/migrations/158_call_logs_error_type.sql b/src/lib/db/migrations/158_call_logs_error_type.sql new file mode 100644 index 0000000000..6aa0bc9956 --- /dev/null +++ b/src/lib/db/migrations/158_call_logs_error_type.sql @@ -0,0 +1,4 @@ +-- #10670: per-call error family, set at the single write point in +-- src/lib/usage/callLogs.ts from classifyProviderError. NULL for successes +-- (analytics filters by failure in SQL, so no need for a sentinel value). +ALTER TABLE call_logs ADD COLUMN error_type TEXT DEFAULT NULL; diff --git a/src/lib/db/migrations/159_remove_mimocode_provider.sql b/src/lib/db/migrations/159_remove_mimocode_provider.sql new file mode 100644 index 0000000000..1775d31333 --- /dev/null +++ b/src/lib/db/migrations/159_remove_mimocode_provider.sql @@ -0,0 +1,22 @@ +-- 159_remove_mimocode_provider.sql +-- MiMoCode was removed from OmniRoute, but installations that configured it +-- before removal can retain provider-scoped state. Remove that stale +-- configuration for both the canonical provider id and its historical alias. +-- +-- Historical request, usage, and call-log records are intentionally preserved. + +DELETE FROM provider_connections +WHERE provider IN ('mimocode', 'mcode'); + +DELETE FROM registered_keys +WHERE provider IN ('mimocode', 'mcode'); + +DELETE FROM provider_key_limits +WHERE provider IN ('mimocode', 'mcode'); + +DELETE FROM discovery_results +WHERE provider_id IN ('mimocode', 'mcode'); + +DELETE FROM key_value +WHERE namespace = 'customModels' + AND key IN ('mimocode', 'mcode'); diff --git a/src/lib/db/migrations/160_rename_freepik_to_magnific.sql b/src/lib/db/migrations/160_rename_freepik_to_magnific.sql new file mode 100644 index 0000000000..3c38e20e39 --- /dev/null +++ b/src/lib/db/migrations/160_rename_freepik_to_magnific.sql @@ -0,0 +1,41 @@ +-- Canonical provider id is now `magnific`. Freepik was the previous slug +-- (Magnific started as Freepik's developer API). Rewrite stored rows so +-- dashboard cards, credentials, and usage stay attached after the rename. +-- `freepik` remains a runtime alias for old URLs and `freepik/` ids. + +UPDATE provider_connections SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE usage_history SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE call_logs SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE registered_keys SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE provider_key_limits SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE quota_snapshots SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE provider_plans SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE hourly_usage_summary SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE daily_usage_summary SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE provider_quota_reset_events SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE session_account_affinity SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE model_context_overrides SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE model_capability_overrides SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE session_model_history SET provider = 'magnific' WHERE provider = 'freepik'; +UPDATE tier_assignments SET provider = 'magnific' WHERE provider = 'freepik'; + +UPDATE usage_history +SET model = 'magnific' || substr(model, 8) +WHERE model LIKE 'freepik/%'; + +UPDATE call_logs +SET model = 'magnific' || substr(model, 8) +WHERE model LIKE 'freepik/%'; + +UPDATE hourly_usage_summary +SET model = 'magnific' || substr(model, 8) +WHERE model LIKE 'freepik/%'; + +UPDATE daily_usage_summary +SET model = 'magnific' || substr(model, 8) +WHERE model LIKE 'freepik/%'; + +UPDATE key_value +SET key = 'magnific' +WHERE namespace IN ('cliToolLastConfig', 'cliToolInitialConfig') + AND key = 'freepik'; diff --git a/src/lib/db/modelCapabilityOverrides.ts b/src/lib/db/modelCapabilityOverrides.ts index 4dcaae9e83..ced5a2bde1 100644 --- a/src/lib/db/modelCapabilityOverrides.ts +++ b/src/lib/db/modelCapabilityOverrides.ts @@ -1,17 +1,34 @@ +import { + parseReasoningEffortsOverride, + REASONING_EFFORT_OVERRIDE_VALUES, + type ReasoningEffortOverrideValue, +} from "@/shared/reasoning/reasoningEffortsOverride"; import { getDbInstance } from "./core"; import { invalidateDbCache } from "./readCache"; -export type ModelCapabilityOverrideKey = "max_input_tokens" | "max_output_tokens" | "max_token"; +export type NumericModelCapabilityOverrideKey = + | "max_input_tokens" + | "max_output_tokens" + | "max_token"; +export type ModelCapabilityOverrideKey = NumericModelCapabilityOverrideKey | "reasoning_efforts"; -export interface ModelCapabilityOverride { +interface ModelCapabilityOverrideBase { provider: string; modelId: string; target: string; - key: ModelCapabilityOverrideKey; - value: number; refreshedAt: string; } +export type ModelCapabilityOverride = + | (ModelCapabilityOverrideBase & { + key: NumericModelCapabilityOverrideKey; + value: number; + }) + | (ModelCapabilityOverrideBase & { + key: "reasoning_efforts"; + value: ReasoningEffortOverrideValue[]; + }); + interface OverrideRow { provider: string; model_id: string; @@ -20,14 +37,27 @@ interface OverrideRow { refreshed_at: string; } -function isSupportedKey(value: unknown): value is ModelCapabilityOverrideKey { +function isNumericKey(value: unknown): value is NumericModelCapabilityOverrideKey { return value === "max_input_tokens" || value === "max_output_tokens" || value === "max_token"; } +function isSupportedKey(value: unknown): value is ModelCapabilityOverrideKey { + return isNumericKey(value) || value === "reasoning_efforts"; +} + function isPositiveInteger(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value > 0; } +function isReasoningEfforts(value: unknown): value is ReasoningEffortOverrideValue[] { + if (!Array.isArray(value) || value.length === 0) return false; + const allowed = new Set(REASONING_EFFORT_OVERRIDE_VALUES); + return ( + value.every((entry) => typeof entry === "string" && allowed.has(entry)) && + new Set(value).size === value.length + ); +} + export function parseModelOverrideTarget( target: unknown ): { provider: string; modelId: string } | null { @@ -51,29 +81,33 @@ function toOverride(row: OverrideRow): ModelCapabilityOverride | null { return null; } - if (!isPositiveInteger(parsedValue)) return null; - - return { + const base: ModelCapabilityOverrideBase = { provider: row.provider, modelId: row.model_id, target: `${row.provider}/${row.model_id}`, - key: row.override_key, - value: parsedValue, refreshedAt: row.refreshed_at, }; + if (row.override_key === "reasoning_efforts") { + return isReasoningEfforts(parsedValue) + ? { ...base, key: row.override_key, value: parsedValue } + : null; + } + return isPositiveInteger(parsedValue) + ? { ...base, key: row.override_key, value: parsedValue } + : null; } -/** Nested provider → model → max_token map used by build-local snapshots. */ +/** Nested provider → model → numeric override map used by build-local snapshots. */ export type NestedMaxTokenOverrideMap = ReadonlyMap>; export function getModelCapabilityOverride( provider: string | null | undefined, modelId: string | null | undefined, - key: ModelCapabilityOverrideKey, + key: NumericModelCapabilityOverrideKey, bulkMaxTokenOverrides?: NestedMaxTokenOverrideMap | null ): number | null { const target = parseModelOverrideTarget(`${provider || ""}/${modelId || ""}`); - if (!target || !isSupportedKey(key)) return null; + if (!target || !isNumericKey(key)) return null; if (bulkMaxTokenOverrides) { // The caller pairs the bulk map with the key it was built for @@ -89,7 +123,30 @@ export function getModelCapabilityOverride( ) .get(target.provider, target.modelId, key) as OverrideRow | undefined; const override = row ? toOverride(row) : null; - return override?.value ?? null; + return override && override.key !== "reasoning_efforts" ? override.value : null; + } catch { + return null; + } +} + +export function getReasoningEffortsOverride( + provider: string | null | undefined, + modelId: string | null | undefined, + bulk?: ReadonlyMap> | null +): readonly ReasoningEffortOverrideValue[] | null { + const target = parseModelOverrideTarget(`${provider || ""}/${modelId || ""}`); + if (!target) return null; + if (bulk) return bulk.get(target.provider)?.get(target.modelId) ?? null; + + try { + const row = getDbInstance() + .prepare( + "SELECT provider, model_id, override_key, override_value, refreshed_at " + + "FROM model_capability_overrides WHERE provider = ? AND model_id = ? AND override_key = 'reasoning_efforts'" + ) + .get(target.provider, target.modelId) as OverrideRow | undefined; + const override = row ? toOverride(row) : null; + return override?.key === "reasoning_efforts" ? override.value : null; } catch { return null; } @@ -98,10 +155,22 @@ export function getModelCapabilityOverride( export function setModelCapabilityOverride( target: string, key: ModelCapabilityOverrideKey, - value: number + value: number | string | readonly string[] ): boolean { const parsedTarget = parseModelOverrideTarget(target); - if (!parsedTarget || !isSupportedKey(key) || !isPositiveInteger(value)) return false; + if (!parsedTarget || !isSupportedKey(key)) return false; + + let normalizedValue: number | ReasoningEffortOverrideValue[]; + if (key === "reasoning_efforts") { + const parsed = Array.isArray(value) + ? parseReasoningEffortsOverride(value.join(",")) + : parseReasoningEffortsOverride(value); + if (!parsed.ok) return false; + normalizedValue = parsed.efforts; + } else { + if (!isPositiveInteger(value)) return false; + normalizedValue = value; + } getDbInstance() .prepare( @@ -109,7 +178,7 @@ export function setModelCapabilityOverride( "(provider, model_id, override_key, override_value, refreshed_at) " + "VALUES (?, ?, ?, ?, datetime('now'))" ) - .run(parsedTarget.provider, parsedTarget.modelId, key, JSON.stringify(value)); + .run(parsedTarget.provider, parsedTarget.modelId, key, JSON.stringify(normalizedValue)); invalidateDbCache("model-capabilities"); return true; } diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index c0c4912909..56e5c20f29 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -485,12 +485,19 @@ export async function getSyncedAvailableModels( return Array.from(map.values()); } +export const SYNCED_AVAILABLE_MODELS_MALFORMED = Symbol("syncedAvailableModelsMalformed"); +export type SyncedAvailableModelsByConnection = Record & { + [SYNCED_AVAILABLE_MODELS_MALFORMED]?: true; +}; + /** * Get synced available models for a provider grouped by connection id. + * A non-enumerable symbol marks malformed persisted rows so strict callers can + * fail closed without changing the existing Record-shaped API. */ export async function getSyncedAvailableModelsByConnection( providerId: string -): Promise> { +): Promise { const db = getDbInstance(); const prefix = `${providerId}:`; const rows = db @@ -498,7 +505,7 @@ export async function getSyncedAvailableModelsByConnection( "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?" ) .all(`${prefix}%`); - const result: Record = {}; + const result: SyncedAvailableModelsByConnection = {}; for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null || !key.startsWith(prefix)) continue; @@ -506,7 +513,10 @@ export async function getSyncedAvailableModelsByConnection( const connectionId = key.slice(prefix.length); result[connectionId] = normalizeSyncedAvailableModels(JSON.parse(value), providerId); } catch { - // Ignore malformed legacy entries. + Object.defineProperty(result, SYNCED_AVAILABLE_MODELS_MALFORMED, { + value: true, + enumerable: false, + }); } } return result; diff --git a/src/lib/db/providerLimits.ts b/src/lib/db/providerLimits.ts index 427cc4a1ef..677c067a6f 100644 --- a/src/lib/db/providerLimits.ts +++ b/src/lib/db/providerLimits.ts @@ -1,4 +1,7 @@ -import { sanitizeGrokBillingStatus, type GrokBillingStatus } from "@/shared/utils/grokBilling"; +import { + sanitizeProviderBillingStatus, + type ProviderBillingStatus, +} from "@/shared/utils/providerBilling"; import { getDbInstance, isBuildPhase, isCloud } from "./core"; type JsonRecord = Record; @@ -26,7 +29,7 @@ export interface ProviderLimitsCacheEntry { fetchedAt: string; source?: string | null; bankedResetCredits?: number; - billing?: GrokBillingStatus; + billing?: ProviderBillingStatus; } const PROVIDER_LIMITS_CACHE_NAMESPACE = "providerLimitsCache"; @@ -45,7 +48,7 @@ function toRecord(value: unknown): JsonRecord | null { function sanitizeCacheEntryForStorage(entry: ProviderLimitsCacheEntry): ProviderLimitsCacheEntry { const { billing: rawBilling, ...rest } = entry; - const billing = sanitizeGrokBillingStatus(rawBilling); + const billing = sanitizeProviderBillingStatus(rawBilling); return billing ? { ...rest, billing } : rest; } @@ -58,7 +61,7 @@ function normalizeCacheEntry(value: unknown): ProviderLimitsCacheEntry | null { if (!fetchedAt) return null; const bankedResetCredits = Number(record.bankedResetCredits); - const billing = sanitizeGrokBillingStatus(record.billing); + const billing = sanitizeProviderBillingStatus(record.billing); return { quotas: toRecord(record.quotas), diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 46d9742e61..3b02933e11 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -19,15 +19,46 @@ import { } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { invalidateReasoningRoutingRuleCache } from "./reasoningRoutingRules"; import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; +import { ensureCodexFingerprintSeed } from "@omniroute/open-sse/config/codexIdentity.ts"; import { bumpProxyConfigGeneration, getSettings } from "./settings"; import { getStoredManagementPassword, isBcryptHash, verifyManagementPassword, } from "@/lib/auth/managementPassword"; -import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup"; +import { + webSessionCredentialKey, + parseProviderSpecificData, + isMatchingOauthIdentity, +} from "./webSessionDedup"; import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection"; import { reconcileCodexUsageHistory } from "./providers/usageIdentityReconciliation"; + +/** + * normalizeProviderSpecificData + the Codex fingerprint-seed invariant: Codex + * OAuth connections whose convergence mode derives account-scoped identities + * (device/session/full — the default session included) carry a persisted + * random seed (`codexFingerprintSeed`) as the derivation source. Created here + * at the persistence choke point so every write path (manual create, OAuth + * persist, edit, import) is covered; the seed is never regenerated once valid, + * so identities stay put across saves. Pre-seed connections rotate from the + * legacy connection-id derivation exactly once on their next write — the + * OmniRoute analog of sub2api's migration-225 backfill (v0.1.178, #5696). + */ +function normalizeConnectionProviderSpecificData( + provider: string | null, + providerSpecificData: unknown, + credentials: { accessToken?: unknown; refreshToken?: unknown }, + existingProviderSpecificData?: unknown +) { + const normalized = normalizeProviderSpecificData(provider, providerSpecificData); + if (provider !== "codex") return normalized; + return ensureCodexFingerprintSeed( + normalized, + credentials, + (existingProviderSpecificData as Record | null) ?? null + ); +} import { withNullableMaxConcurrent, withNullableQuotaWindowThresholds, @@ -353,9 +384,10 @@ export async function createProviderConnection(data: JsonRecord) { await assertApiKeyIsNotManagementPassword(data.apiKey); const db = getDbInstance() as unknown as DbLike; const now = new Date().toISOString(); - const normalizedProviderSpecificData = normalizeProviderSpecificData( + const normalizedProviderSpecificData = normalizeConnectionProviderSpecificData( toStringOrNull(data.provider), - data.providerSpecificData + data.providerSpecificData, + data ); let existing: JsonRecord | null = null; @@ -407,30 +439,25 @@ export async function createProviderConnection(data: JsonRecord) { } } else { // For other providers (or Codex without workspaceId), match on email — - // disambiguated by providerSpecificData.username when present on both - // sides. Two different IdPs can share the same email address (e.g. a - // Google account and a HuggingFace account); matching on email alone - // would silently overwrite the other account's connection on the - // second login. Only fall back to the bare email-only match when - // neither side carries a username (legacy rows created before this - // disambiguation existed). + // disambiguated by providerSpecificData.username and/or + // providerSpecificData.profileArn when present on both sides. Two + // different IdPs (or two distinct Kiro/AWS profiles authenticated via + // the same email-carrying IdP) can share the same email address; + // matching on email alone would silently overwrite the other + // account's connection on the second login. Only fall back to the + // bare email-only match when neither side carries a username/profileArn + // (legacy rows created before this disambiguation existed). const incomingUsername = toStringOrNull(providerSpecificData.username); + const incomingProfileArn = toStringOrNull(providerSpecificData.profileArn); const emailMatches = db .prepare( "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND email = ?" ) .all(data.provider, data.email) as JsonRecord[]; existing = - emailMatches.find((row) => { - const existingUsername = toStringOrNull( - parseProviderSpecificData(row.provider_specific_data)?.username - ); - if (incomingUsername && existingUsername) { - return incomingUsername === existingUsername; - } - if (incomingUsername || existingUsername) return false; - return true; - }) || null; + emailMatches.find((row) => + isMatchingOauthIdentity(row, incomingUsername, incomingProfileArn) + ) || null; } } else if (data.authType === "apikey") { // Name-based upsert (existing behavior): same provider + same name → update. @@ -483,9 +510,11 @@ export async function createProviderConnection(data: JsonRecord) { const rawExisting = toRecord(rowToCamel(existing)); const decryptedExisting = decryptConnectionFields({ ...rawExisting }); const merged: JsonRecord = { ...decryptedExisting, ...data, updatedAt: now }; - merged.providerSpecificData = normalizeProviderSpecificData( + merged.providerSpecificData = normalizeConnectionProviderSpecificData( toStringOrNull(merged.provider), - merged.providerSpecificData + merged.providerSpecificData, + merged, + decryptedExisting.providerSpecificData ); const persistence: JsonRecord = { ...merged }; for (const field of CONNECTION_CREDENTIAL_FIELDS) { @@ -805,14 +834,17 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { // on every unrelated field edit. await assertApiKeyIsNotManagementPassword(data.apiKey); + const existingCamel = toRecord(rowToCamel(existing)); const merged: JsonRecord = { - ...toRecord(rowToCamel(existing)), + ...existingCamel, ...data, updatedAt: new Date().toISOString(), }; - merged.providerSpecificData = normalizeProviderSpecificData( + merged.providerSpecificData = normalizeConnectionProviderSpecificData( toStringOrNull(merged.provider), - merged.providerSpecificData + merged.providerSpecificData, + merged, + existingCamel.providerSpecificData ); // Mirror the sanitization the create path applies — keep the returned // object in lockstep with what we persist. @@ -857,6 +889,11 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { ); } +export { + updateCodexScopedQuotaState, + updateCodexScopeCooldown, +} from "./providers/codexAccountState"; + /** * Atomic conditional clear of recoverable error state on a connection row. * diff --git a/src/lib/db/providers/codexAccountState.ts b/src/lib/db/providers/codexAccountState.ts new file mode 100644 index 0000000000..77600de3bd --- /dev/null +++ b/src/lib/db/providers/codexAccountState.ts @@ -0,0 +1,119 @@ +import { backupDbFile } from "../backup"; +import { getDbInstance, rowToCamel } from "../core"; +import { invalidateDbCache } from "../readCache"; +import { toRecord } from "./columns"; + +type JsonRecord = Record; + +interface StatementLike { + get: (...params: unknown[]) => TRow | undefined; + run: (...params: unknown[]) => { changes?: number }; +} + +interface DbLike { + prepare: (sql: string) => StatementLike; + transaction: (fn: () => T) => () => T; +} + +type CodexScopedQuotaPatch = { + quotaState?: JsonRecord; + exhaustedWindow?: "5h" | "7d" | null; + rateLimitedUntil?: string; + rateLimitSource?: "fallback" | "quota_reset"; +}; + +/** + * Atomically merge one virtual Codex child's quota evidence into its persisted parent. + * The transaction reads the latest row so sibling child state cannot be lost. + */ +export async function updateCodexScopedQuotaState( + id: string, + scope: "codex" | "spark", + patch: CodexScopedQuotaPatch +): Promise { + const db = getDbInstance() as unknown as DbLike; + const candidate = db.prepare("SELECT provider FROM provider_connections WHERE id = ?").get(id); + if (toRecord(candidate).provider !== "codex") return null; + + backupDbFile("pre-write"); + const persisted = db.transaction(() => { + const existing = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id); + if (!existing) return null; + + const existingRecord = toRecord(rowToCamel(existing)); + if (existingRecord.provider !== "codex") return null; + const providerSpecificData = toRecord(existingRecord.providerSpecificData); + const nextProviderSpecificData: JsonRecord = { ...providerSpecificData }; + + if (patch.quotaState) { + const quotaByScope = toRecord(providerSpecificData.codexQuotaStateByScope); + nextProviderSpecificData.codexQuotaStateByScope = { + ...quotaByScope, + [scope]: patch.quotaState, + }; + nextProviderSpecificData.codexQuotaState = { + ...patch.quotaState, + scope, + updatedAt: patch.quotaState.observedAt, + }; + } + + if (patch.exhaustedWindow !== undefined) { + const exhaustedByScope = { ...toRecord(providerSpecificData.codexExhaustedWindowByScope) }; + if (patch.exhaustedWindow) exhaustedByScope[scope] = patch.exhaustedWindow; + else delete exhaustedByScope[scope]; + nextProviderSpecificData.codexExhaustedWindowByScope = exhaustedByScope; + if (patch.exhaustedWindow) { + nextProviderSpecificData.codexExhaustedWindow = patch.exhaustedWindow; + } else { + delete nextProviderSpecificData.codexExhaustedWindow; + } + } + + if (patch.rateLimitedUntil) { + const scopeCooldowns = toRecord(providerSpecificData.codexScopeRateLimitedUntil); + const sourceByScope = toRecord(providerSpecificData.codexScopeRateLimitSource); + const existingCooldownMs = + typeof scopeCooldowns[scope] === "string" + ? new Date(scopeCooldowns[scope] as string).getTime() + : NaN; + const existingIsAuthoritative = + sourceByScope[scope] === "quota_reset" && + patch.rateLimitSource !== "quota_reset" && + Number.isFinite(existingCooldownMs) && + existingCooldownMs > Date.now(); + nextProviderSpecificData.codexScopeRateLimitedUntil = { + ...scopeCooldowns, + [scope]: existingIsAuthoritative ? scopeCooldowns[scope] : patch.rateLimitedUntil, + }; + nextProviderSpecificData.codexScopeRateLimitSource = { + ...sourceByScope, + [scope]: existingIsAuthoritative + ? sourceByScope[scope] + : (patch.rateLimitSource ?? "fallback"), + }; + } + + db.prepare( + `UPDATE provider_connections + SET provider_specific_data = ?, updated_at = ? + WHERE id = ?` + ).run(JSON.stringify(nextProviderSpecificData), new Date().toISOString(), id); + return nextProviderSpecificData; + })(); + + if (persisted) invalidateDbCache("connections"); + return persisted; +} + +/** Persist one child cooldown through the shared scoped quota-state transaction. */ +export async function updateCodexScopeCooldown( + id: string, + scope: "codex" | "spark", + rateLimitedUntil: string +): Promise { + return updateCodexScopedQuotaState(id, scope, { + rateLimitedUntil, + rateLimitSource: "fallback", + }); +} diff --git a/src/lib/db/usageLogs.ts b/src/lib/db/usageLogs.ts index 7549f93880..53c572785b 100644 --- a/src/lib/db/usageLogs.ts +++ b/src/lib/db/usageLogs.ts @@ -1,11 +1,6 @@ /** - * db/usageLogs.ts — Read-only aggregation queries over `usage_logs` - * extracted from the /api/analytics/auto-routing route handler. - * - * Hard Rule #5: routes must not embed raw SQL — these queries live here so the - * /api/analytics/auto-routing route can delegate. - * - * Sliced out of #3500 (usage_logs cluster, slice 4). + * Read-only auto-routing aggregations over `call_logs`. + * `requested_model` keeps the client auto/* id after routing resolves a target. */ import { getDbInstance } from "./core"; @@ -19,8 +14,7 @@ export interface AutoRoutingTotalResult { } /** - * Returns the total number of requests routed through auto/ prefix models. - * Matches model = 'auto' OR model LIKE 'auto/%'. + * Returns the number of call-log rows requested through auto/ prefix models. */ export function getAutoRoutingTotalCount(): AutoRoutingTotalResult { const db = getDbInstance(); @@ -28,8 +22,8 @@ export function getAutoRoutingTotalCount(): AutoRoutingTotalResult { .prepare( ` SELECT COUNT(*) as count - FROM usage_logs - WHERE model = 'auto' OR model LIKE 'auto/%' + FROM call_logs + WHERE requested_model = 'auto' OR requested_model LIKE 'auto/%' ` ) .get() as AutoRoutingTotalResult | undefined; @@ -42,11 +36,7 @@ export interface AutoRoutingVariantRow { } /** - * Returns per-variant request counts for auto/ prefix models. - * Variant is derived from the model name: - * 'auto' → 'default' - * 'auto/X' → 'X' - * other → 'other' (should not occur given the WHERE clause) + * Returns per-variant request counts from the client-requested model id. */ export function getAutoRoutingVariantBreakdown(): AutoRoutingVariantRow[] { const db = getDbInstance(); @@ -55,13 +45,13 @@ export function getAutoRoutingVariantBreakdown(): AutoRoutingVariantRow[] { ` SELECT CASE - WHEN model = 'auto' THEN 'default' - WHEN model LIKE 'auto/%' THEN SUBSTR(model, 6) + WHEN requested_model = 'auto' THEN 'default' + WHEN requested_model LIKE 'auto/%' THEN SUBSTR(requested_model, 6) ELSE 'other' END as variant, COUNT(*) as count - FROM usage_logs - WHERE model = 'auto' OR model LIKE 'auto/%' + FROM call_logs + WHERE requested_model = 'auto' OR requested_model LIKE 'auto/%' GROUP BY variant ORDER BY count DESC ` @@ -75,7 +65,7 @@ export interface AutoRoutingTopProviderRow { } /** - * Returns the top 10 providers used for auto/ prefix model requests. + * Returns the top 10 providers used for auto/ prefix requests. */ export function getAutoRoutingTopProviders(): AutoRoutingTopProviderRow[] { const db = getDbInstance(); @@ -83,8 +73,10 @@ export function getAutoRoutingTopProviders(): AutoRoutingTopProviderRow[] { .prepare( ` SELECT provider, COUNT(*) as count - FROM usage_logs - WHERE model = 'auto' OR model LIKE 'auto/%' + FROM call_logs + WHERE (requested_model = 'auto' OR requested_model LIKE 'auto/%') + AND provider IS NOT NULL + AND TRIM(provider) != '' GROUP BY provider ORDER BY count DESC LIMIT 10 diff --git a/src/lib/db/webSessionDedup.ts b/src/lib/db/webSessionDedup.ts index 341afc9a2d..b68ee00122 100644 --- a/src/lib/db/webSessionDedup.ts +++ b/src/lib/db/webSessionDedup.ts @@ -55,3 +55,43 @@ export function parseProviderSpecificData(raw: unknown): Record } return null; } + +/** Trimmed non-empty string, else null — local to avoid a cross-module import for one coercion. */ +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** + * Two-sided disambiguator match: `true` when both sides agree, `false` when + * both carry a value and it differs, `undefined` when the field can't decide + * (at most one side carries it) — the caller then defers to other fields. + */ +function fieldMatch(incoming: string | null, existing: string | null): boolean | undefined { + if (incoming && existing) return incoming === existing; + if (incoming || existing) return false; + return undefined; +} + +/** + * Decide whether `row` (an existing `provider_connections` record) is the + * same OAuth identity as an incoming connection carrying `incomingUsername` + * and `incomingProfileArn` (#10815). + * + * Two independent disambiguators, either of which can prove "different + * account": `providerSpecificData.username` (Raycast-style IdP dedup) and + * `providerSpecificData.profileArn` (Kiro/AWS profile dedup — Kiro never + * sets `username`). A field only rules a match IN/OUT when both the + * incoming and existing record carry it; when neither carries either field + * the legacy bare-email match still applies unchanged. + */ +export function isMatchingOauthIdentity( + row: { provider_specific_data?: unknown }, + incomingUsername: string | null, + incomingProfileArn: string | null +): boolean { + const existingPsd = parseProviderSpecificData(row.provider_specific_data); + const usernameMatch = fieldMatch(incomingUsername, nonEmptyString(existingPsd?.username)); + const profileArnMatch = fieldMatch(incomingProfileArn, nonEmptyString(existingPsd?.profileArn)); + if (usernameMatch === false || profileArnMatch === false) return false; + return true; +} diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts index dab9523ef6..e7337f49e2 100644 --- a/src/lib/embeddings/service.ts +++ b/src/lib/embeddings/service.ts @@ -10,13 +10,14 @@ import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/er import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import * as log from "@/sse/utils/logger"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; -import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; import { - getCachedProviderNodes, - getComboByName, - getCombos, - getDatabaseSettings, -} from "@/lib/localDb"; + getProviderCredentials, + clearRecoveredProviderState, + markAccountUnavailable, +} from "@/sse/services/auth"; +import { getCachedProviderNodes } from "@/lib/db/readCache"; +import { getComboByName, getCombos } from "@/lib/db/combos"; +import { getDatabaseSettings } from "@/lib/db/databaseSettings"; import { resolveProxyForConnection } from "@/lib/db/settings"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { handleComboChat } from "@omniroute/open-sse/services/combo.ts"; @@ -309,7 +310,7 @@ export async function createEmbeddingResponse( // #10347 — thread the selected connection id so handleEmbedding can cool the // account on a hard upstream failure (previously always null on /v1/embeddings). connectionId: - ((credentials as { connectionId?: string } | null)?.connectionId) || + (credentials as { connectionId?: string } | null)?.connectionId || options.connectionId || connectionIdForProxy || null, @@ -340,6 +341,33 @@ export async function createEmbeddingResponse( }); } + // #10347: cool down the account on hard errors (402 subscription expired, + // 401 revoked, 403 forbidden, 404 model gone, 429 rate limit, 5xx server + // errors) so the next embedding request skips this account. Mirrors chat.ts + // behavior. + // Skip for 400 (bad request) — the account is fine, the request was wrong. + // Best-effort: don't block the error response on the DB write. + const HARD_ERROR_STATUSES = new Set([401, 402, 403, 404, 429, 500, 502, 503, 504]); + if ( + credentials && + "connectionId" in credentials && + typeof credentials.connectionId === "string" && + HARD_ERROR_STATUSES.has(result.status) + ) { + markAccountUnavailable( + credentials.connectionId, + result.status, + result.error || "Embedding provider error", + provider, + resolvedModel || null + ).catch((err) => { + log.debug( + "EMBED", + `Cooldown write failed for ${provider}/${credentials.connectionId?.slice(0, 8)}: ${err}` + ); + }); + } + responseHeaders.set("Content-Type", "application/json"); const errorPayload = toJsonErrorPayload(result.error, "Embedding provider error"); return new Response(JSON.stringify(errorPayload), { diff --git a/src/lib/healthzLag.ts b/src/lib/healthzLag.ts new file mode 100644 index 0000000000..fb4f17567e --- /dev/null +++ b/src/lib/healthzLag.ts @@ -0,0 +1,40 @@ +import { monitorEventLoopDelay } from "node:perf_hooks"; + +/** `/healthz` returning 200 after this much event-loop lag is already sick (#10303). */ +export const HEALTHZ_SLOW_LAG_MS = 200; +const WARN_EVERY_MS = 10_000; + +let lastWarnAt = 0; +let histogram: ReturnType | null = null; + +export function resetHealthzLagWarnStateForTests(): void { + lastWarnAt = 0; +} + +export function shouldWarnHealthzLag(lagMs: number, now = Date.now()): boolean { + if (!Number.isFinite(lagMs) || lagMs < HEALTHZ_SLOW_LAG_MS) return false; + if (now - lastWarnAt < WARN_EVERY_MS) return false; + lastWarnAt = now; + return true; +} + +export function formatHealthzLagWarning(lagMs: number): string { + return `GET /healthz event-loop lag ${Math.round(lagMs)}ms (HTTP 200 is not healthy; busy != ready)`; +} + +export function getEventLoopLagMs(): number { + if (!histogram) { + histogram = monitorEventLoopDelay({ resolution: 20 }); + histogram.enable(); + } + return histogram.mean / 1e6; +} + +export function observeHealthzEventLoopLag( + log: (msg: string) => void = console.warn, + lagMs = getEventLoopLagMs() +): boolean { + if (!shouldWarnHealthzLag(lagMs)) return false; + log(`[HEALTHZ] ${formatHealthzLagWarning(lagMs)}`); + return true; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index c4607dc594..761e2db4eb 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -771,7 +771,7 @@ export type { } from "./db/usageAnalytics"; // --------------------------------------------------------------------------- -// usage_logs — auto-routing analytics (#3500 slice 4) +// call_logs auto-routing analytics (#3500 slice 4) // --------------------------------------------------------------------------- export { getAutoRoutingTotalCount, diff --git a/src/lib/machineToken.ts b/src/lib/machineToken.ts index e83da29495..bb910ae9ed 100644 --- a/src/lib/machineToken.ts +++ b/src/lib/machineToken.ts @@ -1,9 +1,13 @@ import { createHash, createHmac } from "node:crypto"; +import { createRequire } from "node:module"; let machineIdSync: (original?: boolean) => string; try { - // Use require() to bypass webpack static analysis that breaks the default export - const mod = require("node-machine-id"); + // Anchor runtime resolution to the process entrypoint. Turbopack rewrites + // createRequire(import.meta.url) into an in-bundle resolver, which cannot load + // external CommonJS packages from the installed standalone node_modules tree. + const runtimeRequire = createRequire(process.argv[1] || process.cwd()); + const mod = runtimeRequire("node-machine-id"); machineIdSync = mod.machineIdSync || mod.default?.machineIdSync; } catch { machineIdSync = () => ""; @@ -15,10 +19,19 @@ function getActiveSalt(): string { return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT; } -function deriveToken(rawId: string, salt: string): string { +export function deriveMachineToken(rawId: string, salt: string): string { + if (!rawId) return ""; return createHmac("sha256", rawId).update(salt).digest("hex"); } +export function deriveLegacyCliToken(machineId: string, salt: string): string { + if (!machineId) return ""; + return createHash("sha256") + .update(machineId + salt) + .digest("hex") + .substring(0, 32); +} + let cached: string | null = null; let cachedSalt: string | null = null; @@ -27,8 +40,9 @@ export function getMachineTokenSync(salt?: string): string { try { // machineIdSync(true) returns the original unhashed hardware ID. const rawId = machineIdSync(true); + if (!rawId) return ""; if (activeSalt === cachedSalt && cached !== null) return cached; - const token = deriveToken(rawId, activeSalt); + const token = deriveMachineToken(rawId, activeSalt); if (!salt) { cached = token; cachedSalt = activeSalt; @@ -43,10 +57,7 @@ export function getLegacyCliTokenSync(salt?: string): string { const activeSalt = salt ?? getActiveSalt(); try { const machineId = machineIdSync(); - return createHash("sha256") - .update(machineId + activeSalt) - .digest("hex") - .substring(0, 32); + return deriveLegacyCliToken(machineId, activeSalt); } catch { return ""; } diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index b354bba3b5..c0b20dfb59 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -13,7 +13,10 @@ import { import { getSyncedCapability } from "@/lib/modelsDevSync"; import { MODELS_DEV_PROVIDER_MAP } from "@/lib/modelsDevSync/transform"; import { getModelContextOverride } from "@/lib/db/modelContextOverrides"; -import { getModelCapabilityOverride } from "@/lib/db/modelCapabilityOverrides"; +import { + getModelCapabilityOverride, + getReasoningEffortsOverride, +} from "@/lib/db/modelCapabilityOverrides"; import { getCustomModelVisionOverride } from "@/lib/db/models"; import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot"; import { resolveAudioCapability, resolveVideoCapability } from "@/lib/modelCapabilityModalities"; @@ -117,6 +120,8 @@ export interface ResolvedModelCapabilities { toolCalling: boolean; reasoning: boolean; supportsThinking: boolean | null; + supportedThinkingEfforts: readonly string[] | null; + reasoningEffortsOverride: boolean; supportsTools: boolean | null; supportsVision: boolean | null; supportsAudio: boolean | null; @@ -634,6 +639,25 @@ function getMaxInputTokenCapabilityOverride( ); } +/** Resolve an exact reasoning-effort vocabulary from the build-local snapshot + * when present, otherwise from the on-demand persisted override lookup. */ +function getReasoningEffortsCapabilityOverride( + resolved: { + provider: string | null; + model: string | null; + rawModel: string | null; + }, + snapshot?: ModelCapabilityResolutionSnapshot | null +): readonly string[] | null { + const bulk = snapshot?.reasoningEffortsOverrides ?? null; + return ( + getReasoningEffortsOverride(resolved.provider, resolved.model, bulk) ?? + (resolved.rawModel && resolved.rawModel !== resolved.model + ? getReasoningEffortsOverride(resolved.provider, resolved.rawModel, bulk) + : null) + ); +} + export function getExplicitModelOutputCap( input: CapabilityInput, snapshot?: ModelCapabilityResolutionSnapshot | null @@ -705,13 +729,18 @@ export function getResolvedModelCapabilities( (typeof spec?.supportsTools === "boolean" ? spec.supportsTools : null) ?? (providerDeniesTools ? false : null); - const supportsThinking = reasoningDenied - ? false - : (synced?.reasoning ?? - (typeof registryModel?.supportsReasoning === "boolean" - ? registryModel.supportsReasoning - : null) ?? - (typeof spec?.supportsThinking === "boolean" ? spec.supportsThinking : null)); + const reasoningEffortsOverride = usePersistedOverrides + ? getReasoningEffortsCapabilityOverride(resolved, snapshot) + : null; + const supportsThinking = reasoningEffortsOverride + ? true + : reasoningDenied + ? false + : (synced?.reasoning ?? + (typeof registryModel?.supportsReasoning === "boolean" + ? registryModel.supportsReasoning + : null) ?? + (typeof spec?.supportsThinking === "boolean" ? spec.supportsThinking : null)); const authoritativeContextWindow = getAuthoritativeStaticContextWindow( resolved.provider, @@ -785,6 +814,9 @@ export function getResolvedModelCapabilities( toolCalling: supportsTools ?? heuristicToolCalling(lookupKey), reasoning: supportsThinking ?? heuristicReasoning(lookupKey), supportsThinking, + supportedThinkingEfforts: + reasoningEffortsOverride ?? registryModel?.supportedThinkingEfforts ?? null, + reasoningEffortsOverride: reasoningEffortsOverride !== null, supportsTools, supportsVision, supportsAudio, diff --git a/src/lib/modelCapabilityResolutionSnapshot.ts b/src/lib/modelCapabilityResolutionSnapshot.ts index ae9f68c000..3bf8eacb02 100644 --- a/src/lib/modelCapabilityResolutionSnapshot.ts +++ b/src/lib/modelCapabilityResolutionSnapshot.ts @@ -9,6 +9,7 @@ * collide via delimiter composition. */ import { listModelCapabilityOverrides } from "@/lib/db/modelCapabilityOverrides"; +import type { ReasoningEffortOverrideValue } from "@/shared/reasoning/reasoningEffortsOverride"; import { listModelContextOverrides } from "@/lib/db/modelContextOverrides"; import { listCustomModelVisionOverrides, @@ -22,11 +23,16 @@ import { /** Nested provider → model → numeric override map (collision-free). */ export type NestedOverrideMap = ReadonlyMap>; +export type NestedReasoningEffortsOverrideMap = ReadonlyMap< + string, + ReadonlyMap +>; export interface ModelCapabilityResolutionSnapshot { readonly synced: CapabilitiesByProvider; readonly maxTokenOverrides: NestedOverrideMap; readonly maxInputTokenOverrides: NestedOverrideMap; + readonly reasoningEffortsOverrides: NestedReasoningEffortsOverrideMap; readonly contextOverrides: NestedOverrideMap; readonly customVisionOverrides: CustomModelVisionOverrideMap; } @@ -61,11 +67,22 @@ export function createModelCapabilityResolutionSnapshot( const maxTokenOverrides = new Map>(); const maxInputTokenOverrides = new Map>(); + const reasoningEffortsOverrides = new Map< + string, + Map + >(); for (const entry of listModelCapabilityOverrides()) { if (entry.key === "max_output_tokens") { setNestedOverride(maxTokenOverrides, entry.provider, entry.modelId, entry.value); } else if (entry.key === "max_input_tokens") { setNestedOverride(maxInputTokenOverrides, entry.provider, entry.modelId, entry.value); + } else if (entry.key === "reasoning_efforts") { + let byModel = reasoningEffortsOverrides.get(entry.provider); + if (!byModel) { + byModel = new Map(); + reasoningEffortsOverrides.set(entry.provider, byModel); + } + byModel.set(entry.modelId, entry.value); } } @@ -78,6 +95,7 @@ export function createModelCapabilityResolutionSnapshot( synced, maxTokenOverrides, maxInputTokenOverrides, + reasoningEffortsOverrides, contextOverrides, customVisionOverrides: listCustomModelVisionOverrides(options.customModelVision), }; diff --git a/src/lib/modelMetadataRegistry.ts b/src/lib/modelMetadataRegistry.ts index 413836e5a6..5e477084ea 100644 --- a/src/lib/modelMetadataRegistry.ts +++ b/src/lib/modelMetadataRegistry.ts @@ -8,6 +8,7 @@ import { isNonChatCatalogSurface, } from "@/lib/modelCapabilities"; import { getModelCapabilityOverride } from "@/lib/db/modelCapabilityOverrides"; +import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot"; import { getAuthoritativeContextWindow, getAuthoritativeProviderContextWindow, @@ -28,7 +29,6 @@ import { CANONICAL_EFFORT_VALUES, extendCodexGpt56EffortValues, } from "@/shared/reasoning/effortStandardization"; -import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot"; const MODEL_METADATA_SCHEMA_VERSION = "model-metadata-v1"; @@ -40,6 +40,7 @@ type JsonRecord = Record; export interface CatalogEnrichmentSnapshot { modelsDevPricing: PricingByProvider | null; + capabilityResolution?: ModelCapabilityResolutionSnapshot; providerNodeIdsByPrefix?: Readonly>; /** #9147: build-local bulk load of synced capabilities + token/context overrides * so per-entry enrichment never hits SQLite again (see catalogResponse.ts). */ @@ -64,6 +65,7 @@ export interface CanonicalModelMetadata { toolCalling: boolean; reasoning: boolean; supportsThinking: boolean | null; + supportedThinkingEfforts: readonly string[] | null; supportsTools: boolean | null; vision: boolean | null; attachment: boolean | null; @@ -90,6 +92,7 @@ export interface CanonicalModelMetadata { providerRegistry: boolean; staticSpec: boolean; syncedCapability: boolean; + reasoningEffortsOverride: boolean; }; }; modalities: { @@ -249,6 +252,7 @@ export function getCanonicalModelMetadata(input: { toolCalling: resolved.toolCalling, reasoning: resolved.reasoning, supportsThinking: resolved.supportsThinking, + supportedThinkingEfforts: resolved.supportedThinkingEfforts, supportsTools: resolved.supportsTools, vision: resolved.supportsVision, attachment: resolved.attachment, @@ -275,6 +279,7 @@ export function getCanonicalModelMetadata(input: { providerRegistry: Boolean(registryModel), staticSpec: Boolean(staticSpec), syncedCapability: Boolean(syncedCapability), + reasoningEffortsOverride: resolved.reasoningEffortsOverride, }, }, modalities: { @@ -437,10 +442,6 @@ export function enrichCatalogModelEntry( snapshot: snapshot?.capabilityResolutionSnapshot ?? null, }); if (!metadata) return entry; - const registryModel = getRegistryModel( - metadata.providerAlias || metadata.provider, - metadata.model - ); const nextEntry: JsonRecord = { ...entry }; const existingName = asNonEmptyString(entry.name); @@ -484,9 +485,9 @@ export function enrichCatalogModelEntry( ...(metadata.capabilities.supportsThinking ? { effort_tiers: - registryModel?.supportedThinkingEfforts && - registryModel.supportedThinkingEfforts.length > 0 - ? [...registryModel.supportedThinkingEfforts] + metadata.capabilities.supportedThinkingEfforts && + metadata.capabilities.supportedThinkingEfforts.length > 0 + ? [...metadata.capabilities.supportedThinkingEfforts] : extendCodexGpt56EffortValues( metadata.provider, metadata.model, diff --git a/src/lib/modelsDevSync.ts b/src/lib/modelsDevSync.ts index b8d16ffbad..c4c0f2f273 100644 --- a/src/lib/modelsDevSync.ts +++ b/src/lib/modelsDevSync.ts @@ -759,7 +759,7 @@ export function getSyncStatus(): SyncStatus { }; } -// ─── Init (called from server-init.ts) ─────────────────── +// ─── Init (called from instrumentation-node.ts) ─────────────────── /** * Initialize models.dev sync if enabled. diff --git a/src/lib/monitoring/observability.ts b/src/lib/monitoring/observability.ts index 44acfdffc6..4cc18b27fd 100644 --- a/src/lib/monitoring/observability.ts +++ b/src/lib/monitoring/observability.ts @@ -1,3 +1,7 @@ +import { + createCodexAccountPool, + getCodexParentAccountDiagnostic, +} from "@omniroute/open-sse/services/codexAccount/index.ts"; import type { AdaptiveAdmissionPublicSnapshot } from "@omniroute/open-sse/services/admission/runtime.ts"; type JsonRecord = Record; @@ -130,7 +134,13 @@ interface BuildHealthPayloadOptions { buildSha?: string | null; catalogCount?: number; settings: { setupComplete?: boolean } | null | undefined; - connections: Array<{ provider?: string; isActive?: boolean | null; rateLimitedUntil?: unknown }>; + connections: Array<{ + id?: string; + provider?: string; + isActive?: boolean | null; + rateLimitedUntil?: unknown; + providerSpecificData?: Readonly> | null; + }>; circuitBreakers: CircuitBreakerStatus[]; rateLimitStatus: JsonRecord; learnedLimits: JsonRecord; @@ -273,6 +283,53 @@ export function summarizeConnectionCooldown( return summary; } +export interface CodexAccountPoolsSummary { + total: number; + available: number; + partiallyLimited: number; + fullyLimited: number; + quotaObserved: number; + soonestRetryAfterMs: number; +} + +export function summarizeCodexAccountPools( + connections: BuildHealthPayloadOptions["connections"], + nowMs: number +): CodexAccountPoolsSummary { + const summary: CodexAccountPoolsSummary = { + total: 0, + available: 0, + partiallyLimited: 0, + fullyLimited: 0, + quotaObserved: 0, + soonestRetryAfterMs: 0, + }; + for (const connection of connections) { + if (connection.provider !== "codex" || !connection.id) continue; + const diagnostic = getCodexParentAccountDiagnostic( + createCodexAccountPool({ + id: connection.id, + provider: connection.provider, + providerSpecificData: connection.providerSpecificData ?? {}, + }), + nowMs + ); + summary.total += 1; + if (diagnostic.status === "available") summary.available += 1; + else if (diagnostic.status === "partially_limited") summary.partiallyLimited += 1; + else summary.fullyLimited += 1; + if (diagnostic.quota.observedScopeCount > 0) summary.quotaObserved += 1; + if ( + diagnostic.cooldown.soonestRetryAfterMs > 0 && + (summary.soonestRetryAfterMs === 0 || + diagnostic.cooldown.soonestRetryAfterMs < summary.soonestRetryAfterMs) + ) { + summary.soonestRetryAfterMs = diagnostic.cooldown.soonestRetryAfterMs; + } + } + return summary; +} + export function buildHealthPayload({ appVersion, catalogCount = 0, @@ -333,7 +390,9 @@ export function buildHealthPayload({ }; } - const connectionHealth = summarizeConnectionCooldown(connections, Date.now()); + const nowMs = Date.now(); + const connectionHealth = summarizeConnectionCooldown(connections, nowMs); + const codexAccountPools = summarizeCodexAccountPools(connections, nowMs); const configuredProviders = new Set( connections.map((connection) => connection.provider).filter(Boolean) @@ -372,6 +431,7 @@ export function buildHealthPayload({ providerBreakers, providerHealth, connectionHealth, + codexAccountPools, providerSummary: { catalogCount, configuredCount: configuredProviders.size, diff --git a/src/lib/pricingSync.ts b/src/lib/pricingSync.ts index e331439eb6..e828d962ed 100644 --- a/src/lib/pricingSync.ts +++ b/src/lib/pricingSync.ts @@ -105,10 +105,22 @@ const LITELLM_PROVIDER_MAP: Record = { vertex_ai: ["gemini"], "vertex_ai-anthropic_models": ["anthropic"], google: ["gemini"], - deepseek: ["if"], + // Registry ALIAS, not registry id — pricingSync writes/reads are keyed by + // alias everywhere else (see getPricingForModel(provider, model) callers). + // Four of these previously used the provider's `id` string, which is not a + // valid pricing-lookup key for that provider and, worse, for `deepseek` a + // real (but wrong) alias existed under that string — silently routing + // DeepSeek's synced pricing onto Qoder (open-sse/config/providers/registry/ + // qoder/index.ts, alias "if", an unrelated third-party API) instead of + // DeepSeek (alias "ds"). `bedrock`/`bedrock_converse` and `cloudflare` + // pointed at their provider's `id` ("kiro", "cloudflare-ai") rather than + // its `alias` ("kr", "cf") — not wrong-provider, just a dead key nothing + // downstream ever looks up, so those two providers silently never received + // synced pricing at all. + deepseek: ["ds"], groq: ["groq"], together_ai: ["openrouter"], - bedrock: ["kiro"], + bedrock: ["kr"], fireworks_ai: ["fireworks"], cerebras: ["cerebras"], nvidia_nim: ["nvidia"], @@ -116,8 +128,11 @@ const LITELLM_PROVIDER_MAP: Record = { "vertex_ai-language_models": ["gemini"], "vertex_ai-mistral_models": ["mistral"], gemini: ["gemini"], - bedrock_converse: ["kiro"], - cloudflare: ["cloudflare-ai"], + bedrock_converse: ["kr"], + cloudflare: ["cf"], + // stability-ai has no chat-completions registry entry (image-only: + // open-sse/config/providers/registry/stability-ai/imageModels.ts) — left + // as-is rather than guessed at; not the same bug shape as the three above. stability: ["stability-ai"], }; @@ -515,7 +530,7 @@ export function getSyncStatus(): SyncStatus { }; } -// ─── Init (called from server-init.ts) ─────────────────── +// ─── Init (called from instrumentation-node.ts) ─────────────────── /** * Initialize pricing sync if enabled. diff --git a/src/lib/providers/catalog.ts b/src/lib/providers/catalog.ts index 1f53542c6b..0491b4d899 100644 --- a/src/lib/providers/catalog.ts +++ b/src/lib/providers/catalog.ts @@ -9,6 +9,7 @@ import { UPSTREAM_PROXY_PROVIDERS, WEB_COOKIE_PROVIDERS, isClaudeCodeCompatibleProvider, + resolveProviderId, supportsApiKeyOnFreeProvider, supportsDualAuthProvider, type RiskNoticeVariant, @@ -194,9 +195,10 @@ export function getStaticProviderCatalogGroup( export function resolveStaticProviderCatalogEntry( providerId: string ): ResolvedStaticProviderCatalogEntry | null { + const canonicalId = resolveProviderId(providerId); for (const category of STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER) { const group = STATIC_PROVIDER_CATALOG_GROUPS[category]; - const provider = group.providers[providerId]; + const provider = group.providers[canonicalId] ?? group.providers[providerId]; if (!provider) continue; return { ...provider, diff --git a/src/lib/providers/imageValidation.ts b/src/lib/providers/imageValidation.ts index 444517a608..1d91eb18ee 100644 --- a/src/lib/providers/imageValidation.ts +++ b/src/lib/providers/imageValidation.ts @@ -28,6 +28,11 @@ const IMAGE_PROVIDER_VALIDATION_ENDPOINTS: Record< topaz: { path: "/account/v1/credits/balance", }, + magnific: { + // GET /v1/ai/mystic lists tasks and does not start a paid generation. + baseUrl: "https://api.magnific.com", + path: "/v1/ai/mystic", + }, }; function normalizeBaseUrl(baseUrl: string) { @@ -86,9 +91,15 @@ function buildImageProviderValidationHeaders( break; case "none": break; - default: - headers.Authorization = `Bearer ${apiKey}`; + default: { + const headerName = String(imageProvider?.authHeader || "").trim(); + if (headerName.toLowerCase().startsWith("x-")) { + headers[headerName] = apiKey; + } else { + headers.Authorization = `Bearer ${apiKey}`; + } break; + } } } @@ -109,7 +120,9 @@ export async function validateImageProviderApiKey({ providerSpecificData = {}, }: any) { const imageProvider = getImageProvider(provider); - const validationConfig = IMAGE_PROVIDER_VALIDATION_ENDPOINTS[provider]; + const validationConfig = + IMAGE_PROVIDER_VALIDATION_ENDPOINTS[imageProvider?.id] || + IMAGE_PROVIDER_VALIDATION_ENDPOINTS[provider]; if (!imageProvider || !validationConfig) { return { valid: false, error: "Provider validation not supported", unsupported: true }; diff --git a/src/lib/providers/modelListingCapability.ts b/src/lib/providers/modelListingCapability.ts index 070520dc6c..8887a1efc7 100644 --- a/src/lib/providers/modelListingCapability.ts +++ b/src/lib/providers/modelListingCapability.ts @@ -11,7 +11,7 @@ const TOOL_ONLY_SERVICE_KINDS = new Set(["webSearch", "webFetch"]); /** Providers whose registry catalog is the complete, intentional model list. */ -const CURATED_MODEL_ONLY_PROVIDERS = new Set(["kimi-web", "zai-web"]); +const CURATED_MODEL_ONLY_PROVIDERS = new Set(["chatgpt-web", "kimi-web", "zai-web"]); export function providerUsesCuratedModelsOnly(providerId: string): boolean { return CURATED_MODEL_ONLY_PROVIDERS.has(providerId.trim().toLowerCase()); diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 1fb62c0bdc..05786ab7aa 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -302,6 +302,10 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec if (Object.keys(record).length === 0) return undefined; const sanitized: JsonRecord = { ...record }; + delete sanitized.accessToken; + delete sanitized.refreshToken; + delete sanitized.idToken; + delete sanitized.apiKey; delete sanitized.consoleApiKey; delete sanitized.secretAccessKey; delete sanitized.awsSecretAccessKey; @@ -316,6 +320,15 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec delete sanitized.usageCookie; delete sanitized.runtimeKey; delete sanitized.validationId; + // System-managed Codex fingerprint seed: never exposed through the API + // (mirrors sub2api stripping `codex_fingerprint_seed`); the server-side + // partial-update merge keeps it alive without the client round-tripping it. + delete sanitized.codexFingerprintSeed; + // Runtime-only Codex identity carriers (in-memory per request, never + // persisted) — strip defensively if they ever leak into a response payload. + delete sanitized.codexClientIdentity; + delete sanitized.codexOriginalIdentityHeaders; + delete sanitized.codexTurnStateEcho; if (sanitized.browserCdpEndpoint) sanitized.browserCdpEndpoint = "configured"; return sanitized; } diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index d5986d2419..7f8180dee0 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -112,6 +112,7 @@ import { validateAdobeFireflyProvider } from "./validation/adobeFirefly"; import { validateV0VercelProvider, validateAuggieProvider, + validateCursorApiProvider, validateQoderProvider, validateKiroProvider, validateGitlabProvider, @@ -139,7 +140,39 @@ export { validateWebCookieProvider, bytezValidationResultFromStatus }; // validateKiroApiKeyRuntimeProbe now live in ./validation/webCookie and ./validation/kiro. // They are re-exported above to preserve the historical public surface. +export async function validateFreebuffProvider({ apiKey }: { apiKey: string }) { + if (!apiKey) { + return { valid: false, error: "Freebuff Auth Token required", unsupported: false }; + } + try { + const res = await fetch("https://www.codebuff.com/api/v1/freebuff/session", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "User-Agent": "codebuff/0.1.0 (darwin-arm64)", + "x-freebuff-model": "deepseek/deepseek-v4-flash", + }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(15000), + }); + + if (res.ok || res.status === 409) { + return { valid: true, error: null }; + } + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid or expired Freebuff Auth Token", unsupported: false }; + } + const errText = await res.text().catch(() => ""); + return { valid: false, error: `Freebuff validation returned ${res.status}: ${errText.slice(0, 100)}`, unsupported: false }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { valid: false, error: `Freebuff validation network error: ${msg}`, unsupported: false }; + } +} + export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { + provider = typeof provider === "string" ? resolveProviderId(provider) : provider; const requiresApiKey = !providerAllowsOptionalApiKey(provider); const isLocal = isLocalProvider(provider); @@ -185,6 +218,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi // for parity with the "jules" cloud-agent entry above — see #6142. devin: validateDevinCloudAgentProvider, auggie: validateAuggieProvider, + "cursor-api": validateCursorApiProvider, aihorde: validateAiHordeProvider, // #10522: registered under both the canonical id and the short alias — Firefly // connections are commonly stored as "firefly" (same prefix as firefly/ @@ -193,6 +227,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi firefly: validateAdobeFireflyProvider, qoder: validateQoderProvider, kiro: validateKiroProvider, + freebuff: validateFreebuffProvider, "command-code": validateCommandCodeProvider, huggingface: validateHuggingFaceProvider, // #5422: auth-only probe — Bytez 404s on every chat model until the account adds it to @@ -212,6 +247,8 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi validateImageProviderApiKey({ provider: "recraft", apiKey, providerSpecificData }), topaz: ({ apiKey, providerSpecificData }: any) => validateImageProviderApiKey({ provider: "topaz", apiKey, providerSpecificData }), + magnific: ({ apiKey, providerSpecificData }: any) => + validateImageProviderApiKey({ provider: "magnific", apiKey, providerSpecificData }), elevenlabs: validateElevenLabsProvider, inworld: validateInworldProvider, kie: validateKieProvider, diff --git a/src/lib/providers/validation/openaiFormat.ts b/src/lib/providers/validation/openaiFormat.ts index 9f6efa9203..3d60113134 100644 --- a/src/lib/providers/validation/openaiFormat.ts +++ b/src/lib/providers/validation/openaiFormat.ts @@ -163,7 +163,11 @@ export async function validateOpenAILikeProvider({ } if (chatRes.status === 404 || chatRes.status === 405) { - return { valid: false, error: "Provider validation endpoint not supported" }; + return { + valid: false, + error: "Provider validation endpoint not supported", + unsupported: true, + }; } if (chatRes.status >= 500) { diff --git a/src/lib/providers/validation/specialtyInline.ts b/src/lib/providers/validation/specialtyInline.ts index 191902d290..fc9102f5cd 100644 --- a/src/lib/providers/validation/specialtyInline.ts +++ b/src/lib/providers/validation/specialtyInline.ts @@ -61,6 +61,28 @@ export async function validateAuggieProvider() { return { valid: true, error: null, unsupported: false, method: result.version }; } +export async function validateCursorApiProvider({ apiKey }: { apiKey?: string }) { + const { exchangeCursorApiKey, CursorApiKeyExchangeError, isCursorApiKey } = + await import("@omniroute/open-sse/services/cursorApiKeyAuth.ts"); + const key = (apiKey || "").trim(); + if (!isCursorApiKey(key)) { + return { + valid: false, + error: "Cursor user API keys start with crsr_ (cursor.com/dashboard/api)", + unsupported: false, + statusCode: 400, + }; + } + try { + await exchangeCursorApiKey(key); + return { valid: true, error: null, unsupported: false, method: "exchange_user_api_key" }; + } catch (error) { + const statusCode = error instanceof CursorApiKeyExchangeError ? error.status : 502; + const message = error instanceof Error ? error.message : "Cursor API key exchange failed"; + return { valid: false, error: message, unsupported: false, statusCode }; + } +} + export async function validateQoderProvider({ apiKey, providerSpecificData }: any) { // Bifurcate validation: PAT tokens use Cosy auth against api1.qoder.sh; // regular API keys validate against dashscope (OpenAI-compatible endpoint). @@ -201,6 +223,19 @@ export async function validateLongcatProvider({ apiKey, providerSpecificData, is } } +export function normalizeNvidiaValidationFailure(error: unknown) { + const failure = toValidationErrorResult(error); + if (failure.timeout) { + return { + valid: true, + error: null, + warning: "NVIDIA auth probe timed out; credential validity is inconclusive", + method: "chat_probe_inconclusive", + }; + } + return failure; +} + // NVIDIA NIM (#2463) — bypass the /models probe in favor of a direct // chat/completions probe. NVIDIA NIM's /models endpoint returns model // catalogs that vary by region and key-tier, and some keys 404 on it, @@ -240,7 +275,7 @@ export async function validateNvidiaProvider({ apiKey, providerSpecificData }: a // Any non-auth response (200, 400, 422, 429) means auth passed return { valid: true, error: null }; } catch (error: any) { - return toValidationErrorResult(error); + return normalizeNvidiaValidationFailure(error); } } diff --git a/src/lib/proxyEchoTarget.ts b/src/lib/proxyEchoTarget.ts new file mode 100644 index 0000000000..84cd9ef35c --- /dev/null +++ b/src/lib/proxyEchoTarget.ts @@ -0,0 +1,90 @@ +/** + * #9694 — echo-IP target selection for proxy egress probes. + * + * #1255 moved every probe from `api.ipify.org` to `api64.ipify.org` so proxies + * with IPv6 egress could be tested. `api64` is IPv6-first, so it broke the case + * the other way: an IPv4-only SOCKS5/SSH tunnel has no route to it and the probe + * hangs until the caller's deadline, reporting a healthy proxy as dead. + * + * Neither single target works for both, so the probe tries them in order and + * splits the caller's existing budget between the attempts. `api64` stays first, + * so a proxy with working IPv6 answers on the first attempt and keeps the exact + * behaviour #1255 introduced — including which of its addresses is reported, + * which matters because the egress IP is an identity used to detect accounts + * sharing an address. Only a proxy that cannot reach `api64` at all pays for the + * second attempt, and the total stays bounded by the budget the caller already + * enforced. + * + * Dependency-free leaf so the ordering and budget arithmetic are unit-testable + * without opening a socket. + */ + +/** IPv6-first echo target (#1255). Answers over IPv4 too when IPv6 is unavailable to the resolver. */ +export const EGRESS_ECHO_URL_DUAL = "https://api64.ipify.org?format=json"; + +/** IPv4-only echo target — reachable from a proxy with no IPv6 route. */ +export const EGRESS_ECHO_URL_V4 = "https://api4.ipify.org?format=json"; + +/** Operators can pin a single target (including a self-hosted echo) per deployment. */ +export const EGRESS_ECHO_URL_ENV = "OMNIROUTE_PROXY_ECHO_URL"; + +/** Minimum a single attempt may be given, so a small caller budget is not split into uselessly short tries. */ +export const MIN_ECHO_ATTEMPT_MS = 2000; + +/** + * Ordered echo targets. An override pins exactly one target — an operator who + * names a target means it, and silently trying ipify anyway would defeat the + * point of pointing the probe at a self-hosted echo. + */ +export function resolveEgressEchoUrls( + env: Record = process.env +): string[] { + const override = env[EGRESS_ECHO_URL_ENV]; + if (typeof override === "string" && override.trim().length > 0) return [override.trim()]; + return [EGRESS_ECHO_URL_DUAL, EGRESS_ECHO_URL_V4]; +} + +/** + * Per-attempt budget. The attempts must fit inside the budget the caller already + * enforces, so the deadline the operator sees does not move. A budget too small + * to split fairly is spent entirely on the first target rather than on two + * attempts that are each too short to succeed. + */ +export function splitEchoAttemptBudget(totalMs: number, attempts: number): number[] { + if (!Number.isFinite(totalMs) || totalMs <= 0 || attempts <= 0) return []; + if (attempts === 1) return [totalMs]; + const even = Math.floor(totalMs / attempts); + if (even < MIN_ECHO_ATTEMPT_MS) return [totalMs]; + return Array.from({ length: attempts }, () => even); +} + +export interface EchoAttemptOutcome { + result: T; + url: string; +} + +/** + * Try each echo target in order until one resolves. Rethrows the LAST error when + * every target fails, so the caller's error message still describes a real + * network failure rather than a bookkeeping one. + */ +export async function probeEchoTargets( + run: (url: string, timeoutMs: number) => Promise, + totalMs: number, + env?: Record +): Promise> { + const urls = resolveEgressEchoUrls(env); + const budgets = splitEchoAttemptBudget(totalMs, urls.length); + const attempts = budgets.length; + let lastError: unknown = new Error("no echo target attempted"); + + for (let i = 0; i < attempts; i++) { + const url = urls[i]; + try { + return { result: await run(url, budgets[i]), url }; + } catch (error) { + lastError = error; + } + } + throw lastError; +} diff --git a/src/lib/proxyEgress.ts b/src/lib/proxyEgress.ts index 6f8b10e81a..1996bec763 100644 --- a/src/lib/proxyEgress.ts +++ b/src/lib/proxyEgress.ts @@ -13,10 +13,13 @@ * entering and leaving by. */ import { request as undiciRequest } from "undici"; -import { createProxyDispatcher, proxyConfigToUrl } from "@omniroute/open-sse/utils/proxyDispatcher.ts"; +import { + createProxyDispatcher, + proxyConfigToUrl, +} from "@omniroute/open-sse/utils/proxyDispatcher.ts"; import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts"; +import { probeEchoTargets } from "./proxyEchoTarget"; -const EGRESS_ECHO_URL = "https://api64.ipify.org?format=json"; const EGRESS_PROBE_TIMEOUT_MS = 6000; const EGRESS_CACHE_TTL_MS = 5 * 60 * 1000; @@ -32,18 +35,26 @@ const egressCache = new Map(); async function defaultEgressProbe(proxyUrl: string | null): Promise { const start = Date.now(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), EGRESS_PROBE_TIMEOUT_MS); try { const dispatcher = proxyUrl ? createProxyDispatcher(proxyUrl) : undefined; - const res = await undiciRequest(EGRESS_ECHO_URL, { - method: "GET", - dispatcher, - signal: controller.signal, - headersTimeout: EGRESS_PROBE_TIMEOUT_MS, - bodyTimeout: EGRESS_PROBE_TIMEOUT_MS, - }); - const text = await res.body.text(); + // #9694: each echo target gets its own controller, so exhausting the budget + // on an unreachable IPv6-first target does not abort the IPv4 attempt. + const { result: text } = await probeEchoTargets(async (url, timeoutMs) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await undiciRequest(url, { + method: "GET", + dispatcher, + signal: controller.signal, + headersTimeout: timeoutMs, + bodyTimeout: timeoutMs, + }); + return await res.body.text(); + } finally { + clearTimeout(timeout); + } + }, EGRESS_PROBE_TIMEOUT_MS); let ip: string | null = null; try { ip = (JSON.parse(text) as { ip?: string }).ip ?? null; @@ -57,8 +68,6 @@ async function defaultEgressProbe(proxyUrl: string | null): Promise(); @@ -187,6 +196,110 @@ export function analyzeEgressSharing(connections: ConnectionEgress[]): { return { byEgressIp, sharedWithinRotationGroup }; } +export const EGRESS_SHARING_WINDOW_MS = 24 * 60 * 60 * 1000; + +export interface EgressLogRow { + provider: string | null; + account: string | null; + connectionId: string | null; + egressIp: string | null; +} + +export interface EgressSharingSummary { + windowStart: string; + windowEnd: string; + distinctEgressIps: number; + sharingByRotationGroup: Array<{ + rotationGroup: string; + sharedIps: number; + maxAccountsSharingOneIp: number; + }>; + maxAccountsSharingOneIp: number; +} + +/** + * PURE: anonymous egress-IP sharing summary over proxy_logs-shaped rows. + * Dedupes per connection (proxy_logs holds one row per request, not per + * connection) — "max accounts behind one IP" therefore counts connections, + * not distinct accounts, when one account spans several connections. Reuses + * analyzeEgressSharing's rotation-group semantics and returns counts only — + * no IP literals, no account identities (#10348). + */ +export function summarizeEgressSharing( + rows: EgressLogRow[], + window: { start: string; end: string } +): { summary: EgressSharingSummary; warnings: EgressSharingWarning[] } { + const byAccount = new Map(); + for (const r of rows) { + if (!r.egressIp) continue; + const key = r.connectionId ?? r.account; + if (!key) continue; + if (!byAccount.has(key)) byAccount.set(key, r); + } + + const connections = [...byAccount.values()].map((r) => ({ + connectionId: r.connectionId ?? r.account ?? "unknown", + provider: r.provider ?? "", + account: r.account ?? r.connectionId, + proxyLevel: "log", + proxyHost: null, + egressIp: r.egressIp, + })); + + const { byEgressIp, sharedWithinRotationGroup } = analyzeEgressSharing(connections); + + const byGroup = new Map(); + let maxAccountsSharingOneIp = 0; + for (const w of sharedWithinRotationGroup) { + const g = byGroup.get(w.rotationGroup) ?? { sharedIps: 0, maxAccountsSharingOneIp: 0 }; + g.sharedIps++; + g.maxAccountsSharingOneIp = Math.max(g.maxAccountsSharingOneIp, w.connections.length); + byGroup.set(w.rotationGroup, g); + maxAccountsSharingOneIp = Math.max(maxAccountsSharingOneIp, w.connections.length); + } + + return { + summary: { + windowStart: window.start, + windowEnd: window.end, + distinctEgressIps: Object.keys(byEgressIp).length, + sharingByRotationGroup: [...byGroup.entries()].map(([rotationGroup, v]) => ({ + rotationGroup, + sharedIps: v.sharedIps, + maxAccountsSharingOneIp: v.maxAccountsSharingOneIp, + })), + maxAccountsSharingOneIp, + }, + // Raw warnings carry IPs and labels — only ever rendered behind the + // PROXY_LOG_INCLUDE_IPS opt-in (#10348), never in the summary itself. + warnings: sharedWithinRotationGroup, + }; +} + +/** + * DB-backed: anonymous egress-sharing summary over the last + * EGRESS_SHARING_WINDOW_MS of persisted proxy_logs (egress_ip is always + * persisted even when the process log line is redacted). No live probes. + * Single place where proxy_logs rows are mapped to EgressLogRow — the sweep + * and the route both consume this helper. Reads all rows in the window (the + * existing SELECT * has no LIMIT); bounded by the 24h window. + */ +export async function getRecentEgressSharingSummary(): Promise<{ + summary: EgressSharingSummary; + warnings: EgressSharingWarning[]; +}> { + const { exportProxyLogsSince } = await import("./db/proxyLogs"); + const end = new Date(); + const start = new Date(end.getTime() - EGRESS_SHARING_WINDOW_MS); + const rows: EgressLogRow[] = exportProxyLogsSince(start.toISOString()).map((r) => ({ + provider: (r.provider as string | null) ?? null, + account: (r.account as string | null) ?? null, + connectionId: (r.connection_id as string | null) ?? null, + egressIp: (r.egress_ip as string | null) ?? null, + })); + return summarizeEgressSharing(rows, { start: start.toISOString(), end: end.toISOString() }); +} + /** * Diagnose egress IPs for every OAuth connection: resolve each connection's * proxy, probe the real egress IP, and flag same-rotation-group IP sharing. @@ -195,9 +308,7 @@ export async function diagnoseAllEgressIps(deps?: { getConnections?: () => Promise< Array<{ id: string; provider: string; name?: string; email?: string; authType?: string }> >; - resolveProxy?: ( - connectionId: string - ) => Promise<{ proxy?: unknown; level?: string } | null>; + resolveProxy?: (connectionId: string) => Promise<{ proxy?: unknown; level?: string } | null>; }): Promise { const getConnections = deps?.getConnections ?? @@ -266,9 +377,21 @@ export interface ProxyValidationResult { */ export async function validateProxyPool(deps?: { listProxies?: () => Promise< - Array<{ id: string; type: string; host: string; port: number | string; username?: string | null; password?: string | null; status?: string | null }> + Array<{ + id: string; + type: string; + host: string; + port: number | string; + username?: string | null; + password?: string | null; + status?: string | null; + }> >; - markStatus?: (id: string, status: string, meta: { latencyMs: number; egressIp: string | null }) => Promise; + markStatus?: ( + id: string, + status: string, + meta: { latencyMs: number; egressIp: string | null } + ) => Promise; }): Promise { const listProxies = deps?.listProxies ?? @@ -351,7 +474,11 @@ export function planProxyDistribution( return; } if (opts.allowSharing) { - assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i % liveProxyIds.length] }); + assignments.push({ + connectionId: c.id, + account, + proxyId: liveProxyIds[i % liveProxyIds.length], + }); } else if (i < liveProxyIds.length) { assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i] }); } else { diff --git a/src/lib/proxyHealth/decision.ts b/src/lib/proxyHealth/decision.ts index daefaa6a5b..ae58cde705 100644 --- a/src/lib/proxyHealth/decision.ts +++ b/src/lib/proxyHealth/decision.ts @@ -2,7 +2,7 @@ * Pure, network-free decision for the proxy health scheduler (#6246). * * Separated from the sweep so the status/removal policy can be unit-tested - * exhaustively without any I/O. The sweep classifies each probe into a tri-state + * exhaustively without any I/O. The sweep classifies each probe into a * {@link ProxyProbeOutcome} and applies the returned {@link ProxyHealthDecision}. * * Policy (agreed for #6246, extended for the auto-disable mode below): @@ -27,12 +27,33 @@ * is free once autoDisable participates in `managesStatus` below. If * both flags are set, auto-remove (destructive) wins: a proxy that is * about to be deleted has no use for a soft-disable in between. + * E — a `blocked` probe (the TARGET refused this egress IP: 401/403/429) is + * neutral like `inconclusive`. The proxy relayed correctly, so it is not + * failing; but it is not serving that destination either, which `ok` hid. + * Kept out of the failure count on purpose: one target refusing an IP + * does not make the proxy dead, and the operator owns the removal policy. */ -export type ProxyProbeOutcome = "ok" | "fail" | "inconclusive"; +export type ProxyProbeOutcome = "ok" | "fail" | "inconclusive" | "blocked"; + +/** Statuses that mean the TARGET refused this egress IP rather than served it. */ +const TARGET_BLOCK_STATUSES: ReadonlySet = new Set([401, 403, 429]); + +/** + * PURE: classify a probe response status into a {@link ProxyProbeOutcome}. + * + * `ok` requires the target to have actually served the request. A 401/403/429 + * means the proxy relayed but the destination refused the egress IP — the case + * a generic "status < 500" test reported as a healthy proxy. + */ +export function classifyProbeStatus(status: number): ProxyProbeOutcome { + if (TARGET_BLOCK_STATUSES.has(status)) return "blocked"; + // A 5xx means the proxy DID relay — the target is at fault, not the proxy. + return status < 500 ? "ok" : "inconclusive"; +} export interface ProxyHealthDecisionInput { - /** Tri-state result of the reachability probe for this proxy. */ + /** Classified result of the reachability probe for this proxy. */ outcome: ProxyProbeOutcome; /** Consecutive failure count recorded BEFORE this probe. */ priorFailures: number; @@ -65,8 +86,8 @@ export function decideProxyHealthAction(input: ProxyHealthDecisionInput): ProxyH // Either opt-in flag hands status control from the operator to the sweep. const managesStatus = autoRemove || autoDisable; - // B: inconclusive probes are neutral — do not touch count or status. - if (outcome === "inconclusive") { + // B/E: inconclusive and blocked probes are neutral — no count, no status. + if (outcome === "inconclusive" || outcome === "blocked") { return { failures: priorFailures, clearFailures: false, setStatus: null, remove: false }; } diff --git a/src/lib/proxyHealth/probeTarget.ts b/src/lib/proxyHealth/probeTarget.ts new file mode 100644 index 0000000000..ee423f7c5c --- /dev/null +++ b/src/lib/proxyHealth/probeTarget.ts @@ -0,0 +1,84 @@ +/** + * Shared resolution of the reachability-probe parameters (#8411). + * + * The scheduler sweep and the bulk "Test All" endpoint each carried their own copy of the + * probe target and batch size. Both now resolve through here, so an operator tunes one + * surface instead of two that can silently drift apart. + * + * The resolvers are pure and take the environment as a parameter, so tests never have to + * mutate `process.env`. + */ + +import { sleep } from "@omniroute/open-sse/utils/sleep"; + +export const DEFAULT_PROBE_TARGET = "https://httpbin.org/ip"; +export const DEFAULT_PROBE_CONCURRENCY = 10; +export const DEFAULT_PROBE_STAGGER_MS = 100; + +/** + * Upper bounds. Making the batch size configurable without a ceiling would let a single + * env var recreate the very probe storm this module exists to damp. + */ +export const MAX_PROBE_CONCURRENCY = 50; +export const MAX_PROBE_STAGGER_MS = 5000; + +type ProbeEnv = Record; + +function resolveBoundedInt(raw: string | undefined, fallback: number, min: number, max: number) { + const parsed = parseInt(raw ?? "", 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(Math.max(parsed, min), max); +} + +/** + * Deliberately keeps the historical `||` semantics: a target that is empty falls back to the + * default, but one made only of whitespace is passed through untouched. Trimming it here would + * silently change how an existing deployment behaves. + */ +export function resolveProbeTarget(env: ProbeEnv = process.env): string { + return env.PROXY_HEALTH_TEST_URL || DEFAULT_PROBE_TARGET; +} + +/** Floored at 1: a zero batch size would make the `i += concurrency` loop never advance. */ +export function resolveProbeConcurrency(env: ProbeEnv = process.env): number { + return resolveBoundedInt( + env.PROXY_HEALTH_TEST_CONCURRENCY, + DEFAULT_PROBE_CONCURRENCY, + 1, + MAX_PROBE_CONCURRENCY + ); +} + +export function resolveProbeStaggerMs(env: ProbeEnv = process.env): number { + return resolveBoundedInt( + env.PROXY_HEALTH_TEST_STAGGER_MS, + DEFAULT_PROBE_STAGGER_MS, + 0, + MAX_PROBE_STAGGER_MS + ); +} + +/** + * Delay before the Nth probe of a batch starts. + * + * A batch fires `Promise.allSettled(batch.map(...))`, so without this every probe leaves at the + * same tick and a shared egress IP hits the target with `concurrency` simultaneous requests. + * Spacing the departures is what removes that spike; the delay is a plain multiple of the index + * rather than a random jitter so a sweep stays reproducible and exactly testable. + * + * The first probe of a batch always returns 0 — no batch is ever slowed down at its head. + */ +export function staggerDelayMs(indexInBatch: number, stepMs: number): number { + if (indexInBatch <= 0 || stepMs <= 0) return 0; + return indexInBatch * stepMs; +} + +/** + * Hold a probe back until its slot in the batch. Call this from the batch `map`, before the + * probe itself: both call sites arm their timeout inside their own test function, so waiting + * out here is what keeps every probe's timeout budget whole. + */ +export async function waitForProbeSlot(indexInBatch: number, stepMs: number): Promise { + const delay = staggerDelayMs(indexInBatch, stepMs); + if (delay > 0) await sleep(delay); +} diff --git a/src/lib/proxyHealth/providerProbeTarget.ts b/src/lib/proxyHealth/providerProbeTarget.ts new file mode 100644 index 0000000000..e41a619b32 --- /dev/null +++ b/src/lib/proxyHealth/providerProbeTarget.ts @@ -0,0 +1,85 @@ +/** + * Resolve a per-provider reachability target for a proxy, instead of the generic PR #8411 + * probe target every proxy shares today (#8411 follow-up to the precedent set by #10420). + * + * A proxy is assigned to a provider, never to a specific connection, so only providers with a + * stable, connection-independent target qualify: one `providerRegistry` entry carrying a + * singular `baseUrl` (or `testKeyBaseUrl`). Providers with only `baseUrls[]` (array — a custom + * `urlBuilder`, e.g. antigravity) or whose real target lives in a connection's + * `providerSpecificData.baseUrl` (self-hosted) are not eligible: there is no single answer for + * "the" target of such a provider, so they fall back to the generic probe. + */ + +import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { normalizeBaseUrl, addModelsSuffix } from "@/lib/providers/validation/urlHelpers"; +import { getProxyWhereUsed } from "@/lib/db/proxies"; + +type ProbeEnv = Record; + +/** Kill switch: `false` skips provider resolution entirely, restoring pre-PR behavior. */ +export function isProviderTargetEnabled(env: ProbeEnv = process.env): boolean { + return env.PROXY_HEALTH_USE_PROVIDER_TARGET !== "false"; +} + +/** + * PURE: is `baseUrl` safe to probe blindly with `GET {baseUrl}/models`? `https://` only — no + * registry entry uses plain `http://` today, and this probe carries no credential, so there is + * no reason to widen the surface. No query string: a probe targets a sibling path, and a query + * string on the base is a sign this isn't a plain host to append to (same guard as + * `resolveWebCookieProbe`, `src/lib/providers/validation/webCookie.ts`). + */ +export function isProbeSafeBaseUrl(baseUrl: string): boolean { + const normalized = normalizeBaseUrl(baseUrl); + return Boolean(normalized) && /^https:\/\//i.test(normalized) && !normalized.includes("?"); +} + +/** + * PURE: the `GET /models` URL for `providerId`, or null if it has no connection-independent + * target. + */ +export function resolveEligibleProviderUrl(providerId: string): string | null { + const entry = getRegistryEntry(providerId); + if (!entry) return null; + + const baseUrl = normalizeBaseUrl(entry.testKeyBaseUrl || entry.baseUrl || ""); + if (!isProbeSafeBaseUrl(baseUrl)) return null; + + const modelsUrl = addModelsSuffix(baseUrl); + return modelsUrl || null; +} + +/** + * Resolve the probe target for `proxyId` from its provider-scope assignments. `getProxyWhereUsed` + * orders rows by `scope, scope_id` — alphabetically by provider id, not by assignment order — + * so with several eligible providers assigned, the alphabetically-first one wins. First eligible + * provider in that order, ineligible ones skipped. Returns null (generic target applies) when + * the switch is off, the proxy has no provider assignment, none of its assigned providers are + * eligible, or resolution itself fails for any reason — target resolution must never be the + * reason a proxy silently drops out of a sweep or a "Test All" run. + * + * Only the registry-backed `proxy_assignments` table is read (`getProxyWhereUsed`). The legacy + * per-provider proxy config (`/api/settings/proxy?level=provider&id=...`) has no proxy→provider + * reverse lookup — building one would be new machinery, not reuse. A legacy-only assignment + * degrades safely to the generic target, same as no assignment at all. + */ +export async function resolveProviderProbeTarget( + proxyId: string, + env: ProbeEnv = process.env +): Promise { + if (!isProviderTargetEnabled(env)) return null; + + try { + const { assignments } = await getProxyWhereUsed(proxyId); + for (const assignment of assignments) { + if (assignment.scope !== "provider" || !assignment.scopeId) continue; + const url = resolveEligibleProviderUrl(assignment.scopeId); + if (url) return url; + } + return null; + } catch { + // Same contract as "no eligible provider": fall back rather than let a resolution error + // propagate into testOneProxy/testSingleProxy, which would silently drop the proxy from + // Promise.allSettled results instead of testing it against the generic target. + return null; + } +} diff --git a/src/lib/proxyHealth/scheduler.ts b/src/lib/proxyHealth/scheduler.ts index 2008acf709..dc30453a34 100644 --- a/src/lib/proxyHealth/scheduler.ts +++ b/src/lib/proxyHealth/scheduler.ts @@ -23,23 +23,42 @@ */ import { deleteProxyById, listProxies, updateProxy } from "@/lib/localDb"; +import { isProxyLogIncludeIps } from "@/lib/proxyLogger"; +import { + getRecentEgressSharingSummary, + type EgressSharingSummary, + type EgressSharingWarning, +} from "@/lib/proxyEgress"; import { createProxyDispatcher, clearDispatcherCache, proxyConfigToUrl, } from "@omniroute/open-sse/utils/proxyDispatcher"; import { fetch as undiciFetch } from "undici"; -import { decideProxyHealthAction, type ProxyProbeOutcome } from "./decision.ts"; +import { + classifyProbeStatus, + decideProxyHealthAction, + type ProxyProbeOutcome, +} from "./decision.ts"; +import { + resolveProbeConcurrency, + resolveProbeStaggerMs, + resolveProbeTarget, + waitForProbeSlot, +} from "./probeTarget.ts"; +import { resolveProviderProbeTarget } from "./providerProbeTarget.ts"; // #6246: a HEAD to the public probe target through a legit (often loaded) proxy // can exceed a few seconds; the old 5s ceiling produced false negatives that // flipped healthy proxies to inactive. Raise it and treat our own timeout as // inconclusive (see testOneProxy) rather than a proxy failure. const TEST_TIMEOUT_MS = 15000; -// Reachability probe target for proxy health checks. Configurable so operators -// can point it at an internal/self-hosted endpoint instead of the public default. -const TEST_URL = process.env.PROXY_HEALTH_TEST_URL || "https://httpbin.org/ip"; -const CONCURRENCY = 10; +// Probe target, batch size and intra-batch spacing come from probeTarget.ts, which the +// auto-test endpoint reads too — one surface to tune instead of two that can drift apart. +// Resolved at module load, as these constants always were. +const TEST_URL = resolveProbeTarget(); +const CONCURRENCY = resolveProbeConcurrency(); +const STAGGER_MS = resolveProbeStaggerMs(); const INITIAL_DELAY_MS = 60_000; const DEFAULT_INTERVAL_MS = 600_000; const DEFAULT_REMOVE_AFTER = 3; @@ -57,6 +76,26 @@ function getFailureMap(): Map { return globalThis.__proxyHealthConsecutiveFailures; } +/** + * PURE: one-line anonymous egress-sharing summary for the sweep log (#10677). + * Counts only by default; raw shared IPs only when PROXY_LOG_INCLUDE_IPS=true + * (the redaction decision from #10348 — never leak IPs or account labels). + */ +export function formatEgressSharingSummaryLine( + summary: EgressSharingSummary, + warnings: EgressSharingWarning[], + includeDetails: boolean +): string { + const base = + `${LOG_PREFIX} egress: ${summary.sharingByRotationGroup.length} rotation group(s) share an ` + + `egress IP (max ${summary.maxAccountsSharingOneIp} accounts)`; + if (!includeDetails) return base; + const detail = warnings + .map((w) => `${w.rotationGroup}: ${w.egressIp} (${w.connections.length} accounts)`) + .join(", "); + return detail ? `${base} — ${detail}` : base; +} + function isEnabled(): boolean { return process.env.PROXY_HEALTH_ENABLED !== "false"; } @@ -90,9 +129,12 @@ function isBackgroundServicesDisabled(): boolean { } /** - * Reachability probe for one proxy, classified into a tri-state so the pure + * Reachability probe for one proxy, classified so the pure * decision layer can apply the #6246 policy: - * - "ok" — the proxy relayed and the target answered (<500). + * - "ok" — the proxy relayed and the target served the request. + * - "blocked" — the proxy relayed, but the TARGET refused this egress IP + * (401/403/429). Neutral like "inconclusive": the proxy is + * not at fault, yet it is not serving that destination. * - "inconclusive" — NOT the proxy's fault: our own timeout/abort, or the probe * TARGET returned a 5xx (the proxy connected fine). Never * penalizes the proxy. @@ -114,29 +156,53 @@ async function testOneProxy(proxy: { proxyUrl = null; } if (!proxyUrl) return "fail"; + // A provider's models endpoint is a real GET-only API surface, unlike httpbin.org/ip: many + // reject HEAD outright. HEAD stays the default for the generic target — this changes nothing + // for a proxy with no eligible provider assignment. + const providerTarget = await resolveProviderProbeTarget(proxy.id); + const target = providerTarget ?? TEST_URL; + const method = providerTarget ? "GET" : "HEAD"; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS); try { const dispatcher = createProxyDispatcher(proxyUrl); - const resp = await undiciFetch(TEST_URL, { - method: "HEAD", + const resp = await undiciFetch(target, { + method, signal: controller.signal, dispatcher, headers: { "User-Agent": "OmniRoute/1.0" }, }); - // A 5xx from the probe target means the proxy DID relay — the target is at - // fault, not the proxy. Do not penalize the proxy for that. - return resp.status < 500 ? "ok" : "inconclusive"; + return classifyProbeStatus(resp.status); } catch { // Our own deadline elapsed → inconclusive (slow, not necessarily dead). - // Any other error is a genuine proxy-level connection failure. - return controller.signal.aborted ? "inconclusive" : "fail"; + if (controller.signal.aborted) return "inconclusive"; + // A provider-resolved target's connection health is not proven the way the + // operator-configured generic target is: a registry baseUrl can be a placeholder that + // never resolves for anyone (e.g. databricks's default azuredatabricks.net host is + // literally 16 zeros). A connection failure there says nothing about this proxy — + // same principle as the 5xx case above, extended to connection-level errors. + return providerTarget ? "inconclusive" : "fail"; } finally { clearTimeout(timeout); } } async function sweep(): Promise { + // #10677: anonymous egress-sharing signal from persisted proxy_logs (no live + // probes). Logged only when sharing exists — the sweep line is a warning + // signal, not a heartbeat. Runs before the empty-registry early return so + // sharing from direct connections is still reported when no proxies are + // configured. Never let a DB hiccup suppress the completion line or fail the + // sweep itself. + try { + const { summary, warnings } = await getRecentEgressSharingSummary(); + if (summary.sharingByRotationGroup.length > 0) { + console.log(formatEgressSharingSummaryLine(summary, warnings, isProxyLogIncludeIps())); + } + } catch (error) { + console.error(`${LOG_PREFIX} Egress summary skipped:`, error); + } + const { items: proxies } = await listProxies({ includeSecrets: true }); if (proxies.length === 0) return; @@ -148,13 +214,17 @@ async function sweep(): Promise { let tested = 0; let alive = 0; let inconclusive = 0; + let blocked = 0; let removed = 0; let disabled = 0; for (let i = 0; i < proxies.length; i += CONCURRENCY) { const batch = proxies.slice(i, i + CONCURRENCY); const results = await Promise.allSettled( - batch.map(async (proxy) => { + batch.map(async (proxy, indexInBatch) => { + // Spread the departures: without this the whole batch leaves at the same tick and a + // shared egress IP hits the target with CONCURRENCY simultaneous requests. + await waitForProbeSlot(indexInBatch, STAGGER_MS); const outcome = await testOneProxy(proxy); return { id: proxy.id, outcome }; }) @@ -166,6 +236,7 @@ async function sweep(): Promise { tested++; if (outcome === "ok") alive++; else if (outcome === "inconclusive") inconclusive++; + else if (outcome === "blocked") blocked++; const decision = decideProxyHealthAction({ outcome, @@ -202,8 +273,8 @@ async function sweep(): Promise { } console.log( - `${LOG_PREFIX} Sweep complete: ${tested} tested, ${alive} alive, ${inconclusive} inconclusive, ` + - `${removed} auto-removed, ${disabled} auto-disabled` + `${LOG_PREFIX} Sweep complete: ${tested} tested, ${alive} alive, ${blocked} blocked by target, ` + + `${inconclusive} inconclusive, ${removed} auto-removed, ${disabled} auto-disabled` ); } diff --git a/src/lib/proxyLogger.ts b/src/lib/proxyLogger.ts index 79bb4e5af4..a9eb4b3805 100644 --- a/src/lib/proxyLogger.ts +++ b/src/lib/proxyLogger.ts @@ -110,9 +110,14 @@ loadFromDb(); // neither IPs nor the account prefix. Deliberately NOT coupled to debugMode // (src/lib/db/settings.ts defaults debugMode to true) — this verbosity is opt-in only. // Storage (in-memory ring buffer + SQLite) is untouched and always keeps full IPs. -const PROXY_LOG_INCLUDE_IPS = - process.env.PROXY_LOG_INCLUDE_IPS === "true" || - process.env.PROXY_LOG_INCLUDE_IPS === "1"; + +/** Read at call time so tests can toggle it between imports. */ +export function isProxyLogIncludeIps(): boolean { + return ( + process.env.PROXY_LOG_INCLUDE_IPS === "true" || + process.env.PROXY_LOG_INCLUDE_IPS === "1" + ); +} /** * Pure formatter for the [ProxyEgress] process-log line (#10348). At the default level it @@ -178,7 +183,7 @@ export function logProxyEvent(entry: ProxyLogInput) { level: log.level, proxyHost: log.proxy?.host, status: log.status, - includeDetails: PROXY_LOG_INCLUDE_IPS, + includeDetails: isProxyLogIncludeIps(), }) ); } diff --git a/src/lib/semanticCache.ts b/src/lib/semanticCache.ts index b185b19963..87ae5c14e5 100644 --- a/src/lib/semanticCache.ts +++ b/src/lib/semanticCache.ts @@ -105,6 +105,22 @@ function getMemoryCache() { return memoryCache; } +/** + * In-memory LRU stats for the semantic cache. + * + * Exposed for `/api/cache/stats`, which used to report `getPromptCache()` — an + * LRU that nothing writes to, so it always answered 0 hit / 0 miss. Same shape + * as `LRUCache.getStats()`, so callers do not have to change. + */ +export function getMemoryCacheStats(): ReturnType { + return getMemoryCache().getStats(); +} + +/** Drop the in-memory LRU without touching the `semantic_cache` table. */ +export function clearMemoryCache(): void { + getMemoryCache().clear(); +} + // ─── Signature Generation ───────────────── /** diff --git a/src/lib/skills/interception.ts b/src/lib/skills/interception.ts index c32745ef4f..bef85f63a1 100644 --- a/src/lib/skills/interception.ts +++ b/src/lib/skills/interception.ts @@ -1,6 +1,7 @@ import { skillExecutor } from "./executor"; import { skillRegistry } from "./registry"; import { builtinSkills } from "./builtins"; +import { memoryBuiltinHandlers, MEMORY_BUILTIN_TOOL_NAMES } from "./memoryBuiltins"; import { detectProvider, decodeSkillToolName } from "./injection"; import { OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webSearchFallback.ts"; import { OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webFetchInterception.ts"; @@ -32,10 +33,12 @@ const BUILTIN_TOOL_ALIASES: Record = { [OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME]: "web_fetch", }; +const MEMORY_TOOL_NAMES = new Set(MEMORY_BUILTIN_TOOL_NAMES); + function resolveBuiltinHandlerName( toolName: string, context: ExecutionContext -): keyof typeof builtinSkills | null { +): keyof typeof builtinSkills | keyof typeof memoryBuiltinHandlers | null { const [rawName] = toolName.includes("@") ? toolName.split("@") : [toolName]; const canonicalName = BUILTIN_TOOL_ALIASES[rawName] || rawName; const allowed = new Set( @@ -46,7 +49,13 @@ function resolveBuiltinHandlerName( return null; } - return canonicalName in builtinSkills ? (canonicalName as keyof typeof builtinSkills) : null; + if (canonicalName in builtinSkills) { + return canonicalName as keyof typeof builtinSkills; + } + if (MEMORY_TOOL_NAMES.has(canonicalName)) { + return canonicalName as keyof typeof memoryBuiltinHandlers; + } + return null; } function getResponsesOutputContainer(response: Record | null | undefined): { @@ -95,12 +104,23 @@ export async function interceptToolCalls( callId: call.id, }); - const result = await builtinSkills[builtinHandlerName](call.arguments, { - apiKeyId: context.apiKeyId, - sessionId: context.sessionId, - provider: context.provider, - model: context.model, - }); + const isMemoryHandler = MEMORY_TOOL_NAMES.has(builtinHandlerName); + const result = isMemoryHandler + ? await memoryBuiltinHandlers[ + builtinHandlerName as keyof typeof memoryBuiltinHandlers + ](call.arguments, { + apiKeyId: context.apiKeyId, + sessionId: context.sessionId, + }) + : await builtinSkills[builtinHandlerName as keyof typeof builtinSkills]( + call.arguments, + { + apiKeyId: context.apiKeyId, + sessionId: context.sessionId, + provider: context.provider, + model: context.model, + } + ); log.info("skills.interception.execution_complete", { toolName: call.name, diff --git a/src/lib/skills/memoryBuiltins.ts b/src/lib/skills/memoryBuiltins.ts new file mode 100644 index 0000000000..b07c658fba --- /dev/null +++ b/src/lib/skills/memoryBuiltins.ts @@ -0,0 +1,294 @@ +import { createMemory, updateMemory, deleteMemory, getMemory } from "@/lib/memory/store"; +import { retrieveMemories } from "@/lib/memory/retrieval"; +import { getMemorySettings, DEFAULT_MEMORY_SETTINGS, toMemoryRetrievalConfig } from "@/lib/memory/settings"; +import { MemoryType } from "@/lib/memory/types"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("MEMORY_BUILTINS"); + +export const MEMORY_SAVE_TOOL_NAME = "memory_save"; +export const MEMORY_UPDATE_TOOL_NAME = "memory_update"; +export const MEMORY_SEARCH_TOOL_NAME = "memory_search"; +export const MEMORY_DELETE_TOOL_NAME = "memory_delete"; + +export const MEMORY_BUILTIN_TOOL_NAMES = [ + MEMORY_SAVE_TOOL_NAME, + MEMORY_UPDATE_TOOL_NAME, + MEMORY_SEARCH_TOOL_NAME, + MEMORY_DELETE_TOOL_NAME, +] as const; + +const MEMORY_TYPES = ["factual", "episodic", "procedural", "semantic"] as const; + +function toMemoryType(value: unknown): MemoryType { + return MEMORY_TYPES.includes(value as (typeof MEMORY_TYPES)[number]) + ? (value as MemoryType) + : MemoryType.FACTUAL; +} + +function toPositiveInt(value: unknown, fallback: number, max: number): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, max); +} + +async function assertOwner(memoryId: string, apiKeyId: string): Promise { + const memory = await getMemory(memoryId); + if (!memory) throw new Error(`Memory not found: ${memoryId}`); + if (memory.apiKeyId !== apiKeyId) { + throw new Error("Memory does not belong to this API key"); + } +} + +function memoryToPlain(memory: Awaited>) { + return { + id: memory.id, + type: memory.type, + key: memory.key, + content: memory.content, + metadata: memory.metadata, + createdAt: memory.createdAt.toISOString(), + updatedAt: memory.updatedAt.toISOString(), + }; +} + +async function handleMemorySave(input: Record, context: { apiKeyId: string; sessionId: string }) { + const { type, key, content, metadata } = input as { + type?: string; + key: string; + content: string; + metadata?: Record; + }; + if (!key || typeof key !== "string") throw new Error("Missing required field: key"); + if (!content || typeof content !== "string") throw new Error("Missing required field: content"); + + const saved = await createMemory({ + apiKeyId: context.apiKeyId, + sessionId: context.sessionId || "", + type: toMemoryType(type), + key, + content, + metadata: metadata && typeof metadata === "object" ? metadata : {}, + expiresAt: null, + }); + + return { + success: true, + memory: memoryToPlain(saved), + message: "Memory saved successfully", + context: context.apiKeyId, + }; +} + +async function handleMemoryUpdate(input: Record, context: { apiKeyId: string }) { + const { id, type, key, content, metadata } = input as { + id: string; + type?: string; + key?: string; + content?: string; + metadata?: Record; + }; + if (!id || typeof id !== "string") throw new Error("Missing required field: id"); + + await assertOwner(id, context.apiKeyId); + + const updates: Record = {}; + if (type !== undefined) updates.type = toMemoryType(type); + if (key !== undefined) updates.key = key; + if (content !== undefined) updates.content = content; + if (metadata !== undefined) updates.metadata = metadata; + + if (Object.keys(updates).length === 0) throw new Error("No fields to update"); + + const ok = await updateMemory(id, updates); + if (!ok) throw new Error(`Failed to update memory: ${id}`); + + return { + success: true, + id, + message: "Memory updated successfully", + context: context.apiKeyId, + }; +} + +async function handleMemorySearch(input: Record, context: { apiKeyId: string }) { + const { query, type, limit, maxTokens } = input as { + query?: string; + type?: string; + limit?: number; + maxTokens?: number; + }; + + const memorySettings = (await getMemorySettings().catch(() => null)) ?? DEFAULT_MEMORY_SETTINGS; + const baseConfig = toMemoryRetrievalConfig(memorySettings, { query }); + const config = { + ...baseConfig, + enabled: true, + maxTokens: toPositiveInt(maxTokens, memorySettings.maxTokens, 8000), + }; + + const memories = await retrieveMemories(context.apiKeyId, config); + + const filtered = type ? memories.filter((m) => m.type === type) : memories; + const limited = limit ? filtered.slice(0, toPositiveInt(limit, 10, 50)) : filtered; + + return { + success: true, + data: { + memories: limited.map((m) => memoryToPlain(m)), + count: limited.length, + totalTokens: limited.reduce((sum, m) => sum + Math.ceil(m.content.length / 4), 0), + }, + context: context.apiKeyId, + }; +} + +async function handleMemoryDelete(input: Record, context: { apiKeyId: string }) { + const { id } = input as { id: string }; + if (!id || typeof id !== "string") throw new Error("Missing required field: id"); + + await assertOwner(id, context.apiKeyId); + + const ok = await deleteMemory(id); + if (!ok) throw new Error(`Failed to delete memory: ${id}`); + + return { + success: true, + id, + message: "Memory deleted successfully", + context: context.apiKeyId, + }; +} + +export const memoryBuiltinHandlers = { + [MEMORY_SAVE_TOOL_NAME]: handleMemorySave, + [MEMORY_UPDATE_TOOL_NAME]: handleMemoryUpdate, + [MEMORY_SEARCH_TOOL_NAME]: handleMemorySearch, + [MEMORY_DELETE_TOOL_NAME]: handleMemoryDelete, +} as const; + +const MEMORY_SAVE_DESCRIPTION = [ + "Save a memory entry for the current API key. Creates a new entry, or updates the existing", + "entry with the same key (UPSERT). Use this to persist user preferences, facts, decisions,", + "or context worth remembering across conversations. Returned memory.id can be used later", + "with memory_update / memory_delete.", +].join(" "); + +const MEMORY_UPDATE_DESCRIPTION = [ + "Update an existing memory entry by id (returned by memory_save or memory_search).", + "Only provided fields are changed. Content updates re-embed the memory.", +].join(" "); + +const MEMORY_SEARCH_DESCRIPTION = [ + "Search the current API key's memory entries by query or type. Returns matching memories", + "with their ids so they can be referenced or updated.", +].join(" "); + +const MEMORY_DELETE_DESCRIPTION = [ + "Delete a memory entry by id (returned by memory_save or memory_search).", +].join(" "); + +const MEMORY_TYPE_SCHEMA = { + type: "string", + enum: [...MEMORY_TYPES], + description: "Memory category: factual (facts/preferences), episodic (events), procedural (how-to), semantic (knowledge).", +}; + +const memorySaveParameters = { + type: "object", + additionalProperties: false, + properties: { + key: { type: "string", description: "Unique key for the memory entry (e.g. 'preference:coffee'). Reusing a key updates the existing entry." }, + content: { type: "string", description: "The memory content to store." }, + type: MEMORY_TYPE_SCHEMA, + metadata: { type: "object", description: "Optional structured metadata attached to the entry." }, + }, + required: ["key", "content"], +}; + +const memoryUpdateParameters = { + type: "object", + additionalProperties: false, + properties: { + id: { type: "string", description: "Memory entry id returned by memory_save or memory_search." }, + type: MEMORY_TYPE_SCHEMA, + key: { type: "string", description: "New key for the entry." }, + content: { type: "string", description: "New content for the entry." }, + metadata: { type: "object", description: "Replacement metadata." }, + }, + required: ["id"], +}; + +const memorySearchParameters = { + type: "object", + additionalProperties: false, + properties: { + query: { type: "string", description: "Search query text. When omitted, returns recent memories." }, + type: MEMORY_TYPE_SCHEMA, + limit: { type: "integer", minimum: 1, maximum: 50, description: "Maximum number of results (default 10)." }, + maxTokens: { type: "integer", minimum: 1, maximum: 8000, description: "Token budget for the results." }, + }, +}; + +const memoryDeleteParameters = { + type: "object", + additionalProperties: false, + properties: { + id: { type: "string", description: "Memory entry id returned by memory_save or memory_search." }, + }, + required: ["id"], +}; + +export function buildMemoryOpenAITools(): unknown[] { + const wrap = (name: string, description: string, parameters: Record) => ({ + type: "function", + function: { name, description, parameters }, + }); + return [ + wrap(MEMORY_SAVE_TOOL_NAME, MEMORY_SAVE_DESCRIPTION, memorySaveParameters), + wrap(MEMORY_UPDATE_TOOL_NAME, MEMORY_UPDATE_DESCRIPTION, memoryUpdateParameters), + wrap(MEMORY_SEARCH_TOOL_NAME, MEMORY_SEARCH_DESCRIPTION, memorySearchParameters), + wrap(MEMORY_DELETE_TOOL_NAME, MEMORY_DELETE_DESCRIPTION, memoryDeleteParameters), + ]; +} + +export function buildMemoryClaudeTools(): unknown[] { + const wrap = (name: string, description: string, input_schema: Record) => ({ + name, + description, + input_schema, + }); + return [ + wrap(MEMORY_SAVE_TOOL_NAME, MEMORY_SAVE_DESCRIPTION, memorySaveParameters), + wrap(MEMORY_UPDATE_TOOL_NAME, MEMORY_UPDATE_DESCRIPTION, memoryUpdateParameters), + wrap(MEMORY_SEARCH_TOOL_NAME, MEMORY_SEARCH_DESCRIPTION, memorySearchParameters), + wrap(MEMORY_DELETE_TOOL_NAME, MEMORY_DELETE_DESCRIPTION, memoryDeleteParameters), + ]; +} + +export function buildMemoryGeminiTools(): unknown[] { + const wrap = (name: string, description: string, parameters: Record) => ({ + name, + description, + parameters, + }); + return [ + wrap(MEMORY_SAVE_TOOL_NAME, MEMORY_SAVE_DESCRIPTION, memorySaveParameters), + wrap(MEMORY_UPDATE_TOOL_NAME, MEMORY_UPDATE_DESCRIPTION, memoryUpdateParameters), + wrap(MEMORY_SEARCH_TOOL_NAME, MEMORY_SEARCH_DESCRIPTION, memorySearchParameters), + wrap(MEMORY_DELETE_TOOL_NAME, MEMORY_DELETE_DESCRIPTION, memoryDeleteParameters), + ]; +} + +export function buildMemoryToolsForProvider( + provider: "openai" | "anthropic" | "google" | "other" +): unknown[] { + switch (provider) { + case "anthropic": + return buildMemoryClaudeTools(); + case "google": + return buildMemoryGeminiTools(); + default: + return buildMemoryOpenAITools(); + } +} diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 8caaedc31d..5e3c878c0c 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -39,6 +39,7 @@ import { toStoredErrorSummary, protectPipelinePayloads, buildRequestSummary, + classifyCallLogError, } from "./callLogs/format"; import { clearArtifactReference, @@ -464,12 +465,14 @@ async function saveCallLogOperation(entry: any): Promise { // while reasoning source/char-count are recorded separately for observability. const tokensReasoning = getReasoningTokensOrNull(entry.tokens); const reasoningObservation = resolveReasoningObservation(tokensReasoning, entry.responseBody); + const errorType = classifyCallLogError(entry.status, entry.error, entry.provider); const logEntry = { id: typeof entry.id === "string" && entry.id.length > 0 ? entry.id : generateLogId(), timestamp: typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString(), method: entry.method || "POST", path: entry.path || "/v1/chat/completions", status: entry.status || 0, + errorType, model: entry.model || "-", requestedModel: resolvedRequestedModel, provider: rawProvider, @@ -550,7 +553,7 @@ async function saveCallLogOperation(entry: any): Promise { combo_name, combo_step_id, combo_execution_key, error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, has_request_body, has_response_body, has_pipeline_details, request_summary, - correlation_id, model_pinned, session_tag, response_id + correlation_id, model_pinned, session_tag, response_id, error_type ) VALUES ( @id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider, @@ -561,7 +564,7 @@ async function saveCallLogOperation(entry: any): Promise { @comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState, @artifactRelPath, @artifactSizeBytes, @artifactSha256, @hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary, - @correlationId, @modelPinned, @sessionTag, @responseId + @correlationId, @modelPinned, @sessionTag, @responseId, @errorType ) ` ).run({ @@ -697,6 +700,14 @@ export async function getCallLogs(filter: any = {}) { if (filter.combo) { conditions.push("cl.combo_name IS NOT NULL"); } + if (filter.excludeTests) { + // Home "Recent Requests" is an allowlist of real provider inference, not a + // blacklist of known backend log types. Persisted provider requests enter via + // the public gateway namespaces (/v1/* or /api/v1/*); internal management work + // (connection tests, model sync, and future /api/providers/* jobs) does not. + // Apply this before LIMIT so backend rows can never displace real traffic. + conditions.push(`(cl.path LIKE '/v1/%' OR cl.path LIKE '/api/v1/%')`); + } if (filter.since) { conditions.push("cl.timestamp >= @since"); params.since = filter.since instanceof Date ? filter.since.toISOString() : String(filter.since); diff --git a/src/lib/usage/callLogs/format.ts b/src/lib/usage/callLogs/format.ts index b4aa72de64..40054c3a37 100644 --- a/src/lib/usage/callLogs/format.ts +++ b/src/lib/usage/callLogs/format.ts @@ -1,4 +1,5 @@ import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts"; +import { classifyProviderError } from "@omniroute/open-sse/services/errorClassifier.ts"; import { sanitizePII } from "../../piiSanitizer"; import { omitEncryptedReasoningFromLogChunks, protectPayloadForLog } from "../../logPayloads"; import type { CallLogDetailState } from "../callLogArtifacts"; @@ -124,3 +125,22 @@ export function buildRequestSummary( if (Object.keys(summary).length === 0) return null; return JSON.stringify(summary); } + +// #10670: per-call error family at the single write point. Reuses the +// production classifier (chatCore.ts:3974, auth.ts:2598) so the persisted +// vocabulary is exactly PROVIDER_ERROR_TYPES. Successes (status < 400 with no +// error text) short-circuit to null — the classifier never returns a family +// for them anyway, this only skips the call. +// Normalization: strings pass through, Error objects yield .message, any other +// object yields "" (no caller passes plain objects — verified: 35 callers use +// strings and Error only). Deliberate deviation from design §4 ("objet → +// JSON.stringify"): a stringified object carries no classifier signal. +export function classifyCallLogError( + status: number, + error: unknown, + provider?: string | null +): string | null { + const errorText = typeof error === "string" ? error : error instanceof Error ? error.message : ""; + if (status < 400 && errorText.length === 0) return null; + return classifyProviderError(status, errorText, provider); +} diff --git a/src/lib/usage/codexResetCredits.ts b/src/lib/usage/codexResetCredits.ts index a0097173d0..ab54b40a9d 100644 --- a/src/lib/usage/codexResetCredits.ts +++ b/src/lib/usage/codexResetCredits.ts @@ -5,6 +5,7 @@ import { refreshAndUpdateCredentials, } from "@/lib/usage/providerLimits"; import { invalidateCodexQuotaCache } from "@omniroute/open-sse/services/codexQuotaFetcher.ts"; +import { getCodexBackendIdentityHeaders } from "@omniroute/open-sse/config/codexClient.ts"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; @@ -291,6 +292,9 @@ function buildCodexResetCreditHeaders(connection: CodexConnectionLike): Record ({ - key, - value: roundNumber(factors[key]), - weight: roundNumber(weights[key]), - contribution: roundNumber(factors[key] * weights[key]), - source: context.sources[key] ?? "default", - note: context.notes[key], - })).sort((left, right) => Math.abs(right.contribution) - Math.abs(left.contribution)); + return FACTOR_KEYS.map((key) => { + // Optional factors (cacheAffinity/sessionAvailability/quality) default to + // their scoring neutral (1 for a factor, 0 for a weight) so the contribution + // sum stays consistent with calculateScore. + const value = factors[key] ?? 1; + const weight = weights[key] ?? 0; + return { + key, + value: roundNumber(value), + weight: roundNumber(weight), + contribution: roundNumber(value * weight), + source: context.sources[key] ?? "default", + note: context.notes[key], + }; + }).sort((left, right) => Math.abs(right.contribution) - Math.abs(left.contribution)); } function targetForecastMap(targets: ComboForecastTarget[]): Map { diff --git a/src/lib/usage/flatRateProviders.ts b/src/lib/usage/flatRateProviders.ts index e24df131a1..8d3eec2c5d 100644 --- a/src/lib/usage/flatRateProviders.ts +++ b/src/lib/usage/flatRateProviders.ts @@ -31,8 +31,9 @@ import { WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers/web-cookie"; * its analytics cost is intentional, not an artifact), `byteplus` (BytePlus * ModelArk is a metered inference host, billed per token — zeroing it would hide * real cost), `minimax-cn` (the metered Minimax China API, distinct from the - * `minimax` "Minimax Coding" plan), and `glm-thinking` (metered tier, distinct - * from the `glm` Coding plan). + * `minimax` "Minimax Coding" plan), `glm-thinking` (metered tier, distinct + * from the `glm` Coding plan), and `anthropic` (the metered Anthropic API, + * distinct from the `claude`/`cc` Claude Code plan below). */ const FLAT_RATE_SUBSCRIPTION_PROVIDER_IDS: ReadonlySet = new Set([ "minimax", // "Minimax Coding" plan @@ -43,6 +44,8 @@ const FLAT_RATE_SUBSCRIPTION_PROVIDER_IDS: ReadonlySet = new Set([ "qwen-cloud-token-plan", // Qwen Cloud Token Plan "glm", // GLM Coding plan "glm-cn", // GLM Coding (China) plan + "claude", // Claude Code plan (OAuth-only — a Claude Pro/Max subscription) + "cc", // Claude Code plan (alias id — same connection, shares the `cc` pricing rows) ]); /** diff --git a/src/lib/usage/providerLimitsCache.ts b/src/lib/usage/providerLimitsCache.ts index 75fd031057..6548bef54b 100644 --- a/src/lib/usage/providerLimitsCache.ts +++ b/src/lib/usage/providerLimitsCache.ts @@ -1,5 +1,6 @@ import type { ProviderLimitsCacheEntry } from "@/lib/db/providerLimits"; -import { sanitizeGrokBillingStatus } from "@/shared/utils/grokBilling"; +import { sanitizeProviderBillingStatus } from "@/shared/utils/providerBilling"; +import { GROK_BUILD_ADDITIONAL_CREDITS_URL } from "@/shared/utils/grokBilling"; const GROK_CLI_PROVIDER = "grok-cli"; @@ -26,7 +27,7 @@ export function toProviderLimitsCacheEntry( fetchedAt, source, bankedResetCredits: Number.isFinite(bankedResetCredits) ? bankedResetCredits : undefined, - billing: sanitizeGrokBillingStatus(usage.billing), + billing: sanitizeProviderBillingStatus(usage.billing), }; } @@ -44,14 +45,21 @@ export function mergeProviderLimitsCacheEntry( if (provider !== GROK_CLI_PROVIDER) return next; const nextBilling = next.billing; - const previousAutoTopUp = previous.billing?.autoTopUp; - if (!nextBilling || nextBilling.autoTopUp.available || !previousAutoTopUp) return next; + const previousBilling = previous.billing; + if ( + !nextBilling || + nextBilling.additionalCreditsUrl !== GROK_BUILD_ADDITIONAL_CREDITS_URL || + nextBilling.autoTopUp.available || + !previousBilling || + previousBilling.additionalCreditsUrl !== GROK_BUILD_ADDITIONAL_CREDITS_URL + ) + return next; return { ...next, billing: { ...nextBilling, - autoTopUp: previousAutoTopUp, + autoTopUp: previousBilling.autoTopUp, }, }; } diff --git a/src/lib/usage/resilienceExplain.ts b/src/lib/usage/resilienceExplain.ts index 6b01e3bd09..e4f3ef8195 100644 --- a/src/lib/usage/resilienceExplain.ts +++ b/src/lib/usage/resilienceExplain.ts @@ -4,6 +4,11 @@ import { isModelExcludedByConnection } from "@/domain/connectionModelRules"; import { getProviderConnections } from "@/lib/db/providers"; import { getCircuitBreaker } from "@/shared/utils/circuitBreaker"; import { getModelLockoutInfo } from "@omniroute/open-sse/services/accountFallback.ts"; +import { + createCodexAccountPool, + inspectCodexAccount, + resolveCodexAccount, +} from "@omniroute/open-sse/services/codexAccount/index.ts"; import type { ResilienceAccountExplanation, ResilienceExplainState, @@ -73,22 +78,6 @@ function isTerminalStatus(status: string): boolean { return status === "credits_exhausted" || status === "banned" || status === "expired"; } -function getCodexModelScope(model: string | null | undefined): "gpt-5" | "gpt-5-codex" { - const normalized = String(model || "").toLowerCase(); - return normalized.includes("codex") ? "gpt-5-codex" : "gpt-5"; -} - -function getCodexScopeRateLimitedUntil( - providerSpecificData: unknown, - model: string | null | undefined -): string | null { - if (!model) return null; - const data = asRecord(providerSpecificData); - const scopeMap = asRecord(data.codexScopeRateLimitedUntil); - const value = scopeMap[getCodexModelScope(model)]; - return toStringOrNull(value); -} - function buildProviderExplanation(provider: string): { provider: ResilienceProviderExplanation; skipReason: ResilienceSkipReason | null; @@ -274,10 +263,20 @@ function accountReason( }; } - const codexUntil = + const codexPool = options.provider === "codex" - ? getCodexScopeRateLimitedUntil(connection.providerSpecificData, options.model) + ? createCodexAccountPool({ + id: connectionId, + provider: options.provider, + providerSpecificData: asRecord(connection.providerSpecificData), + }) : null; + const codexAccount = codexPool ? resolveCodexAccount(codexPool, options.model) : null; + const codexState = + codexPool && codexAccount?.kind === "child" + ? inspectCodexAccount(codexPool, codexAccount, options.now) + : null; + const codexUntil = codexState?.kind === "child" ? codexState.rateLimitedUntil : null; const codexCooldownMs = retryAfter(codexUntil, options.now); if (codexCooldownMs !== null && codexCooldownMs > 0) { return { @@ -288,7 +287,7 @@ function accountReason( connectionId, message: `Codex scope for ${options.model} is in cooldown until ${codexUntil}.`, retryAfterMs: codexCooldownMs, - evidence: { rateLimitedUntil: codexUntil, scope: getCodexModelScope(options.model) }, + evidence: { rateLimitedUntil: codexUntil, scope: codexState?.scope ?? null }, }, }; } diff --git a/src/mitm/cert/generate.ts b/src/mitm/cert/generate.ts index 6eb26878bd..ae9b6c69e6 100644 --- a/src/mitm/cert/generate.ts +++ b/src/mitm/cert/generate.ts @@ -16,12 +16,17 @@ const TARGET_HOST = TARGET_HOSTS[0]; /** * Generate self-signed SSL certificate using selfsigned (pure JS, no openssl needed) */ -export async function generateCert(): Promise<{ key: string; cert: string }> { +export async function generateCert(options?: { + force?: boolean; +}): Promise<{ key: string; cert: string }> { const certDir = path.join(resolveMitmDataDir(), "mitm"); const keyPath = path.join(certDir, "server.key"); const certPath = path.join(certDir, "server.crt"); - if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + // #10467: callers that only need a cert to exist keep the existing one, but the + // regenerate endpoint has to actually mint a new one — otherwise a cert missing the + // SANs added in #6494 can never be replaced from the UI. + if (!options?.force && fs.existsSync(keyPath) && fs.existsSync(certPath)) { console.log("✅ SSL certificate already exists"); return { key: keyPath, cert: certPath }; } diff --git a/src/proxy.ts b/src/proxy.ts index 93de5e149f..153785efdf 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,6 +1,25 @@ import type { NextRequest } from "next/server"; import { runAuthzPipeline } from "./server/authz/pipeline"; +// #10627: the proxy runs in its own Next.js runtime and never executes +// instrumentation-node.ts's startup warm-ups, so its FIRST request used to +// trigger a cold `import("@/lib/db/settings")` → native SQLite driver load ON +// the request path. If that addon hangs (see driverFactory's #10627 probe), +// every proxied request stalled indefinitely with 0 bytes and no logs. +// Warm the settings cache here at boot instead: a driver failure now surfaces +// as a logged startup error, and real requests start with a hot cache. +// Fire-and-forget — never blocks proxy initialization, never rejects the +// module (mirrors the `void warmModelCatalogCache()` pattern in +// instrumentation-node.ts). +void import("./lib/db/readCache") + .then(({ getCachedSettings }) => getCachedSettings()) + .catch((err: unknown) => { + console.error( + "[proxy] DB settings warm failed; requests will use default limits:", + err instanceof Error ? err.message : err + ); + }); + export async function proxy(request: NextRequest) { return runAuthzPipeline(request, { enforce: true }); } diff --git a/src/server-init.ts b/src/server-init.ts deleted file mode 100644 index 04e6190d5a..0000000000 --- a/src/server-init.ts +++ /dev/null @@ -1,183 +0,0 @@ -// Server startup script -import initializeCloudSync from "./shared/services/initializeCloudSync"; -import { enforceWebRuntimeEnv } from "./lib/env/runtimeEnv"; -import { enforceSecrets } from "./shared/utils/secretsValidator"; -import { initAuditLog, cleanupExpiredLogs, logAuditEvent } from "./lib/compliance/index"; -import { initConsoleInterceptor } from "./lib/consoleInterceptor"; -import { registerBudgetResetJob } from "./lib/jobs/budgetResetJob"; -import { registerTokenHealthCheck } from "./lib/jobs/tokenHealthCheckJob"; -import { startReasoningCacheCleanupJob } from "./lib/jobs/reasoningCacheCleanupJob"; -import { startCleanupScheduler } from "./lib/db/cleanup"; -import { getSettings } from "./lib/db/settings"; -import { applyRuntimeSettings } from "./lib/config/runtimeSettings"; -import { setSystemPromptConfig } from "@omniroute/open-sse/services/systemPrompt.ts"; -import { hydrateThinkingBudgetConfig } from "@omniroute/open-sse/services/thinkingBudget.ts"; -import { startRuntimeConfigHotReload } from "./lib/config/hotReload"; -import { startSpendBatchWriter } from "./lib/spend/batchWriter"; -import { registerDefaultGuardrails } from "./lib/guardrails"; -import { ensurePersistentManagementPasswordHash } from "./lib/auth/managementPassword"; -import { skillExecutor } from "./lib/skills/executor"; -import { registerBuiltinSkills } from "./lib/skills/builtins"; -import { createLogger } from "./shared/utils/logger"; - -const startupLog = createLogger("server-init"); - -function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - -async function startServer() { - // Trigger request-log layout migration during startup, before serving requests. - await import("./lib/usage/migrations"); - - // Console interceptor: capture all console output to log file (must be first) - initConsoleInterceptor(); - - // FASE-01: Validate required secrets before anything else (fail-fast) - enforceSecrets(); - enforceWebRuntimeEnv(); - - // Compliance: Initialize audit_log table - try { - initAuditLog(); - startupLog.info("Audit log table initialized"); - } catch (err) { - startupLog.warn({ err }, "Could not initialize audit log"); - } - - // Compliance: One-time cleanup of expired logs - try { - const cleanup = await cleanupExpiredLogs(); - if ( - cleanup.deletedUsage || - cleanup.deletedCallLogs || - cleanup.deletedProxyLogs || - cleanup.deletedRequestDetailLogs || - cleanup.deletedAuditLogs || - cleanup.deletedMcpAuditLogs - ) { - startupLog.info({ cleanup }, "Expired log cleanup completed"); - } - } catch (err) { - startupLog.warn({ err }, "Log cleanup failed"); - } - - startupLog.info("Starting server with cloud sync"); - - try { - let settings = await getSettings(); - const passwordState = await ensurePersistentManagementPasswordHash({ - logger: { log: (message: string) => startupLog.info(message) }, - settings, - source: "startup", - }); - settings = passwordState.settings; - const runtimeChanges = await applyRuntimeSettings(settings, { force: true, source: "startup" }); - if (runtimeChanges.length > 0) { - startupLog.info( - { sections: runtimeChanges.map((entry) => entry.section) }, - "Runtime settings hydrated" - ); - } - - // Restore the Global System Prompt into the in-memory config. It lives in the - // `settings.systemPrompt` key but is NOT covered by applyRuntimeSettings, so without - // this the toggle/prompt revert to defaults on every restart (#2470). - if (settings.systemPrompt) { - setSystemPromptConfig(settings.systemPrompt); - startupLog.info("Global System Prompt restored from settings"); - } - - // Restore the proxy-level Thinking-Budget config (#5312). It lives in - // `settings.thinkingBudget` and is NOT covered by applyRuntimeSettings, so - // without this the dashboard mode (auto/custom/adaptive) silently reverts to - // the passthrough default on every restart. - if (hydrateThinkingBudgetConfig(settings)) { - startupLog.info("Thinking-Budget config restored from settings"); - } - - // Initialize cloud sync - startSpendBatchWriter(); - registerDefaultGuardrails(); - registerBuiltinSkills(skillExecutor); - startupLog.info("Spend batch writer started"); - startupLog.info("Guardrail registry initialized"); - startupLog.info("Builtin skill handlers registered"); - - // Load active plugins on startup so they survive restarts - try { - const { pluginManager } = await import("./lib/plugins/manager"); - await pluginManager.loadAll(); - startupLog.info("Plugin manager loaded active plugins"); - } catch (err) { - startupLog.warn({ err }, "Plugin manager loadAll failed (non-fatal)"); - } - - await initializeCloudSync(); - // register() only persists the definition; startAll() is what arms the timers. - // This path does not call ensureCloudSyncInitialized(), so nothing else here - // would start the jobs on our behalf. It registers the same set as that path: - // starting one job and not the other is how a background job goes missing - // without anything failing. - const { getJobRegistry } = await import("./lib/jobRegistry"); - const jobRegistry = getJobRegistry(); - registerBudgetResetJob(jobRegistry); - registerTokenHealthCheck(jobRegistry); - await jobRegistry.startAll(); - startReasoningCacheCleanupJob(); - startCleanupScheduler(); - startRuntimeConfigHotReload(); - startupLog.info("Server started with cloud sync initialized"); - - // Log server start event to audit log - logAuditEvent({ - action: "server.start", - actor: "system", - target: "server-runtime", - resourceType: "maintenance", - status: "success", - details: { timestamp: new Date().toISOString() }, - }); - } catch (error) { - startupLog.error({ err: error }, "Error initializing cloud sync"); - process.exit(1); - } - - // Pricing sync: opt-in external pricing data (non-blocking, never fatal) - if (process.env.PRICING_SYNC_ENABLED === "true") { - try { - const { initPricingSync } = await import("./lib/pricingSync"); - await initPricingSync(); - } catch (err) { - startupLog.warn({ error: getErrorMessage(err) }, "Pricing sync could not initialize"); - } - } - - // Arena ELO sync: model intelligence from leaderboard data (non-blocking, never fatal). - // On by default; opt out with Dashboard Feature Flags or ARENA_ELO_SYNC_ENABLED=false. - try { - const { initArenaEloSync } = await import("./lib/arenaEloSync"); - await initArenaEloSync(); - } catch (err) { - startupLog.warn({ error: getErrorMessage(err) }, "Arena ELO sync could not initialize"); - } - - // Radar daily feed sync: only arms itself when RADAR_ENABLED AND the user - // opt-in are already on (a flag-off boot stays timer-free — Radar inertia - // contract). Non-blocking, never fatal. - try { - const { initRadarSyncScheduler } = await import("./lib/radar/scheduler"); - initRadarSyncScheduler(); - } catch (err) { - startupLog.warn({ error: getErrorMessage(err) }, "Radar sync scheduler could not initialize"); - } -} - -// Start the server initialization -startServer().catch((err) => { - startupLog.error({ err }, "Server initialization failed"); - process.exit(1); -}); - -// Export for use as module if needed -export default startServer; diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index f2619a189b..9f4e46bed1 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -26,6 +26,7 @@ import { AUTHZ_HEADER_REQUEST_ID, AUTHZ_HEADER_ROUTE_CLASS, AUTHZ_TRUSTED_HEADERS, + CLI_TOKEN_HEADER, PEER_IP_HEADER, VIA_PROXY_HEADER, } from "./headers"; @@ -330,6 +331,11 @@ export async function runAuthzPipeline( process.env.OMNIROUTE_PEER_STAMP_TOKEN ); requestHeaders.set(AUTHZ_HEADER_PEER_LOCALITY, peerLocality); + // Local CLI-token auth is decided centrally above. Preserve that trusted + // decision for route-level requireManagementAuth without forwarding the + // machine token itself: custom client auth headers are stripped before the + // route runs, so the route consumes only the stamped auth subject. + requestHeaders.delete(CLI_TOKEN_HEADER); if (method === "OPTIONS") { const preflight = new NextResponse(null, { status: 204 }); diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index e3523035f6..772c801247 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -77,6 +77,7 @@ function isPrivateLanRequest(ctx: PolicyContext): boolean { } function hasValidCliToken(ctx: PolicyContext): boolean { + if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") return false; if (!isLoopbackRequest(ctx)) return false; const headers = ctx.request.headers; const provided = headers.get(CLI_TOKEN_HEADER); diff --git a/src/server/ws/liveServer.ts b/src/server/ws/liveServer.ts index fc640415eb..4c2287fa20 100644 --- a/src/server/ws/liveServer.ts +++ b/src/server/ws/liveServer.ts @@ -11,6 +11,10 @@ * Server → Client: { type: "pong" } * Server → Client: { type: "welcome", version, sessionId, channels, backlog } * Server → Client: { type: "error", code, message } + * + * Liveness: besides the application ping/pong above, the server sends a protocol-level + * ping (RFC 6455 §5.5.2) each HEARTBEAT_INTERVAL_MS. Conformant clients answer it with a + * pong control frame automatically, so a quiet-but-alive subscriber survives. */ import { WebSocketServer, WebSocket } from "ws"; @@ -428,10 +432,13 @@ function startHeartbeat(server: WebSocketServer): void { clients.delete(clientId); continue; } - // Send the application-level heartbeat response. Only inbound client - // messages (including { type: "ping" }) refresh lastActivity; renewing it - // here would keep a half-open socket alive indefinitely (#10452). + // Send the application-level heartbeat response for clients that still rely on it. sendTo(client.ws, { type: "pong" } as WsServerMessage); + // Protocol-level ping: the client's automatic pong reply is what keeps a + // silent-but-alive subscriber alive, while a half-open socket stays silent and is + // still reaped. Nothing here refreshes lastActivity — only a received pong does, + // so #10452 holds. + client.ws.ping(); } }, HEARTBEAT_INTERVAL_MS); // Don't keep the process alive solely for the heartbeat (it is also cleared on close). @@ -567,6 +574,13 @@ export async function startLiveDashboardServer( console.error("[LiveWS] Client error %s: %s", clientId, err.message); clients.delete(clientId); }); + + // A control-frame pong (RFC 6455 §5.5.3) from the client is the only signal that + // proves the socket is not half-open (see startHeartbeat). + ws.on("pong", () => { + const current = clients.get(clientId); + if (current) current.lastActivity = Date.now(); + }); }); // Heartbeat @@ -610,9 +624,7 @@ export async function startLiveDashboardServer( // Build/test environments never auto-start regardless of the flag. function isBuildOrTest(): boolean { - return ( - isBuildProcess() || isAutomatedTestProcess() - ); + return isBuildProcess() || isAutomatedTestProcess(); } export function isLiveWsEnabled(): boolean { diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 5cc190c5f8..2a885f7573 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -430,13 +430,17 @@ export default function OAuthModal({ const verifyUrl = data.verification_uri_complete || data.verification_uri; if (typeof verifyUrl === "string" && verifyUrl) window.open(verifyUrl, "oauth_verify"); - // Start polling - pass extraData for Kiro (contains _clientId, _clientSecret) + // Start polling - pass extraData for Kiro (contains _clientId, _clientSecret). + // _authMethod must be forwarded too: pollToken falls back to "builder-id" without it, + // which makes postExchange skip the Q Developer profile lookup. An IdC connection then + // gets persisted with no profileArn and every usage call returns 403. const extraData = provider === "kiro" || provider === "amazon-q" ? { _clientId: data._clientId, _clientSecret: data._clientSecret, _region: data._region, + _authMethod: data._authMethod, } : provider === "ghe-copilot" && gheUrl.trim() ? { gheUrl: gheUrl.trim() } diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index bdd50292a1..aeb6f777ba 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -115,6 +115,7 @@ const KNOWN_SVGS = new Set([ "fal", "fireworks", "freeaiapikey", + "freebuff", "freemodel-dev", "friendli", "galadriel", @@ -240,6 +241,7 @@ const KNOWN_SVGS = new Set([ ]); const LOCAL_SVG_ALIASES: Record = { + "cursor-api": "cursor", "qwen-cloud": "qwencloud", "qwen-cloud-token-plan": "qwencloud", }; diff --git a/src/shared/constants/capabilities/capabilityFilter.ts b/src/shared/constants/capabilities/capabilityFilter.ts index b0c0de8091..4dd0365fb6 100644 --- a/src/shared/constants/capabilities/capabilityFilter.ts +++ b/src/shared/constants/capabilities/capabilityFilter.ts @@ -17,9 +17,11 @@ import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; import { evaluateContextLimit } from "@omniroute/open-sse/services/combo/contextOverrideGate"; -import { hasEstimableContent } from "@omniroute/open-sse/services/combo/knownContextOverflow"; import { isRecord } from "@omniroute/open-sse/services/combo/comboData"; -import { providerSupportsEmulatedToolCalling } from "@omniroute/open-sse/services/combo/comboStructure"; +import { + hasEstimableContent, + providerSupportsEmulatedToolCalling, +} from "@omniroute/open-sse/services/combo/comboStructure"; import { estimateTokens } from "@omniroute/open-sse/services/contextManager"; // ── Types ───────────────────────────────────────────────────────────────── @@ -89,10 +91,15 @@ function isContextOverflow( capabilities: { maxInputTokens: number | null; contextWindow: number | null }, requirements: { requiredContextTokens: number } ): boolean { - return evaluateContextLimit( - { maxInputTokens: capabilities.maxInputTokens, contextWindow: capabilities.contextWindow }, - { estimatedInputTokens: requirements.requiredContextTokens, requiredContextTokens: requirements.requiredContextTokens } - ) === false; + return ( + evaluateContextLimit( + { maxInputTokens: capabilities.maxInputTokens, contextWindow: capabilities.contextWindow }, + { + estimatedInputTokens: requirements.requiredContextTokens, + requiredContextTokens: requirements.requiredContextTokens, + } + ) === false + ); } function valueContainsImageType(value: Record): boolean { @@ -140,7 +147,9 @@ export function buildCapabilityMismatchMessage( structured_output: `Provider '${provider}' does not support structured output`, context_window: `Request exceeds the context window for ${provider}/${model}`, }; - return msgs[terminalReason] || `Provider '${provider}' does not support the required capabilities`; + return ( + msgs[terminalReason] || `Provider '${provider}' does not support the required capabilities` + ); } /** @@ -167,8 +176,11 @@ function collectCapabilityFailures( maxOutputTokens: number | null; }; - if (requirements.requiresTools && (caps.supportsTools === false || !caps.toolCalling) - && !providerSupportsEmulatedToolCalling(provider)) { + if ( + requirements.requiresTools && + (caps.supportsTools === false || !caps.toolCalling) && + !providerSupportsEmulatedToolCalling(provider) + ) { failures.push("tools"); } if (requirements.requiresVision && caps.supportsVision !== true) { @@ -203,9 +215,13 @@ export function checkRequestCapabilityFit( requirements: RequestCapabilityRequirements, provider?: string | null ): CapabilityFilterResult { - const failures = collectCapabilityFailures(capabilities as Record, requirements, provider); + const failures = collectCapabilityFailures( + capabilities as Record, + requirements, + provider + ); if (failures.length === 0) { return { compatible: true, failures: [] }; } return { compatible: false, failures, terminalReason: primaryFailure(failures) }; -} \ No newline at end of file +} diff --git a/src/shared/constants/config.ts b/src/shared/constants/config.ts index eacf73db7e..0810042444 100644 --- a/src/shared/constants/config.ts +++ b/src/shared/constants/config.ts @@ -36,6 +36,7 @@ export const PROVIDER_ENDPOINTS = { "free-ai": "https://api.free.ai/v1/chat/", "void-ai": "https://api.voidai.app/v1/chat/completions", helixmind: "https://helixmind.online/v1/chat/completions", + tabitoken: "https://tabitoken.com/v1/messages", glm: "https://api.z.ai/api/anthropic/v1/messages", glmt: "https://api.z.ai/api/anthropic/v1/messages", "bailian-coding-plan": @@ -53,6 +54,7 @@ export const PROVIDER_ENDPOINTS = { openadapter: "https://api.openadapter.in/v1/chat/completions", dit: "https://api.dit.ai/v1/chat/completions", tokenrouter: "https://api.tokenrouter.com/v1/chat/completions", + "token-kiosk": "https://agent-router.gaib.ai/v1/chat/completions", sumopod: "https://ai.sumopod.com/v1/chat/completions", x5lab: "https://api.x5lab.dev/v1/chat/completions", kenari: "https://kenari.id/v1/chat/completions", diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 98baeafbec..37861d2994 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -236,7 +236,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ warningLevel: "info", }, - // ──────────────── Policies (4) ──────────────── + // ──────────────── Policies (5) ──────────────── { key: "TOOL_POLICY_MODE", label: "Tool Policy Mode", @@ -271,6 +271,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: true, warningLevel: "info", }, + { + key: "DISABLE_CONTEXT_WINDOW_CHECKS", + label: "Disable Context Window Checks", + description: + "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers remain responsible for enforcing their actual limits. Off by default.", + descriptionI18nKey: "featureFlagDisableContextWindowChecksDescription", + category: "policies", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "danger", + }, { key: "CAPABILITY_FILTER_ENABLED", label: "Capability Filter", diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index f7342c54f4..f26b3f653f 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -66,7 +66,14 @@ const BEDROCK_CLAUDE_ALIASES = (...modelIds: string[]) => [ // Provider discovery/sync sources can under-report GLM-5.2 IDs as 128K. // Keep native/bare Z.AI GLM-5.2 context authoritative, but do not blindly apply // it to every provider-wrapped alias: hosted providers can and do cap lower. -const AUTHORITATIVE_CONTEXT_WINDOW_MODEL_IDS = new Set(["glm-5.2", "glm-5.2-high", "glm-5.2-max"]); +const AUTHORITATIVE_CONTEXT_WINDOW_MODEL_IDS = new Set([ + "glm-5.3", + "glm-5.3-high", + "glm-5.3-low", + "glm-5.2", + "glm-5.2-high", + "glm-5.2-max", +]); const AUTHORITATIVE_PROVIDER_CONTEXT_WINDOWS = new Map([ ["cloudflare-ai/@cf/zai-org/glm-5.2", 262144], // Hugging Face Router has 1M-capable backends, but bare routing can select @@ -175,12 +182,54 @@ export const MODEL_SPECS: Record = { }, // ── Gemini 3.7 Flash (current Antigravity/AGY live tiers) ───────── - // The model id itself selects the upstream 10k/4k/1k reasoning tier. Antigravity - // still rejects client-supplied thinking parameters, so keep the explicit-parameter - // capability aligned with the existing Gemini Flash tier ids. - "gemini-3.7-flash-high": { ...GEMINI_35_FLASH_MODEL_SPEC }, - "gemini-3.7-flash-medium": { ...GEMINI_35_FLASH_MODEL_SPEC }, - "gemini-3.7-flash-low": { ...GEMINI_35_FLASH_MODEL_SPEC }, + // The tier suffix configures the thinking budget passed to the upstream + // gemini-3.7-flash-tiered backend (high: 24.5k, medium: 8k, low: 1k). + "gemini-3.7-flash-high": { + maxOutputTokens: 65536, + contextWindow: 1048576, + defaultThinkingBudget: 24576, + thinkingBudgetCap: 24576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.7-flash-medium": { + maxOutputTokens: 65536, + contextWindow: 1048576, + defaultThinkingBudget: 8192, + thinkingBudgetCap: 24576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.7-flash-low": { + maxOutputTokens: 65536, + contextWindow: 1048576, + defaultThinkingBudget: 1024, + thinkingBudgetCap: 24576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.7-flash": { + maxOutputTokens: 65536, + contextWindow: 1048576, + defaultThinkingBudget: 8192, + thinkingBudgetCap: 24576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + aliases: ["gemini-3.7-flash-tiered"], + }, + "gemini-3.7-flash-tiered": { + maxOutputTokens: 65536, + contextWindow: 1048576, + defaultThinkingBudget: 8192, + thinkingBudgetCap: 24576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, // Provider-neutral compatibility for providers that still serve Gemini 3.6. // Antigravity/AGY availability is governed by their own provider catalogs and @@ -525,6 +574,30 @@ export const MODEL_SPECS: Record = { supportsTools: true, }, + // ── Z.AI GLM-5.3 (1M context mirrored from 5.2 — same base model; 128K max + // output; effort via reasoning_effort param, tiers are OmniRoute aliases) ── + "glm-5.3": { + maxOutputTokens: 131072, + contextWindow: 1000000, + thinkingBudgetCap: 38912, + supportsThinking: true, + supportsTools: true, + }, + "glm-5.3-high": { + maxOutputTokens: 131072, + contextWindow: 1000000, + thinkingBudgetCap: 38912, + supportsThinking: true, + supportsTools: true, + }, + "glm-5.3-low": { + maxOutputTokens: 131072, + contextWindow: 1000000, + thinkingBudgetCap: 38912, + supportsThinking: true, + supportsTools: true, + }, + // ── Z.AI GLM-5.2 (1M context, 128K max output, effort tiers) ──── "glm-5.2": { maxOutputTokens: 131072, diff --git a/src/shared/constants/pricing/frontier-labs.ts b/src/shared/constants/pricing/frontier-labs.ts index 2923945b9c..f1bb039a53 100644 --- a/src/shared/constants/pricing/frontier-labs.ts +++ b/src/shared/constants/pricing/frontier-labs.ts @@ -317,20 +317,28 @@ export const DEFAULT_PRICING_FRONTIER = { reasoning: 2.19, cache_creation: 0.55, }, - // DeepSeek official API list prices, checked 2026-08-13. + // DeepSeek official API list prices, checked 2026-08-18. Superseded the + // prior 2026-08-13 flat prices below: DeepSeek switched v4-pro/v4-flash to + // peak/off-peak dynamic pricing on 2026-08-17 (peak = exactly 2x off-peak; + // peak hours 01:00-04:00 and 06:00-10:00 UTC — see + // https://api-docs.deepseek.com/quick_start/pricing/). This static table has + // no time-of-day dimension, so these are the OFF-PEAK (lower-bound) prices — + // a deliberate, documented undercount during the two peak windows, never an + // overcount. True peak-awareness would need a time dimension threaded through + // getPricingForModel() and every call site; out of scope for this fix. "deepseek-v4-pro": { - input: 0.435, - output: 0.87, - cached: 0.003625, - reasoning: 0.87, - cache_creation: 0.435, + input: 0.66, + output: 1.98, + cached: 0.022, + reasoning: 1.98, + cache_creation: 0.66, }, "deepseek-v4-flash": { - input: 0.14, - output: 0.28, - cached: 0.0028, - reasoning: 0.28, - cache_creation: 0.14, + input: 0.22, + output: 0.66, + cached: 0.007, + reasoning: 0.66, + cache_creation: 0.22, }, }, blackbox: { diff --git a/src/shared/constants/pricing/shared-tiers.ts b/src/shared/constants/pricing/shared-tiers.ts index 325ac84fda..8bd2e4ae4f 100644 --- a/src/shared/constants/pricing/shared-tiers.ts +++ b/src/shared/constants/pricing/shared-tiers.ts @@ -111,6 +111,30 @@ export const CLAUDE_SONNET_5_PRICING = { }; export const GLM_PRICING = { + // GLM-5.3 (2026-08-14): Z.ai hasn't published 5.3 rates yet — mirrored from + // GLM-5.2 (same base model; 5.1 and 5.2 also share identical rates). + // Correct when https://docs.z.ai/guides/overview/pricing lists glm-5.3. + "glm-5.3": { + input: 1.2, + output: 5, + cached: 0.3, + reasoning: 5, + cache_creation: 1.2, + }, + "glm-5.3-high": { + input: 1.2, + output: 5, + cached: 0.3, + reasoning: 5, + cache_creation: 1.2, + }, + "glm-5.3-low": { + input: 1.2, + output: 5, + cached: 0.3, + reasoning: 5, + cache_creation: 1.2, + }, "glm-5.2": { input: 1.2, output: 5, diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index d50c63e32a..23f9c53b39 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1,21 +1,12 @@ // Re-export service kinds from leaf module (avoids circular dep with providerSchema) export type { ServiceKind } from "./serviceKinds"; -export { SERVICE_KIND_VALUES } from "./serviceKinds"; - export type RiskNoticeVariant = "oauth" | "webCookie" | "deprecated" | "embedded-service"; -export interface ProviderRiskNoticeFields { - subscriptionRisk?: boolean; - riskNoticeVariant?: RiskNoticeVariant; - isEmbeddedService?: boolean; -} - import { NOAUTH_PROVIDERS } from "./providers/noauth"; export { supportsNoAuthProviderProxy } from "./providers/noauth"; import { OAUTH_PROVIDERS } from "./providers/oauth"; import { WEB_COOKIE_PROVIDERS, resolveWebProviderHost } from "./providers/web-cookie"; export { resolveWebProviderHost }; -export type { WebProviderHostLink } from "./providers/web-cookie"; import { APIKEY_PROVIDERS } from "./providers/apikey"; import { LOCAL_PROVIDERS } from "./providers/local"; import { SEARCH_PROVIDERS } from "./providers/search"; @@ -70,6 +61,10 @@ export const PROVIDER_CONNECTION_FAMILY_ALIASES: Readonly, { }, }); -export type AiProviderId = - | keyof typeof NOAUTH_PROVIDERS - | keyof typeof OAUTH_PROVIDERS - | keyof typeof APIKEY_PROVIDERS - | keyof typeof WEB_COOKIE_PROVIDERS - | keyof typeof LOCAL_PROVIDERS - | keyof typeof SEARCH_PROVIDERS - | keyof typeof AUDIO_ONLY_PROVIDERS - | keyof typeof UPSTREAM_PROXY_PROVIDERS - | keyof typeof CLOUD_AGENT_PROVIDERS - | keyof typeof SYSTEM_PROVIDERS; - export type AiProviderDefinition = | (typeof NOAUTH_PROVIDERS)[keyof typeof NOAUTH_PROVIDERS] | (typeof OAUTH_PROVIDERS)[keyof typeof OAUTH_PROVIDERS] diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index cbfd651529..606d6eb493 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -19,6 +19,21 @@ export const APIKEY_PROVIDERS_GATEWAYS = { "Create an API key at https://cheaperinference.com/?utm_source=omniroute (needs the `inference` scope), then paste the ir_live_… token here.", passthroughModels: true, }, + freebuff: { + id: "freebuff", + alias: "freebuff", + name: "Freebuff", + icon: "terminal", + color: "#10B981", + textIcon: "FB", + website: "https://freebuff.com", + hasFree: true, + serviceKinds: ["llm"], + authHint: "Enter Freebuff / Codebuff Auth Token (obtained via CLI login or automated harvester).", + freeNote: "Free Codebuff / Freebuff AI models.", + apiHint: "Token is authenticated against Codebuff upstream session pool.", + passthroughModels: true, + }, "charm-hyper": { id: "charm-hyper", alias: "charm-hyper", @@ -1055,6 +1070,19 @@ export const APIKEY_PROVIDERS_GATEWAYS = { apiHint: "TokenRouter exposes an OpenAI-compatible chat completions endpoint at https://api.tokenrouter.com/v1/chat/completions, plus a working /v1/models catalog. OmniRoute uses the OpenAI protocol.", }, + "token-kiosk": { + id: "token-kiosk", + alias: "tk", + name: "Token Kiosk", + icon: "hub", + color: "#6366F1", + textIcon: "TKI", + website: "https://agent-router.gaib.ai", + authHint: + "Use your Token Kiosk API key in Authorization: Bearer . Fully OpenAI-compatible gateway. API base URL: https://agent-router.gaib.ai/v1.", + apiHint: + "Token Kiosk is a multi-provider agent LLM routing infrastructure exposing an OpenAI-compatible endpoint at https://agent-router.gaib.ai/v1/chat/completions with auto-fallback and latency routing.", + }, sumopod: { id: "sumopod", alias: "sumopod", @@ -1251,4 +1279,19 @@ export const APIKEY_PROVIDERS_GATEWAYS = { apiHint: "Create a helix- key and use https://helixmind.online/v1. OpenAI requests use Bearer authentication; the Anthropic-compatible messages endpoint accepts x-api-key.", }, + // TabiToken (https://tabitoken.com) — NewAPI-based Claude gateway. Its public pricing + // endpoint lists a Claude-only catalog (Opus 5 / 4.8, each with a -thinking variant), + // every model accepting the Anthropic and OpenAI protocols. + tabitoken: { + id: "tabitoken", + alias: "tabitoken", + name: "TabiToken", + icon: "hub", + color: "#F97316", + textIcon: "TT", + passthroughModels: true, + website: "https://tabitoken.com", + 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.", + }, }; diff --git a/src/shared/constants/providers/apikey/specialty-media.ts b/src/shared/constants/providers/apikey/specialty-media.ts index 88a5049b2b..b6924d239e 100644 --- a/src/shared/constants/providers/apikey/specialty-media.ts +++ b/src/shared/constants/providers/apikey/specialty-media.ts @@ -85,15 +85,16 @@ export const APIKEY_PROVIDERS_SPECIALTY = { website: "https://ideogram.ai", authHint: "Get API key at ideogram.ai/docs/api", }, - freepik: { - id: "freepik", - alias: "fpk", - name: "Freepik (Mystic)", + magnific: { + id: "magnific", + alias: "freepik", + name: "Magnific", icon: "image", color: "#1B9E7F", - textIcon: "FP", - website: "https://freepik.com", - authHint: "Get API key at freepik.com/developers (Mystic image endpoint)", + textIcon: "MG", + website: "https://www.magnific.com", + authHint: + "Get an API key at magnific.com/user/api-keys (header x-magnific-api-key). Legacy Freepik developer keys still work.", hasFree: true, freeNote: "One-time ~€5 API credit for new accounts; pay-per-use afterward.", }, @@ -305,4 +306,19 @@ export const APIKEY_PROVIDERS_SPECIALTY = { apiHint: "DeepAI uses per-endpoint REST calls (e.g. /api/text2img) instead of OpenAI chat/completions. OmniRoute adapts OpenAI image generation requests to DeepAI's /api/{slug} endpoints.", }, + "cursor-api": { + id: "cursor-api", + alias: "cua", + name: "Cursor API", + icon: "edit_note", + color: "#00D4AA", + textIcon: "CA", + website: "https://cursor.com/dashboard/api", + subscriptionRisk: true, + riskNoticeVariant: "oauth", + authHint: + "Paste a Cursor user API key (crsr_...) from cursor.com/dashboard/api. OmniRoute exchanges it for a session token on demand; no IDE or cursor-agent install is needed. Usage bills to the Cursor plan that owns the key.", + apiHint: + "Same agent protocol and model catalog as the Cursor IDE provider. The Cursor CLI can also be pointed at /api/cursor-cli on this instance and authenticated with an OmniRoute API key.", + }, }; diff --git a/src/shared/constants/providers/local.ts b/src/shared/constants/providers/local.ts index a16939fbb9..a3e455d64f 100644 --- a/src/shared/constants/providers/local.ts +++ b/src/shared/constants/providers/local.ts @@ -3,6 +3,32 @@ * Pure data literal; re-exported by the providers.ts barrel. No behavior change. */ export const LOCAL_PROVIDERS = { + "mlx-gemma": { + id: "mlx-gemma", + alias: "mlx-gemma", + name: "MLX Gemma 26B", + icon: "memory", + color: "#8B5CF6", + textIcon: "MG", + website: "https://github.com/ml-explore/mlx", + authHint: + "No API key required. Runs mlx-lm server locally on port 11435. Requires uv and mlx-lm installed. Model: mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned (~15.9GB peak memory).", + localDefault: "http://localhost:11435/v1", + passthroughModels: false, + }, + "mlx-qwen": { + id: "mlx-qwen", + alias: "mlx-qwen", + name: "MLX Qwen 3.8 27B", + icon: "memory", + color: "#EC4899", + textIcon: "MQ", + website: "https://github.com/ml-explore/mlx", + authHint: + "No API key required. Runs mlx-lm server locally on port 11436. Requires uv and mlx-lm installed. Model: maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw (~13.1GB peak memory).", + localDefault: "http://localhost:11436/v1", + passthroughModels: false, + }, "ollama-local": { id: "ollama-local", alias: "ollama", diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index e01fa95190..6d1b08be7d 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -152,7 +152,7 @@ export const WEB_COOKIE_PROVIDERS = { textIcon: "M365", website: "https://m365.cloud.microsoft/chat", authHint: - "Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration.", + "Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. Optional: store a refresh_token in providerSpecificData.refreshToken (any Microsoft device-code/refresh flow for the substrate.office.com/sydney scopes) and OmniRoute pre-flight-refreshes the access token itself — otherwise re-capture after every ~75 min expiry.", subscriptionRisk: true, riskNoticeVariant: "webCookie", }, diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index 07d8610adf..7044089a59 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -30,6 +30,12 @@ const PUBLIC_API_ROUTE_PREFIXES = [ // auth (503 when TELEGRAM_BOT_TOKEN is unset; 401 on invalid initData // HMAC). See src/app/api/telegram/update/route.ts. Do not widen. "/api/telegram/", + // Cursor CLI passthrough (CURSOR_API_ENDPOINT -> OmniRoute -> api2.cursor.sh). + // The handler enforces its own auth: /auth/exchange_user_api_key requires an + // OmniRoute API key (validateApiKey); every other path requires the + // OmniRoute-minted session JWT that exchange returns. See + // open-sse/handlers/cursorCliProxy.ts. Do not widen. + "/api/cursor-cli/", ]; const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ @@ -38,6 +44,14 @@ const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ "/api/settings/require-login", ]; +// Read-only routes public by EXACT path, never by prefix. +// +// `/api/health` has to be reachable without a key — a probe has none, and a 401 there is +// indistinguishable from a wrong key or a missing route. It cannot go in the prefix list +// above: `startsWith("/api/health")` would also expose `/api/health/degradation`, which is +// authenticated today. +const PUBLIC_READONLY_API_ROUTES_EXACT = new Set(["/api/health"]); + const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); const PUBLIC_CLOUD_API_ROUTES = [ @@ -70,7 +84,18 @@ export function isPublicApiRoute(pathname: string, method = "GET"): boolean { return false; } + for (const route of PUBLIC_READONLY_API_ROUTES_EXACT) { + if (pathMatchesExactRoute(pathname, route)) { + return true; + } + } + return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((route) => pathname.startsWith(route)); } -export { PUBLIC_API_ROUTE_PREFIXES, PUBLIC_READONLY_API_ROUTE_PREFIXES, PUBLIC_READONLY_METHODS }; +export { + PUBLIC_API_ROUTE_PREFIXES, + PUBLIC_READONLY_API_ROUTE_PREFIXES, + PUBLIC_READONLY_API_ROUTES_EXACT, + PUBLIC_READONLY_METHODS, +}; diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts index ef1733f8db..1c3c25b904 100644 --- a/src/shared/middleware/chatBodyAdmission.ts +++ b/src/shared/middleware/chatBodyAdmission.ts @@ -655,9 +655,8 @@ export async function admitChatStructure( } = {} ): Promise { if (!body || typeof body !== "object" || Array.isArray(body)) return { admit: true, lease }; - const record = body as Record; - const messages = Array.isArray(record.messages) ? record.messages : []; + const messages = [record.messages, record.input].flat().filter((item) => item != null); const tools = Array.isArray(record.tools) ? record.tools : []; const maxMessages = options.maxMessages ?? CHAT_HARD_MAX_MESSAGES; // Opt-in only: `0`/unset means no history cap, so oversized conversations reach the diff --git a/src/shared/middleware/withChatAdmission.ts b/src/shared/middleware/withChatAdmission.ts new file mode 100644 index 0000000000..7a762b6f9e --- /dev/null +++ b/src/shared/middleware/withChatAdmission.ts @@ -0,0 +1,48 @@ +/** + * Compose process-wide chat admission in front of a route handler. + * + * Uses the shipped `admitChatRequest` budget/fairness controller — it does not + * introduce a second admission path. Call this *outside* `withInjectionGuard` + * so a large `/v1/responses` or `/v1/messages` body is reserved (or 503-shed) + * before `request.clone()` / `.json()`. + */ +import { + admitChatRequest, + CHAT_ADMISSION_QUEUE_MAX_MS, + releaseChatAdmissionAfterHandler, + resolveSessionId, + type ChatAdmissionController, +} from "./chatBodyAdmission"; + +type RouteHandler = (request: Request, ...args: any[]) => Promise | Response; + +export function withChatAdmission( + handler: RouteHandler, + options: { + controller?: ChatAdmissionController; + queueMs?: number; + largeBodyBytes?: number; + hardMaxBytes?: number; + } = {} +): RouteHandler { + return async function admittedHandler(request: Request, ...args: any[]) { + const sessionId = resolveSessionId(request); + const admission = await admitChatRequest(request, { + sessionId, + queueMs: options.queueMs ?? CHAT_ADMISSION_QUEUE_MAX_MS, + controller: options.controller, + largeBodyBytes: options.largeBodyBytes, + hardMaxBytes: options.hardMaxBytes, + }); + if (admission.admit === false) return admission.response; + try { + return await releaseChatAdmissionAfterHandler( + Promise.resolve(handler(admission.request, ...args)), + admission.lease + ); + } catch (error) { + admission.lease?.release(); + throw error; + } + }; +} diff --git a/src/shared/network/outboundUrlGuard.ts b/src/shared/network/outboundUrlGuard.ts index 8a63de5be7..e7175da0bc 100644 --- a/src/shared/network/outboundUrlGuard.ts +++ b/src/shared/network/outboundUrlGuard.ts @@ -44,6 +44,9 @@ export function isPrivateHost(hostname: string) { if ( normalized === "localhost" || normalized === "0.0.0.0" || + // `::` is the IPv6 twin of `0.0.0.0`: connecting to it reaches a service bound + // to the IPv6 loopback, so it has to be refused alongside its IPv4 spelling. + normalized === "::" || normalized === "127.0.0.1" || normalized === "::1" || normalized.endsWith(".localhost") || @@ -81,6 +84,24 @@ export function isPrivateHost(hostname: string) { return false; } +// WHATWG URL serialises an IPv4-mapped IPv6 address as hextets, so +// `http://[::ffff:169.254.169.254]/` reaches these helpers as `::ffff:a9fe:a9fe`. +// Matching the dotted spelling alone therefore misses every mapped address that +// arrives through a parsed URL. Fold the embedded IPv4 back out before deciding. +function mappedIpv4Host(hostname: string): string | null { + const normalized = normalizeHost(hostname); + if (!normalized.startsWith("::ffff:")) return null; + const embedded = normalized.slice("::ffff:".length); + if (isIP(embedded) === 4) return embedded; + const hextets = embedded.split(":"); + if (hextets.length !== 2) return null; + const [high, low] = hextets.map((part) => + /^[0-9a-f]{1,4}$/.test(part) ? parseInt(part, 16) : Number.NaN + ); + if (Number.isNaN(high) || Number.isNaN(low)) return null; + return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; +} + const CLOUD_METADATA_HOSTNAMES = new Set([ "169.254.169.254", // AWS / GCP / Azure / Oracle IMDS "metadata.google.internal", // GCP @@ -89,6 +110,11 @@ const CLOUD_METADATA_HOSTNAMES = new Set([ "fd00:ec2::254", // AWS IPv6 IMDS ]); +function isCloudMetadataIpv4(host: string): boolean { + if (CLOUD_METADATA_HOSTNAMES.has(host)) return true; + return host.startsWith("169.254."); // IPv4 link-local /16 +} + /** * Cloud-metadata and IPv4 link-local (169.254.0.0/16) endpoints are the classic * SSRF→IAM-credential pivot and have no legitimate webhook/automation use case. They are @@ -97,9 +123,11 @@ const CLOUD_METADATA_HOSTNAMES = new Set([ export function isCloudMetadataHost(hostname: string): boolean { const host = normalizeHost(hostname); if (!host) return false; - if (CLOUD_METADATA_HOSTNAMES.has(host)) return true; - if (host.startsWith("169.254.")) return true; // IPv4 link-local /16 - return false; + if (isCloudMetadataIpv4(host)) return true; + // An IPv4-mapped IPv6 literal routes to the embedded IPv4 address, so the same + // verdict has to apply to it — otherwise this block is spelling-sensitive. + const mapped = mappedIpv4Host(host); + return mapped !== null && isCloudMetadataIpv4(mapped); } export function parseOutboundUrl(input: string | URL) { diff --git a/src/shared/reasoning/reasoningEffortsOverride.ts b/src/shared/reasoning/reasoningEffortsOverride.ts new file mode 100644 index 0000000000..a91e55e646 --- /dev/null +++ b/src/shared/reasoning/reasoningEffortsOverride.ts @@ -0,0 +1,55 @@ +export const REASONING_EFFORT_OVERRIDE_VALUES = [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", +] as const; + +export type ReasoningEffortOverrideValue = (typeof REASONING_EFFORT_OVERRIDE_VALUES)[number]; + +const REASONING_EFFORT_OVERRIDE_SET = new Set(REASONING_EFFORT_OVERRIDE_VALUES); +const EDGE_INVISIBLE_PATTERN = + /^[\p{White_Space}\p{Separator}\p{Control}\p{Format}]+|[\p{White_Space}\p{Separator}\p{Control}\p{Format}]+$/gu; +const NON_ASCII_COMMA_PATTERN = + /[،、︐︑﹐﹑,、]/u; + +export type ReasoningEffortsOverrideParseResult = + { ok: true; efforts: ReasoningEffortOverrideValue[] } | { ok: false; error: string }; + +function stripInvisibleEdges(value: string): string { + return value.replace(EDGE_INVISIBLE_PATTERN, ""); +} + +/** Parse one ASCII-comma-separated native reasoning-effort vocabulary. */ +export function parseReasoningEffortsOverride(value: unknown): ReasoningEffortsOverrideParseResult { + if (typeof value !== "string") { + return { ok: false, error: "reasoning_efforts must be a string" }; + } + if (NON_ASCII_COMMA_PATTERN.test(value)) { + return { ok: false, error: "reasoning_efforts must use English commas" }; + } + + const segments = value.split(","); + + const efforts: ReasoningEffortOverrideValue[] = []; + const seen = new Set(); + for (const segment of segments) { + const effort = stripInvisibleEdges(segment).toLowerCase(); + if (!effort) { + return { ok: false, error: "reasoning_efforts contains an empty item" }; + } + if (!REASONING_EFFORT_OVERRIDE_SET.has(effort)) { + return { ok: false, error: `Unsupported reasoning effort: ${effort}` }; + } + if (seen.has(effort)) { + return { ok: false, error: `Duplicate reasoning effort: ${effort}` }; + } + seen.add(effort); + efforts.push(effort as ReasoningEffortOverrideValue); + } + + return { ok: true, efforts }; +} diff --git a/src/shared/types/utilization.ts b/src/shared/types/utilization.ts index 20ec1850d1..4b637e7385 100644 --- a/src/shared/types/utilization.ts +++ b/src/shared/types/utilization.ts @@ -272,7 +272,8 @@ export type ComboScoringInspectorFactorKey = | "cacheAffinity" | "sessionAvailability" | "resetWindowAffinity" - | "connectionDensity"; + | "connectionDensity" + | "quality"; export type ComboScoringInspectorSource = "combo_health" | "combo_forecast" | "combo_autopilot" | "runtime" | "default"; diff --git a/src/shared/utils/clineAuth.ts b/src/shared/utils/clineAuth.ts index c8bab6214c..b923332fa0 100644 --- a/src/shared/utils/clineAuth.ts +++ b/src/shared/utils/clineAuth.ts @@ -13,6 +13,8 @@ import { randomUUID } from "node:crypto"; import { APP_CONFIG } from "../constants/appConfig"; const APP_VERSION = APP_CONFIG.version; +const DEFAULT_CLINE_CLIENT_TYPE = "omniroute"; +const INTERNAL_HEALTH_CHECK_CLIENT_TYPE = "omniroute-internal-health-check"; export interface ClineHeaderContext { taskId?: string; @@ -44,6 +46,12 @@ export function resolveClineTaskId(clientHeaders?: Record | null return getHeaderCaseInsensitive(clientHeaders, "x-task-id") ?? randomUUID(); } +function resolveClineClientType(clientHeaders?: Record | null): string | undefined { + return getHeaderCaseInsensitive(clientHeaders, "x-internal-test") === "combo-health-check" + ? INTERNAL_HEALTH_CHECK_CLIENT_TYPE + : undefined; +} + /** * Apply the required Cline billing headers with case-insensitive replacement. * These fields are authoritative in the official client and must win over @@ -58,12 +66,18 @@ export function applyClineProtocolHeaders( getHeaderCaseInsensitive(headers, "x-task-id") ?? randomUUID(); const clientVersion = cleanHeaderValue(context.clientVersion) ?? APP_VERSION; + const existingClientType = getHeaderCaseInsensitive(headers, "x-client-type"); + const clientType = + cleanHeaderValue(context.clientType) ?? + (existingClientType === INTERNAL_HEALTH_CHECK_CLIENT_TYPE + ? INTERNAL_HEALTH_CHECK_CLIENT_TYPE + : DEFAULT_CLINE_CLIENT_TYPE); const required: Record = { "HTTP-Referer": "https://cline.bot", "X-Title": "Cline", "User-Agent": `Cline/${clientVersion}`, "X-IS-MULTIROOT": context.isMultiRoot === true ? "true" : "false", - "X-CLIENT-TYPE": cleanHeaderValue(context.clientType) ?? "omniroute", + "X-CLIENT-TYPE": clientType, "X-CLIENT-VERSION": clientVersion, "X-PLATFORM": cleanHeaderValue(context.platform) ?? process.platform ?? "unknown", "X-PLATFORM-VERSION": cleanHeaderValue(context.platformVersion) ?? process.version ?? "unknown", @@ -159,7 +173,10 @@ export function applyClineAuthHeaders( clientHeaders: Record | null | undefined, isClinepass: boolean ): Record { - const context: ClineHeaderContext = { taskId: resolveClineTaskId(clientHeaders) }; + const context: ClineHeaderContext = { + taskId: resolveClineTaskId(clientHeaders), + clientType: resolveClineClientType(clientHeaders), + }; const built = isClinepass ? buildClinepassHeaders(credentials, effectiveKey, context) : buildClineHeaders(effectiveKey || credentials?.accessToken, {}, context); diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 9a3582370d..54874fcbff 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -72,6 +72,22 @@ export function isCcCompatibleProviderEnabled(): boolean { return isFeatureFlagEnabled("ENABLE_CC_COMPATIBLE_PROVIDER"); } +/** + * Context-window checks are fail-safe: an unavailable flag store must never + * silently disable local request bounds. + */ +export function areContextWindowChecksDisabled(): boolean { + try { + return isFeatureFlagEnabled("DISABLE_CONTEXT_WINDOW_CHECKS"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve DISABLE_CONTEXT_WINDOW_CHECKS, keeping checks enabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function isApiKeyRevealEnabledFlag(): boolean { try { return isFeatureFlagEnabled("ALLOW_API_KEY_REVEAL"); diff --git a/src/shared/utils/kimiBilling.ts b/src/shared/utils/kimiBilling.ts new file mode 100644 index 0000000000..c818a675aa --- /dev/null +++ b/src/shared/utils/kimiBilling.ts @@ -0,0 +1,199 @@ +/** + * Public Dashboard contract for Kimi Coding Extra Usage (额度加油包). + * + * The existing read-only `GET /coding/v1/usages` response carries both the + * Code quota windows and `boosterWallet`. Only the strictly whitelisted fields + * below may cross the Provider Limits cache/UI boundary. + */ + +export const KIMI_CODE_ADDITIONAL_CREDITS_URL = + "https://www.kimi.com/membership/subscription?tab=quota&aff=omniroute"; + +type KimiExtraUsageStatus = "enabled" | "disabled" | "frozen" | "unavailable"; + +export interface KimiBillingStatus { + /** ISO 4217 currency reported by the wallet money wrappers. */ + currency: string; + /** Remaining Extra Usage balance in cents. */ + extraCreditsMinorUnits?: number; + /** Extra Usage spend so far this calendar month, in cents. */ + monthlyUsedMinorUnits?: number; + /** Whether the member enabled a monthly spending cap. */ + monthlyLimitEnabled?: boolean; + /** Monthly spending cap in cents; 0/absent means unlimited. */ + monthlyLimitMinorUnits?: number; + extraUsageStatus: KimiExtraUsageStatus; + additionalCreditsUrl: typeof KIMI_CODE_ADDITIONAL_CREDITS_URL; +} + +type KimiBillingTranslationKey = + | "kimiExtraUsageCredits" + | "kimiExtraUsage" + | "kimiExtraUsageEnabled" + | "kimiExtraUsageDisabled" + | "kimiExtraUsageFrozen" + | "kimiExtraUsageUnavailable" + | "kimiMonthlyUsed" + | "kimiMonthlyLimit" + | "kimiMonthlyLimitUnlimited" + | "kimiAdditionalCredits"; + +type KimiBillingTranslator = (key: KimiBillingTranslationKey, fallback: string) => string; + +type KimiBillingCardRow = + | { kind: "balance" | "status"; label: string; value: string } + | { + kind: "link"; + label: string; + href: typeof KIMI_CODE_ADDITIONAL_CREDITS_URL; + target: "_blank"; + rel: "noreferrer noopener"; + }; + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function minorUnits(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +const ISO_4217 = /^[A-Za-z]{3}$/; +const EXTRA_USAGE_STATUSES = new Set([ + "enabled", + "disabled", + "frozen", + "unavailable", +]); + +export function sanitizeKimiBillingStatus(value: unknown): KimiBillingStatus | undefined { + const billing = toRecord(value); + if (!billing || billing.additionalCreditsUrl !== KIMI_CODE_ADDITIONAL_CREDITS_URL) + return undefined; + + const currency = + typeof billing.currency === "string" && ISO_4217.test(billing.currency) + ? billing.currency.toUpperCase() + : undefined; + const extraUsageStatus = + typeof billing.extraUsageStatus === "string" && + EXTRA_USAGE_STATUSES.has(billing.extraUsageStatus as KimiExtraUsageStatus) + ? (billing.extraUsageStatus as KimiExtraUsageStatus) + : undefined; + if (!currency || !extraUsageStatus) return undefined; + + const extraCreditsMinorUnits = minorUnits(billing.extraCreditsMinorUnits); + const monthlyUsedMinorUnits = minorUnits(billing.monthlyUsedMinorUnits); + const monthlyLimitMinorUnits = minorUnits(billing.monthlyLimitMinorUnits); + const monthlyLimitEnabled = + typeof billing.monthlyLimitEnabled === "boolean" ? billing.monthlyLimitEnabled : undefined; + + return { + currency, + ...(extraCreditsMinorUnits !== undefined ? { extraCreditsMinorUnits } : {}), + ...(monthlyUsedMinorUnits !== undefined ? { monthlyUsedMinorUnits } : {}), + ...(monthlyLimitEnabled !== undefined ? { monthlyLimitEnabled } : {}), + ...(monthlyLimitMinorUnits !== undefined ? { monthlyLimitMinorUnits } : {}), + extraUsageStatus, + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + }; +} + +function formatKimiMinorUnits( + value: number | undefined, + currency: KimiBillingStatus["currency"], + locales?: Intl.LocalesArgument +): string | null { + if (value === undefined) return null; + return new Intl.NumberFormat(locales, { + style: "currency", + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value / 100); +} + +const fallbackTranslation: KimiBillingTranslator = (_key, fallback) => fallback; + +function formatExtraUsageStatus( + status: KimiExtraUsageStatus, + translate: KimiBillingTranslator +): string { + switch (status) { + case "enabled": + return translate("kimiExtraUsageEnabled", "Enabled"); + case "disabled": + return translate("kimiExtraUsageDisabled", "Disabled"); + case "frozen": + return translate("kimiExtraUsageFrozen", "Frozen"); + default: + return translate("kimiExtraUsageUnavailable", "Unavailable"); + } +} + +export function buildKimiBillingCardRows( + billing: KimiBillingStatus, + locales?: Intl.LocalesArgument, + translate: KimiBillingTranslator = fallbackTranslation +): KimiBillingCardRow[] { + const rows: KimiBillingCardRow[] = []; + const walletPresent = billing.extraCreditsMinorUnits !== undefined; + + const extraCredits = formatKimiMinorUnits( + billing.extraCreditsMinorUnits, + billing.currency, + locales + ); + if (extraCredits !== null) { + rows.push({ + kind: "balance", + label: translate("kimiExtraUsageCredits", "Extra Usage Credits"), + value: extraCredits, + }); + } + + rows.push({ + kind: "status", + label: translate("kimiExtraUsage", "Extra Usage"), + value: formatExtraUsageStatus(billing.extraUsageStatus, translate), + }); + + if (walletPresent) { + const monthlyUsed = formatKimiMinorUnits( + billing.monthlyUsedMinorUnits, + billing.currency, + locales + ); + if (monthlyUsed !== null) { + rows.push({ + kind: "status", + label: translate("kimiMonthlyUsed", "Used this month"), + value: monthlyUsed, + }); + } + + const capped = + billing.monthlyLimitEnabled === true && + billing.monthlyLimitMinorUnits !== undefined && + billing.monthlyLimitMinorUnits > 0; + const monthlyLimit = capped + ? formatKimiMinorUnits(billing.monthlyLimitMinorUnits, billing.currency, locales) + : null; + rows.push({ + kind: "status", + label: translate("kimiMonthlyLimit", "Monthly limit"), + value: monthlyLimit ?? translate("kimiMonthlyLimitUnlimited", "Unlimited"), + }); + } + + rows.push({ + kind: "link", + label: translate("kimiAdditionalCredits", "Additional Credits"), + href: billing.additionalCreditsUrl, + target: "_blank", + rel: "noreferrer noopener", + }); + return rows; +} diff --git a/src/shared/utils/probeOrigin.ts b/src/shared/utils/probeOrigin.ts new file mode 100644 index 0000000000..1c426e117c --- /dev/null +++ b/src/shared/utils/probeOrigin.ts @@ -0,0 +1,63 @@ +/** + * Probe-origin tracking via AsyncLocalStorage. + * + * Convention: ANY probe flow (model test-all, future batch tests, + * credential-health if it ever routes through the chat path) MUST execute + * inside runAsProbe() so deactivation guards can refuse probe-origin + * failures (invariant #9817: only a real request-path failure deactivates + * a connection). Pinned by tests/unit/probe-testall-isolation.test.ts. + * + * NOTE: when a probe dispatches through a scheduler with a queue + * (Bottleneck via withRateLimit), runAsProbe must wrap the scheduled fn + * itself — a queued job otherwise executes outside this context + * (pinned by the queued-scheduler test below). + * + * EXCEPTIONS (deliberate, documented in the PR): tokenHealthCheck refresh + * failures keep deactivating (re-auth semantics — a dead refresh token is + * a real death, not a probe artifact), and circuit-breaker HALF_OPEN + * probes are real generations by design. Those flows stay outside + * runAsProbe. + */ +import { AsyncLocalStorage } from "node:async_hooks"; + +const probeContext = new AsyncLocalStorage<{ probe: true }>(); + +export function runAsProbe(fn: () => Promise): Promise { + return probeContext.run({ probe: true }, fn); +} + +export function isProbeContext(): boolean { + return probeContext.getStore() !== undefined; +} + +/** + * Central probe-isolation decision used by every deactivation site. + * + * True when the current execution is probe-origin AND the opt-in setting + * `probeCanDisable` is OFF (default): the probe failure is recorded but + * must never remove the connection from the pool (cooldowns, terminal + * status, per-model lockouts, auto-disable, circuit breaker). Operators + * who use test-all as a maintenance tool set `probeCanDisable: true` to + * restore the historical behavior where a probe counts as a real + * generation. + */ +export async function shouldIsolateProbeFailures(): Promise { + if (!isProbeContext()) return false; + // Feature-flag kill-switch (env/DB override; fail-safe false like the + // AUTH_LOG_INCLUDE_ACCOUNT_ID usage): PROBE_CAN_DISABLE restores the + // historical behavior where a probe counts as a real generation. + try { + const { isFeatureFlagEnabled } = await import("@/shared/utils/featureFlags"); + if (isFeatureFlagEnabled("PROBE_CAN_DISABLE")) return false; + } catch { + // Fail-safe: on lookup failure the isolation stays ON. + } + try { + const { getCachedSettings } = await import("@/lib/db/readCache"); + const settings = await getCachedSettings(); + return !settings.probeCanDisable; + } catch { + // Fail-safe: on settings-lookup failure the isolation stays ON. + return true; + } +} diff --git a/src/shared/utils/providerBilling.ts b/src/shared/utils/providerBilling.ts new file mode 100644 index 0000000000..f2ddca4dee --- /dev/null +++ b/src/shared/utils/providerBilling.ts @@ -0,0 +1,36 @@ +import { + GROK_BUILD_ADDITIONAL_CREDITS_URL, + sanitizeGrokBillingStatus, + type GrokBillingStatus, +} from "./grokBilling"; +import { + KIMI_CODE_ADDITIONAL_CREDITS_URL, + sanitizeKimiBillingStatus, + type KimiBillingStatus, +} from "./kimiBilling"; + +export type ProviderBillingStatus = GrokBillingStatus | KimiBillingStatus; + +export const PROVIDER_BILLING_PROVIDERS = [ + "grok-cli", + "kimi-coding", + "kimi-coding-apikey", +] as const; + +export function isProviderBillingProvider(provider: string | undefined): boolean { + return ( + provider !== undefined && (PROVIDER_BILLING_PROVIDERS as readonly string[]).includes(provider) + ); +} + +export function sanitizeProviderBillingStatus(value: unknown): ProviderBillingStatus | undefined { + return sanitizeGrokBillingStatus(value) ?? sanitizeKimiBillingStatus(value); +} + +export function isGrokBillingStatus(billing: ProviderBillingStatus): billing is GrokBillingStatus { + return billing.additionalCreditsUrl === GROK_BUILD_ADDITIONAL_CREDITS_URL; +} + +export function isKimiBillingStatus(billing: ProviderBillingStatus): billing is KimiBillingStatus { + return billing.additionalCreditsUrl === KIMI_CODE_ADDITIONAL_CREDITS_URL; +} diff --git a/src/shared/utils/secretsValidator.ts b/src/shared/utils/secretsValidator.ts index dd3415da1d..37e86de9dc 100644 --- a/src/shared/utils/secretsValidator.ts +++ b/src/shared/utils/secretsValidator.ts @@ -101,35 +101,3 @@ export function validateSecrets(env = process.env) { warnings, }; } - -/** - * Validate secrets and terminate process if critical ones are missing. - * Should be called during server initialization (fail-fast). - * @param {object} [logger] - Optional logger (defaults to console) - */ -export function enforceSecrets(logger = console, env = process.env) { - const result = validateSecrets(env); - - // Print warnings (non-fatal) - for (const w of result.warnings) { - logger.warn(`⚠️ [SECURITY] ${w.issue}`); - } - - // If there are errors, print them and exit - if (!result.valid) { - logger.error(""); - logger.error("═══════════════════════════════════════════════════"); - logger.error(" ❌ SECURITY: Missing required secrets"); - logger.error("═══════════════════════════════════════════════════"); - for (const e of result.errors) { - logger.error(` • ${e.issue}`); - logger.error(` → ${e.hint}`); - } - logger.error(""); - logger.error(" Set these in your .env file or environment."); - logger.error(" See .env.example for reference."); - logger.error("═══════════════════════════════════════════════════"); - logger.error(""); - process.exit(1); - } -} diff --git a/src/shared/validation/compressionConfigSchemas.ts b/src/shared/validation/compressionConfigSchemas.ts index 50a21aaf10..e0ba0b6fe5 100644 --- a/src/shared/validation/compressionConfigSchemas.ts +++ b/src/shared/validation/compressionConfigSchemas.ts @@ -71,6 +71,8 @@ export const rtkConfigSchema = z trustProjectFilters: z.boolean().optional(), rawOutputRetention: rtkRawOutputRetentionSchema.optional(), rawOutputMaxBytes: z.number().int().min(1024).max(10_000_000).optional(), + rawOutputMaxFiles: z.number().int().min(1).max(10_000_000).optional(), + rawOutputMaxAgeDays: z.number().int().min(1).max(3650).optional(), enableGrouping: z.boolean().optional(), groupingThreshold: z.number().int().min(2).max(100).optional(), stripCodeComments: z.boolean().optional(), diff --git a/src/shared/validation/helpers.ts b/src/shared/validation/helpers.ts index b10ca0c7bd..4e486c7287 100644 --- a/src/shared/validation/helpers.ts +++ b/src/shared/validation/helpers.ts @@ -56,6 +56,19 @@ export function isValidationFailure( return validation.success === false; } +/** + * Build a human-readable 400 message from a validation failure, naming the + * first offending field instead of the generic "Invalid request" (#10849). + * Intended for routes that reply with a single message string (e.g. + * `errorResponse()`) rather than the full `{ message, details }` envelope + * returned by `validatedJsonBody()`. + */ +export function formatValidationMessage(error: ValidationErrorPayload): string { + const [first] = error.details; + if (!first) return error.message; + return first.field ? `${first.field}: ${first.message}` : first.message; +} + /** * Result of attempting to parse and validate a JSON body against a Zod schema. * diff --git a/src/shared/validation/schemas/apiV1.ts b/src/shared/validation/schemas/apiV1.ts index ba8f61067b..35c53d3f5d 100644 --- a/src/shared/validation/schemas/apiV1.ts +++ b/src/shared/validation/schemas/apiV1.ts @@ -568,27 +568,16 @@ export const v1SearchSchema = z .trim() .min(1, "Query is required") .max(500, "Query must be 500 characters or fewer"), - provider: z - .enum([ - "serper-search", - "brave-search", - "perplexity-search", - "exa-search", - "tavily-search", - "firecrawl", - "google-pse-search", - "linkup-search", - "ollama-search", - "searchapi-search", - "youcom-search", - "searxng-search", - "zai-search", - "jina-search", - "jina-ai", - "jina", - "duckduckgo-free", - ]) - .optional(), + // Not a z.enum: the runtime catalog (SEARCH_PROVIDERS + SEARCH_PROVIDER_ALIASES in + // open-sse/config/searchRegistry.ts) is the source of truth via resolveSearchProvider(), + // which already returns a named "Unknown search provider: " error for bad ids (see + // src/app/api/v1/search/route.ts). A hard-coded enum here would 400 before that check + // ever runs, hiding the informative message behind a generic Zod failure (#10849). + // Known catalog ids as of this writing: serper-search, brave-search, perplexity-search, + // exa-search, tavily-search, firecrawl, google-pse-search, linkup-search, ollama-search, + // searchapi-search, youcom-search, searxng-search, zai-search, jina-search, jina-ai, + // jina, duckduckgo-free (plus short aliases resolved by SEARCH_PROVIDER_ALIASES). + provider: z.string().min(1).optional(), max_results: z.coerce.number().int().min(1).max(100).default(5), search_type: z.enum(["web", "news"]).default("web"), offset: z.coerce.number().int().min(0).default(0), diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index f9b7e3c832..c616e63372 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -186,6 +186,7 @@ export const comboRuntimeConfigSchema = z nestedComboMode: z.enum(["flatten", "execute"]).optional(), trackMetrics: z.boolean().optional(), reasoningTokenBufferEnabled: z.boolean().optional(), + reasoningTransportFallback: z.enum(["skip", "drop"]).optional(), compressionMode: compressionModeSchema.optional(), failoverBeforeRetry: z.boolean().optional(), maxSetRetries: z.coerce.number().int().min(0).max(10).optional(), @@ -379,7 +380,12 @@ export const updateComboSchema = z .object({ name: comboNameSchema.optional(), description: z.string().max(2000).optional().nullable(), - models: z.array(comboModelEntry).optional(), + // Creation may leave `models` empty (`omniroute combo create` drafts one + // that way); an update may not, or a working combo loses every target. + models: z + .array(comboModelEntry) + .min(1, "an update cannot remove every model from a combo") + .optional(), strategy: comboStrategySchema.optional(), config: comboRuntimeConfigSchema.optional(), isActive: z.boolean().optional(), diff --git a/src/shared/validation/schemas/routing.ts b/src/shared/validation/schemas/routing.ts index aa47d3d9f5..588405367b 100644 --- a/src/shared/validation/schemas/routing.ts +++ b/src/shared/validation/schemas/routing.ts @@ -56,18 +56,45 @@ export const taskRoutingModelMapSchema = z }) .strict(); +// Same bound as the combo guardrail substring lists (forbiddenSubstrings/requiredSubstrings, +// src/shared/validation/schemas/combo.ts) — matched against request text the same way +// (plain includes(), never a regex, so no ReDoS surface), the closest existing precedent +// for an operator-supplied list of match strings. +const taskPatternListSchema = z.array(z.string().min(1).max(500)).max(50); + +const taskPatternOverrideSchema = z + .object({ + patterns: taskPatternListSchema.optional(), + userPatterns: taskPatternListSchema.optional(), + }) + .strict(); + +export const taskPatternOverridesSchema = z + .object({ + coding: taskPatternOverrideSchema.optional(), + creative: taskPatternOverrideSchema.optional(), + analysis: taskPatternOverrideSchema.optional(), + vision: taskPatternOverrideSchema.optional(), + summarization: taskPatternOverrideSchema.optional(), + background: taskPatternOverrideSchema.optional(), + chat: taskPatternOverrideSchema.optional(), + }) + .strict(); + export const updateTaskRoutingSchema = z .object({ enabled: z.boolean().optional(), taskModelMap: taskRoutingModelMapSchema.optional(), detectionEnabled: z.boolean().optional(), + patternOverrides: taskPatternOverridesSchema.optional(), }) .strict() .superRefine((value, ctx) => { if ( value.enabled === undefined && value.taskModelMap === undefined && - value.detectionEnabled === undefined + value.detectionEnabled === undefined && + value.patternOverrides === undefined ) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -85,4 +112,4 @@ export const taskRoutingActionSchema = z.discriminatedUnion("action", [ body: jsonObjectSchema.optional(), }) .strict(), -]); \ No newline at end of file +]); diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index c4cb849573..83d87893f9 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -174,6 +174,10 @@ export const updateSettingsSchema = z.object({ ) .optional(), customBannedSignals: z.array(z.string().max(200)).optional(), + // #9817: opt-in (default off) — lets a probe-origin (model test-all) + // failure deactivate a connection like real traffic. Off by default: + // probe failures are recorded but never mutate routing state. + probeCanDisable: z.boolean().optional(), debugMode: z.boolean().optional(), logToolSources: z.boolean().optional(), hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(), diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index b8aff4cd2a..3ec6e6c6d6 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -126,6 +126,7 @@ import { classify429FromError, type FailureKind } from "@/shared/utils/classify4 import { isSubscriptionQuotaText } from "@omniroute/open-sse/services/quotaTextCooldowns.ts"; import { resolveUseUpstream429BreakerHints } from "@/shared/utils/providerHints"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; +import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin"; import { getCircuitBreaker, isLocalStreamLifecycleError } from "../../shared/utils/circuitBreaker"; import { markAccountExhaustedFrom429 } from "../../domain/quotaCache"; import { resolveForcedConnectionForCredentialPool } from "../services/sessionAffinityPin.ts"; @@ -176,6 +177,10 @@ import { resolveCooldownAwareRetrySettings, waitForCooldownAwareRetry, } from "../services/cooldownAwareRetry"; +import { + shouldRetrySameAccountTransport, + sameAccountTransportRetryDelayMs, +} from "../services/sameAccountTransportRetry"; import { constrainConnectionsToQuota, resolveQuotaKeyScope } from "../../lib/quota/quotaKey"; import { checkConnectionCapacity } from "../utils/backpressure"; import { @@ -333,7 +338,11 @@ function isManagedComboUnsupported( const managedComboRejection = () => buildManagedLeaseErrorResponse( - new LeaseContextError(409, "LEASE_UNSUPPORTED_ROUTE", "Managed leases do not support this route") + new LeaseContextError( + 409, + "LEASE_UNSUPPORTED_ROUTE", + "Managed leases do not support this route" + ) ); const comboPromoteDeps = { updateCombo, info: log.info, warn: log.warn }; @@ -1003,6 +1012,8 @@ async function handleChatImplementation( const relayConfig = combo.strategy === "context-relay" ? resolveComboConfig(combo, settings) : null; + const reasoningTransportFallback = + combo.config?.reasoningTransportFallback === "skip" ? "skip" : "drop"; // Per-request Auto-Combo controls (#6023 / #6024 / #6025 / #3470): steer an // `auto` combo on this single request without mutating its stored config. const perRequestAutoControls = resolveRequestAutoControls(request.headers); @@ -1078,6 +1089,7 @@ async function handleChatImplementation( correlationId: reqId, conversationId, modelPinned: (target as any)?.modelPinned ?? false, + reasoningTransportFallback, reasoningDecision, reasoningIntent, reasoningRequestTags: requestRoutingTags.tags, @@ -1291,6 +1303,7 @@ async function handleSingleModelChat( reasoningDecision?: ReasoningRuleDecision | null; reasoningIntent?: ExtractedReasoningIntent | null; reasoningRequestTags?: string[]; + reasoningTransportFallback?: "skip" | "drop"; managedLease?: ManagedLeaseDispatchContext | null; /** * Per-target abort signal from combo.ts's targetTimeoutRunner @@ -1365,6 +1378,8 @@ async function handleSingleModelChat( allowRateLimitedConnection: resolvedTarget?.allowRateLimitedConnection === true, providerId: resolvedTarget?.providerId ?? null, correlationId: runtimeOptions?.correlationId ?? null, + reasoningTransportFallback: + redirectCombo.config?.reasoningTransportFallback === "skip" ? "skip" : "drop", conversationId: runtimeOptions?.conversationId ?? null, managedLease: runtimeOptions.managedLease ?? null, // #7360 follow-up — see the primary handleSingleModel closure above. @@ -1534,6 +1549,7 @@ async function handleSingleModelChat( // re-attempt to exactly one for the whole request. Declared outside both retry // loops so it can never reset and loop. let streamEarlyEofRetries = 0; + const sameAccountTransportRetries = new Map(); const occupancySessionKey = runtimeOptions.sessionAffinityKey ?? runtimeOptions.sessionId ?? `request:${randomUUID()}`; let initialPreselectedCredentials = runtimeOptions.preselectedCredentials; @@ -1655,7 +1671,10 @@ async function handleSingleModelChat( credentials?.allRateLimited && isProviderBreakerFailureStatus(breakerFailureStatus) && !isNetworkError && - !isQueueTimeout + !isQueueTimeout && + // Probe-origin dispatches must not degrade the provider breaker — + // routing state untouched (#9817). + !(await shouldIsolateProbeFailures()) ) { breaker._onFailure(); } @@ -1673,7 +1692,8 @@ async function handleSingleModelChat( model, lastError, lastStatus, - candidateAliases + candidateAliases, + isCombo ); const lastFailedConnectionId = excludedConnectionIds.size > 0 @@ -1818,7 +1838,8 @@ async function handleSingleModelChat( comboStrategy, isCombo, comboStepId: runtimeOptions.comboStepId ?? null, - comboExecutionKey: runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null, + comboExecutionKey: + runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null, extendedContext, modelApiFormat: apiFormat, modelTargetFormat: targetFormat, @@ -1830,6 +1851,7 @@ async function handleSingleModelChat( modelPinned: runtimeOptions?.modelPinned ?? false, routingComboId: runtimeOptions?.routingComboId ?? null, sessionAffinityKey: runtimeOptions.sessionAffinityKey ?? null, + reasoningTransportFallback: runtimeOptions.reasoningTransportFallback ?? "skip", managedLease: runtimeOptions.managedLease ?? null, }, runtimeOptions @@ -2195,12 +2217,53 @@ async function handleSingleModelChat( const passthroughModels = credentials.providerSpecificData?.passthroughModels; if ( result.status === 429 && - shouldMarkAccountExhaustedFrom429(provider, model, passthroughModels, failureKind) + shouldMarkAccountExhaustedFrom429(provider, model, passthroughModels, failureKind) && + // T-PROBE: a probe must not poison the 5min quotaCache for real + // traffic (#9817). + !(await shouldIsolateProbeFailures()) ) { markAccountExhaustedFrom429(credentials.connectionId, provider); } } + // #9708: retry a retryable pre-output transport failure once on the same + // account (jittered 2-3s) before cooling the connection. A first 503/507 + // must not rotate away from a still-healthy Codex prompt-cache partition. + // Skipped inside an emergency-fallback hop: that path guarantees exactly one + // upstream call against the free fallback model (#1731) — an extra retry there + // burns a second call against a provider we're already treating as a last resort. + // Skipped for combo targets too: combo routing owns its own target-level + // fallback/retry policy (per-target error handling in handleSingleModel, + // then the next combo target) — a same-account retry here just delays that + // policy and can surface the wrong terminal status when a later hop throws. + const transportAttempts = sameAccountTransportRetries.get(credentials.connectionId) || 0; + if ( + !runtimeOptions.emergencyFallbackTried && + !comboName && + shouldRetrySameAccountTransport({ + status: result.status, + errorText: errorStr, + errorCode: result.errorCode, + errorType: result.errorType, + attempt: transportAttempts, + hasForcedConnection, + }) + ) { + sameAccountTransportRetries.set(credentials.connectionId, transportAttempts + 1); + const waitMs = sameAccountTransportRetryDelayMs(); + log.warn( + "RETRY", + `${provider}/${model} retryable pre-output ${result.status} — retrying same account once after ${waitMs}ms` + ); + const completed = await waitForCooldownAwareRetry(waitMs, requestSignal); + if (!completed) { + releaseOAuthSession(); + return errorResponse(499, "Request aborted"); + } + preselectedCredentials = credentials; + continue; + } + // 8. Fallback to next account // A3 guard: if 401 and connection has extra keys, skip connection-level disable // (key-level failure already recorded in chatCore.ts via T07) @@ -2259,7 +2322,12 @@ async function handleSingleModelChat( continue; } - if (shouldTripProviderBreakerForResult(result, isCombo, forceLiveComboTest)) { + // T-PROBE: a probe failure must not degrade the provider-wide circuit + // breaker for real traffic (#9817). + if ( + !(await shouldIsolateProbeFailures()) && + shouldTripProviderBreakerForResult(result, isCombo, forceLiveComboTest) + ) { breaker._onFailure(); } diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index c82333a78e..63aa617bab 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -422,6 +422,7 @@ export async function executeChatWithBreaker({ conversationId = null, modelPinned = false, routingComboId = null, + reasoningTransportFallback = "skip", sessionAffinityKey = null, managedLease = null, }: ExecuteChatWithBreakerOptions): Promise { @@ -481,6 +482,7 @@ export async function executeChatWithBreaker({ modelPinned, routingComboId, sessionAffinityKey, + reasoningTransportFallback, managedLease, skipResourcePressureGuard: true, onCredentialsRefreshed: async (newCreds: any) => { @@ -630,7 +632,8 @@ export function handleNoCredentials( model: string, lastError: string | null, lastStatus: number | null, - candidateAliases?: readonly string[] + candidateAliases?: readonly string[], + isCombo: boolean = false ) { if (credentials?.allRateLimited) { const errorMsg = lastError || credentials.lastError || "Unavailable"; @@ -705,7 +708,7 @@ export function handleNoCredentials( log.warn("AUTH", `No active credentials for provider: ${provider}`); // #FIX: surface the candidate aliases (from resolveModelOrError) so the // operator can pick a working provider/model prefix instead of guessing. - // Without this, "No active credentials for provider: kiro" leaves the + // Without this, "No active credentials for provider: byNara" leaves the // user staring at a wall — most bugs in this area are actually "wrong // provider was picked", not "the provider is broken". const hint = @@ -715,6 +718,26 @@ export function handleNoCredentials( .map((a) => `${a}/${model}`) .join(", ")}.` : ""; + + // Issue #2: for single-model (non-combo) requests, a 404 leaks a misleading + // "No active credentials" status to a direct API client (e.g. OpenCode) that + // then mis-files it as "resource not found" instead of an auth/credential + // failure. The 404 is only meaningful as a combo fall-through signal, so + // remap it to an explicit error status for single-model traffic: a 401 when + // the provider exists but has no usable credentials, else 503 when the + // provider itself is unknown/unreachable. Combo routing keeps the 404 so it + // can still skip past a disabled-credentials leg. + if (!isCombo) { + const singleModelStatus = + provider && String(provider).trim().length > 0 + ? HTTP_STATUS.UNAUTHORIZED + : HTTP_STATUS.SERVICE_UNAVAILABLE; + return errorResponse( + singleModelStatus, + `No active credentials for provider: ${provider}.${hint}` + ); + } + return errorResponse( HTTP_STATUS.NOT_FOUND, `No active credentials for provider: ${provider}.${hint}` diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index bf9076df92..7f831b81c5 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -32,6 +32,7 @@ import { DEFAULT_QUOTA_THRESHOLD_PERCENT, getQuotaCache, getQuotaWindowStatus, + hydrateCodexQuotaCacheForRequest, isQuotaExhaustedForRequest, } from "@/domain/quotaCache"; import { getQuotaScopeLabelForProvider } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; @@ -61,6 +62,10 @@ import { } from "@omniroute/open-sse/services/quotaPreflight.ts"; import { resolveResilienceSettings } from "@/lib/resilience/settings"; import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings"; +import { + buildMixedAvailabilityError, + isTransportCooldownErrorCode, +} from "../services/sameAccountTransportRetry"; import { syncHealthFromDB, type KeyHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { classifyProviderError, @@ -82,6 +87,11 @@ import { toCodexBaseQuotaWindowName, toCodexScopedQuotaWindowName, } from "@omniroute/open-sse/config/codexQuotaScopes.ts"; +import { + getCodexChildCooldown, + isCodexChildUnavailable, + persistCodexChildCooldown, +} from "@omniroute/open-sse/services/codexAccount/index.ts"; import { getProviderById, getProviderAlias, @@ -116,6 +126,7 @@ import { getNextFromDeckSync, planNextFromDeckSync, } from "@/shared/utils/shuffleDeck"; +import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin"; import { applyExclusiveConnectionLeasePolicy, invalidateManagedConnectionLease, @@ -329,44 +340,6 @@ function applyCodexWindowPolicy(rawWindows: string[], providerSpecificData: Json return uniqueWindows(windows); } -function getCodexScopeRateLimitedUntil( - providerSpecificData: JsonRecord, - model: string | null -): string | null { - if (!model) return null; - const scope = getCodexModelScope(model); - const scopeMap = asRecord(providerSpecificData.codexScopeRateLimitedUntil); - const value = scopeMap[scope]; - return typeof value === "string" && value.trim().length > 0 ? value : null; -} -function isCodexScopeUnavailable( - connection: ProviderConnectionView, - model: string | null -): boolean { - const until = getCodexScopeRateLimitedUntil(connection.providerSpecificData, model); - if (!until) return false; - return new Date(until).getTime() > Date.now(); -} -function getEarliestCodexScopeRateLimitedUntil( - connections: ProviderConnectionView[], - model: string | null -): string | null { - let earliest: string | null = null; - let earliestMs = Infinity; - - for (const conn of connections) { - const until = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model); - if (!until) continue; - const ms = new Date(until).getTime(); - if (!Number.isFinite(ms) || ms <= Date.now()) continue; - if (ms < earliestMs) { - earliest = until; - earliestMs = ms; - } - } - - return earliest; -} function normalizeStatus(value: string | null): string { return (value || "").trim().toLowerCase(); } @@ -936,6 +909,15 @@ async function markQuotaPreflightAccountUnavailable( 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"; @@ -1049,7 +1031,9 @@ async function getProviderSearchPool(provider: string): Promise { if (!nodeId) continue; if ( nodePrefix && - (nodePrefix === provider || nodePrefix === canonicalProvider || nodePrefix === canonicalAlias) + (nodePrefix === provider || + nodePrefix === canonicalProvider || + nodePrefix === canonicalAlias) ) { searchPool.add(nodeId); } @@ -1274,6 +1258,11 @@ export async function getProviderCredentials( } } + const isCodexScopeUnavailable = ( + connection: ProviderConnectionView, + model: string | null + ): boolean => provider === "codex" && isCodexChildUnavailable(connection, model); + // #5903: an active session-affinity pin outranks a per-request reset-aware // forcedConnectionId (see sessionAffinityPin leaf for the full rationale). if (!options.lease) { @@ -1541,7 +1530,7 @@ export async function getProviderCredentials( : ` → ${c.id?.slice(0, 8)} | skipped terminal status=${c.testStatus}` ); } else if (codexScopeLimited) { - const scopeUntil = getCodexScopeRateLimitedUntil(c.providerSpecificData, requestedModel); + const scopeUntil = getCodexChildCooldown(c, requestedModel); log.debug( "AUTH", allowSuppressedConnections @@ -1564,9 +1553,7 @@ export async function getProviderCredentials( const connectionCooldownMs = parseFutureDateMs(connection.rateLimitedUntil); const codexScopeCooldownMs = provider === "codex" - ? parseFutureDateMs( - getCodexScopeRateLimitedUntil(connection.providerSpecificData, requestedModel) - ) + ? parseFutureDateMs(getCodexChildCooldown(connection, requestedModel)) : null; const modelLockout = requestedModel ? getModelLockoutInfo(provider, connection.id, requestedModel) @@ -1578,12 +1565,7 @@ export async function getProviderCredentials( ? Date.now() + modelLockout.remainingMs : null; - return { - connection, - connectionCooldownMs, - codexScopeCooldownMs, - retryableModelCooldownMs, - }; + return { connection, connectionCooldownMs, codexScopeCooldownMs, retryableModelCooldownMs }; }); const cooldownCandidates = cooldownStates @@ -1659,6 +1641,12 @@ export async function getProviderCredentials( }> = []; const quotaResults = new Map(); + if (provider === "codex") { + for (const connection of availableConnections) { + hydrateCodexQuotaCacheForRequest(connection, requestedModel); + } + } + if (!bypassQuotaPolicy) { policyEligibleConnections = availableConnections.filter((connection) => { const evaluation = evaluateQuotaLimitPolicy(provider, connection, requestedModel); @@ -1686,6 +1674,32 @@ export async function getProviderCredentials( } if (policyEligibleConnections.length === 0 && availableConnections.length > 0) { + const transportUnavailable = connections.filter( + (connection) => + connectionFilterStatus.get(connection.id) === "rateLimited" && + isTransportCooldownErrorCode(connection.errorCode) + ); + if (transportUnavailable.length > 0) { + const mixed = buildMixedAvailabilityError({ + provider, + quotaFilteredCount: blockedByPolicy.length, + transportUnavailableCount: transportUnavailable.length, + transportStatus: Number(transportUnavailable[0]?.errorCode) || 503, + }); + const retryAfter = + getEarliestFutureDate( + transportUnavailable.map((connection) => connection.rateLimitedUntil || null) + ) || new Date(Date.now() + 3000).toISOString(); + invalidateManagedLease(options, "HEALTH_OR_COOLDOWN"); + return { + allRateLimited: true, + retryAfter, + retryAfterHuman: formatRetryAfter(retryAfter), + lastError: mixed.lastError, + lastErrorCode: mixed.lastErrorCode, + }; + } + const earliestResetAt = getEarliestFutureDate(blockedByPolicy.map((entry) => entry.resetAt)); const earliestResetMs = parseFutureDateMs(earliestResetAt); @@ -2382,11 +2396,8 @@ export async function markAccountUnavailable( } // T09: Codex scope-aware lockout guard (codex vs spark independent pools). - if (provider === "codex" && model) { - const scopeRateLimitedUntil = getCodexScopeRateLimitedUntil( - conn?.providerSpecificData || {}, - model - ); + if (provider === "codex" && typeof model === "string" && model.trim().length > 0) { + const scopeRateLimitedUntil = conn ? getCodexChildCooldown(conn, model) : null; if (scopeRateLimitedUntil && new Date(scopeRateLimitedUntil).getTime() > Date.now()) { log.info( "AUTH", @@ -2436,6 +2447,33 @@ export async function markAccountUnavailable( effectiveProviderProfile ); + // T-PROBE: probe-origin failures (model test-all) must never remove the + // connection from the pool. Record the failure for visibility but leave + // ALL routing state untouched — cooldowns, terminal status, per-model + // lockouts (T09 codex-scope, per-model quota, agentrouter #10334) and + // auto-disable. Only a real request-path failure deactivates (#9817); + // the opt-in setting probeCanDisable restores the historical behavior. + if (await shouldIsolateProbeFailures()) { + await updateProviderConnection(connectionId, { + // lastError kept RAW (full text) — maximal probe visibility; the + // divergence vs the normal path's slice(0,100) is intentional. + // backoffLevel is deliberately NOT written: a positive backoff + // triggers the selection-time auto-decay (resetConnectionBackoff, + // auth.ts getProviderCredentials) which wipes lastError back to + // NULL on the next attempt — silently destroying the probe record. + // The backoff is also routing state a probe must not touch (#9817). + lastError: errorText, + lastErrorType: fallbackResult.reason || null, + errorCode: status, + lastErrorAt: new Date().toISOString(), + }); + log.warn( + "AUTH", + `[T-PROBE] ${connectionId.slice(0, 8)} ${provider ?? ""} failure ${status} recorded — connection stays in the pool` + ); + return { shouldFallback: true, cooldownMs: 0 }; + } + // Read passthroughModels from connection config (user-configured per-model quota) const connProviderSpecificData = (conn?.providerSpecificData as Record) || {}; if (provider && conn) { @@ -2759,26 +2797,22 @@ export async function markAccountUnavailable( const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"; // T09: Codex per-scope lockout (do not block the whole account globally). - if (provider === "codex" && status === 429 && model && conn) { + if ( + provider === "codex" && + status === 429 && + typeof model === "string" && + model.trim().length > 0 && + conn + ) { const scope = getCodexModelScope(model); - const existingScopeMap = asRecord(conn.providerSpecificData.codexScopeRateLimitedUntil); - const persistedScopeUntil = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model); - const scopeRateLimitedUntil = persistedScopeUntil || getUnavailableUntil(cooldownMs); + const scopeRateLimitedUntil = + getCodexChildCooldown(conn, model) || getUnavailableUntil(cooldownMs); const scopeCooldownMs = Math.max(new Date(scopeRateLimitedUntil).getTime() - Date.now(), 0); - await updateProviderConnection(connectionId, { - testStatus: "unavailable", - lastError: errorMsg, - errorCode: status, - lastErrorAt: new Date().toISOString(), - backoffLevel: newBackoffLevel ?? backoffLevel, - providerSpecificData: { - ...conn.providerSpecificData, - codexScopeRateLimitedUntil: { - ...existingScopeMap, - [scope]: scopeRateLimitedUntil, - }, - }, + await persistCodexChildCooldown({ + connectionId, + model, + rateLimitedUntil: scopeRateLimitedUntil, }); if (scopeCooldownMs > 0) { @@ -2792,6 +2826,12 @@ export async function markAccountUnavailable( return { shouldFallback: true, cooldownMs: scopeCooldownMs }; } + // A Codex quota response without a model cannot be assigned to either virtual child. + // Preserve failover without inventing a third parent-level quota/cooldown state. + if (provider === "codex" && status === 429) { + return { shouldFallback: true, cooldownMs }; + } + const baseUpdate = { lastError: errorMsg, lastErrorType: providerErrorType, diff --git a/src/sse/services/autoDisableBannedAccount.ts b/src/sse/services/autoDisableBannedAccount.ts index 2d1d8d33d1..b55a8b8417 100644 --- a/src/sse/services/autoDisableBannedAccount.ts +++ b/src/sse/services/autoDisableBannedAccount.ts @@ -9,6 +9,7 @@ import { getCachedSettings } from "@/lib/db/readCache"; import { updateProviderConnection } from "@/lib/db/providers"; import { resolveProviderId, WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers"; import { shouldAutoDisableBannedConnection } from "@/shared/utils/autoDisableBanned"; +import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin"; import * as log from "../utils/logger"; /** Deactivate a connection after a permanent ban when settings and scope allow it. */ @@ -20,6 +21,15 @@ export async function maybeAutoDisableBannedAccount(input: { permanent?: boolean; }): Promise { if (!input.permanent) return; + // T-PROBE: a probe-origin failure (model test-all) must never disable a + // connection — only a real request-path failure deactivates (#9817). + if (await shouldIsolateProbeFailures()) { + log.info( + "AUTH", + `Skipped auto-disable for ${input.connectionId.slice(0, 8)} — probe origin (permanent failure, connection stays active)` + ); + return; + } try { const settings = await getCachedSettings(); const scope = settings.autoDisableBannedScope; diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 53e065d8ef..7a84606cd1 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -206,6 +206,24 @@ function findSyncedModelMeta(models: unknown, modelId: string): any { return Array.isArray(models) ? models.find((model: any) => model.id === modelId) : undefined; } +function findLiveCatalogModelMeta( + providerId: string, + requestedModelId: string, + resolvedModelId: string, + syncedModels: unknown +): any { + const directMatch = findSyncedModelMeta(syncedModels, resolvedModelId); + if (directMatch || !Array.isArray(syncedModels)) return directMatch; + + const registryModel = findRegistryModel(providerId, requestedModelId); + const liveCatalogIds = registryModel?.liveCatalogIds; + if (!Array.isArray(liveCatalogIds) || liveCatalogIds.length === 0) return undefined; + + return syncedModels.find( + (model: any) => typeof model?.id === "string" && liveCatalogIds.includes(model.id) + ); +} + function resolveRuntimeFormats(customMatch: any, syncedMatch: any): RuntimeModelMeta { const apiFormat = customMatch?.apiFormat === "responses" || syncedMatch?.apiFormat === "responses" @@ -228,8 +246,10 @@ function copySyncedThinkingMetadata(metadata: RuntimeModelMeta, syncedMatch: any // Only let a non-empty synced effort list override the static registry fallback; // an empty array from an incomplete synced discovery must not erase registry-declared // tiers (#9485 review). - if (Array.isArray(syncedMatch?.supportedThinkingEfforts) && - syncedMatch.supportedThinkingEfforts.length > 0) { + if ( + Array.isArray(syncedMatch?.supportedThinkingEfforts) && + syncedMatch.supportedThinkingEfforts.length > 0 + ) { metadata.supportedThinkingEfforts = syncedMatch.supportedThinkingEfforts; } if (typeof syncedMatch?.defaultThinkingEffort === "string") { @@ -300,7 +320,12 @@ async function lookupModelMeta( // Custom models remain explicit operator overrides even when live discovery // is authoritative for the provider. const customMatch = findCustomModelMeta(customModels, resolvedModelId); - const syncedMatch = findSyncedModelMeta(syncedModels, resolvedModelId); + const syncedMatch = findLiveCatalogModelMeta( + providerId, + modelId, + resolvedModelId, + syncedModels + ); const registryMatch = findRegistryModel(providerId, resolvedModelId); const effortBaseModelId = getRegisteredProviderEffortBaseModelId(providerId, modelId); diff --git a/src/sse/services/sameAccountTransportRetry.ts b/src/sse/services/sameAccountTransportRetry.ts new file mode 100644 index 0000000000..4c82d94765 --- /dev/null +++ b/src/sse/services/sameAccountTransportRetry.ts @@ -0,0 +1,108 @@ +/** + * Same-account retry for retryable pre-output transport failures (#9708). + * + * A 503/507 (connection reset, retry-buffer overflow, early EOF before useful + * output) must not immediately cool the account and rotate. One jittered + * same-account retry absorbs brief proxy blips and keeps Codex prompt-cache + * affinity. A second failure then takes a short cooldown and may rotate. + */ + +export const SAME_ACCOUNT_TRANSPORT_RETRY_MAX = 1; +export const SAME_ACCOUNT_TRANSPORT_RETRY_MIN_DELAY_MS = 2000; +export const SAME_ACCOUNT_TRANSPORT_RETRY_JITTER_MS = 1000; + +const RETRYABLE_TRANSPORT_STATUSES = new Set([502, 503, 504, 507]); + +const RETRYABLE_TRANSPORT_TEXT = [ + /upstream connect error/i, + /disconnect\/reset before headers/i, + /remote connection failure/i, + /connection reset/i, + /exceeded request buffer limit/i, + /early eof/i, + /econnreset/i, + /socket hang up/i, + /und_err_socket/i, +]; + +const NON_RETRYABLE_ERROR_TYPES = new Set(["lease_error", "account_semaphore_capacity"]); + +export function isRetryableTransportStatus(status: unknown): boolean { + const numeric = Number(status); + return Number.isFinite(numeric) && RETRYABLE_TRANSPORT_STATUSES.has(numeric); +} + +export function isRetryablePreOutputTransportError( + status: unknown, + errorText: string | null | undefined, + errorCode?: string | null, + errorType?: string | null +): boolean { + if (errorType && NON_RETRYABLE_ERROR_TYPES.has(errorType)) return false; + if (errorCode && String(errorCode).startsWith("LEASE_")) return false; + + const text = String(errorText || ""); + const numericStatus = Number(status); + if (numericStatus === 429 || numericStatus === 401 || numericStatus === 400) return false; + if (/quota (threshold|exhausted)|credits exhausted/i.test(text)) return false; + if (/invalid_request|prompt is too long|context.?length|unsupported model/i.test(text)) { + return false; + } + + const statusRetryable = isRetryableTransportStatus(status); + const textRetryable = RETRYABLE_TRANSPORT_TEXT.some((pattern) => pattern.test(text)); + const codeRetryable = + errorCode === "STREAM_EARLY_EOF" || + errorCode === "proxy_unreachable" || + errorCode === "PROXY_UNREACHABLE"; + + return statusRetryable || textRetryable || codeRetryable; +} + +export function sameAccountTransportRetryDelayMs(random: () => number = Math.random): number { + const draw = random(); + const unit = Number.isFinite(draw) ? Math.min(Math.max(draw, 0), 1) : 0; + return Math.round( + SAME_ACCOUNT_TRANSPORT_RETRY_MIN_DELAY_MS + SAME_ACCOUNT_TRANSPORT_RETRY_JITTER_MS * unit + ); +} + +export function shouldRetrySameAccountTransport(options: { + status: unknown; + errorText?: string | null; + errorCode?: string | null; + errorType?: string | null; + attempt: number; + hasForcedConnection?: boolean; + hasEmittedOutput?: boolean; +}): boolean { + if (options.hasForcedConnection) return false; + if (options.hasEmittedOutput) return false; + if (options.attempt >= SAME_ACCOUNT_TRANSPORT_RETRY_MAX) return false; + return isRetryablePreOutputTransportError( + options.status, + options.errorText, + options.errorCode, + options.errorType + ); +} + +export function isTransportCooldownErrorCode(errorCode: unknown): boolean { + return isRetryableTransportStatus(errorCode); +} + +export function buildMixedAvailabilityError(options: { + provider: string; + quotaFilteredCount: number; + transportUnavailableCount: number; + transportStatus?: number | null; +}): { status: number; lastError: string; lastErrorCode: number } { + const quota = Math.max(0, options.quotaFilteredCount); + const transport = Math.max(0, options.transportUnavailableCount); + const upstreamStatus = options.transportStatus || 503; + return { + status: 503, + lastErrorCode: 503, + lastError: `No ${options.provider} accounts currently available: ${quota} quota-filtered, ${transport} temporarily unavailable after upstream ${upstreamStatus}`, + }; +} diff --git a/stryker.conf.json b/stryker.conf.json index 61c50f3db9..42d5eb7412 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -42,6 +42,7 @@ "plugins": ["@stryker-mutator/tap-runner"], "tap": { "testFiles": [ + "tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts", "tests/unit/7993-noauth-proxy-routing.test.ts", "tests/unit/8200-perplexity-web-401-cooldown.test.ts", "tests/unit/8247-accountfallback-model-unhealthy.test.ts", @@ -51,7 +52,6 @@ "tests/unit/8396-cooldown-429-cap.test.ts", "tests/unit/8488-capability-filter-fail-closed.test.ts", "tests/unit/8779-agy-prefix-credential-lookup.test.ts", - "tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts", "tests/unit/account-fallback-anthropic-quota.test.ts", "tests/unit/account-fallback-cf1010-no-retry-8775.test.ts", "tests/unit/account-fallback-lockout-eviction.test.ts", @@ -64,12 +64,12 @@ "tests/unit/adobe-firefly.test.ts", "tests/unit/agentrouter-error-rules.test.ts", "tests/unit/agentrouter-lock-scope-10334.test.ts", + "tests/unit/aihorde-optional-api-key.test.ts", "tests/unit/alibaba-free-tier-exhaustion.test.ts", "tests/unit/anthropic-thinking-signature-recovery.test.ts", "tests/unit/antigravity-429-quota-tdd.test.ts", "tests/unit/antigravity-prefer-stored-project.test.ts", "tests/unit/api-key-policy-noauth-allowed-connections.test.ts", - "tests/unit/api-key-policy-noauth-allowed-connections.test.ts", "tests/unit/api-key-rotator-health.test.ts", "tests/unit/api/jobs.test.ts", "tests/unit/appearance-widget-settings-schema.test.ts", @@ -106,11 +106,12 @@ "tests/unit/chat-helpers.test.ts", "tests/unit/chat-route-coverage.test.ts", "tests/unit/chat-route-edge-cases.test.ts", + "tests/unit/chatcore-codex-account-pool.test.ts", "tests/unit/chatcore-compression-integration.test.ts", "tests/unit/chatcore-executor-helpers.test.ts", - "tests/unit/chatcore-header-drop-warn-dedupe-10315.test.ts", "tests/unit/chatcore-executor-proxy.test.ts", "tests/unit/chatcore-extracted-modules-3821.test.ts", + "tests/unit/chatcore-header-drop-warn-dedupe-10315.test.ts", "tests/unit/chatcore-headers.test.ts", "tests/unit/chatcore-imports-cleanly.test.ts", "tests/unit/chatcore-log-truncation.test.ts", @@ -144,13 +145,13 @@ "tests/unit/clinepass-provider.test.ts", "tests/unit/cliproxyapi-dedicated-credential-7645.test.ts", "tests/unit/cliproxyapi-model-mapping-dispatch.test.ts", - "tests/unit/cliproxyapi-model-mapping-dispatch.test.ts", - "tests/unit/cliproxyapi-model-mapping-dispatch.test.ts", "tests/unit/codex-failover.test.ts", "tests/unit/codex-quota-selection-hydration.test.ts", "tests/unit/codex-responses-to-chat-9161.test.ts", + "tests/unit/codex-same-account-transport-retry-9708.test.ts", "tests/unit/codex-session-affinity-reset-aware-5903.test.ts", "tests/unit/codex-stream-false.test.ts", + "tests/unit/codex-turn-state.test.ts", "tests/unit/collect-metrics-module-coverage.test.ts", "tests/unit/combo-499-abort.test.ts", "tests/unit/combo-account-allowlist-3266.test.ts", @@ -216,21 +217,24 @@ "tests/unit/cursor-renewal.test.ts", "tests/unit/custom-model-target-format.test.ts", "tests/unit/db-reset-module-state.test.ts", + "tests/unit/db/stats-dbstat-optional.test.ts", "tests/unit/ddg-circuit-breaker-null-content-6999-7000.test.ts", "tests/unit/domain-persistence.test.ts", "tests/unit/edgetts-provider.test.ts", + "tests/unit/embedding-account-cooldown-10347.test.ts", + "tests/unit/embedding-cooldown-integration-10347.test.ts", "tests/unit/embeddings-auth.test.ts", "tests/unit/error-classification.test.ts", - "tests/unit/executor-contract-violation-terminal.test.ts", "tests/unit/error-message-sanitization.test.ts", "tests/unit/error-sensitive-redaction.test.ts", "tests/unit/execute-chat-resource-pressure-breaker.test.ts", "tests/unit/executor-antigravity.test.ts", + "tests/unit/executor-contract-violation-terminal.test.ts", "tests/unit/executor-devin-cli-agentic-acp.test.ts", "tests/unit/executor-web-cookie-sweep.test.ts", "tests/unit/format-provider-error-cause.test.ts", "tests/unit/forwarded-header-budget.test.ts", - "tests/unit/gemini-web-capabilities-9356.test.ts", + "tests/unit/fusion-vision-panel-3378.test.ts", "tests/unit/gemini-web-capabilities-9356.test.ts", "tests/unit/gemini-web-missing-browser-3516.test.ts", "tests/unit/grok-cli-oauth.test.ts", @@ -280,6 +284,9 @@ "tests/unit/plan3-p0.test.ts", "tests/unit/plugin-sandbox-permissions.test.ts", "tests/unit/plugins-route-error-sanitization.test.ts", + "tests/unit/probe-gate-autodisable.test.ts", + "tests/unit/probe-production-path.test.ts", + "tests/unit/probe-testall-isolation.test.ts", "tests/unit/provider-breaker-halfopen-recovery.test.ts", "tests/unit/provider-error-rules.test.ts", "tests/unit/provider-health-matrix.test.ts", @@ -301,11 +308,11 @@ "tests/unit/rate-limit-queue-timeout-lockout.test.ts", "tests/unit/repro-7503-no-choices.test.ts", "tests/unit/repro-9486.test.ts", - "tests/unit/repro-9486.test.ts", "tests/unit/repro-9630-combo-false-503.test.ts", "tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts", "tests/unit/resilience-connections.test.ts", "tests/unit/responses-handler.test.ts", + "tests/unit/responses-passthrough-openai-compatible.test.ts", "tests/unit/rotation-config-omniroute.test.ts", "tests/unit/route-explainability.test.ts", "tests/unit/route-guard-acp-agents-local-only.test.ts", @@ -319,6 +326,7 @@ "tests/unit/route-guard-provider-login-local-only.test.ts", "tests/unit/route-guard-qwen-settings-local-only.test.ts", "tests/unit/router-strategies.test.ts", + "tests/unit/routing-adaptive-e2e.test.ts", "tests/unit/rule12-error-sanitization-sweep.test.ts", "tests/unit/serial/combo-health-autopilot.test.ts", "tests/unit/serial/combo-quota-share-cooldown-wait-timing.test.ts", @@ -333,9 +341,10 @@ "tests/unit/settings/authz-bypass.test.ts", "tests/unit/skip-provider-breaker-consumer-2743.test.ts", "tests/unit/sse-auth-antigravity-credits.test.ts", + "tests/unit/sse-auth-codex-account-pool.test.ts", + "tests/unit/sse-auth-exclusive-leases.test.ts", "tests/unit/sse-auth-resource-404.test.ts", "tests/unit/sse-auth.test.ts", - "tests/unit/db/stats-dbstat-optional.test.ts", "tests/unit/stream-early-eof-breaker.test.ts", "tests/unit/stream-readiness.test.ts", "tests/unit/strict-random-deck.test.ts", diff --git a/tests/helpers/assertResponsesOutputIndexLifecycle.ts b/tests/helpers/assertResponsesOutputIndexLifecycle.ts deleted file mode 100644 index b1ae64fa9b..0000000000 --- a/tests/helpers/assertResponsesOutputIndexLifecycle.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Validates the Responses-API output_index lifecycle invariant that real - * clients (e.g. OpenClaw's outputSlots tracker) enforce: an output_index - * claimed by response.output_item.added must be closed by a matching - * response.output_item.done before any later item reuses that same index. - * - * Existing coverage (responses-reasoning-close-before-message-466.test.ts) - * asserts this invariant by hand for one specific emitter path (the real - * translator/transformer). This helper generalizes that check so any SSE - * event sequence — including hand-rolled synthetic frames like the early - * keepalive placeholder — can be verified against the same contract a real - * downstream client applies, without duplicating the tracking logic per test. - * - * Mirrors OpenClaw's createResponsesOutputSlotTracker() closely enough to - * reproduce the exact failure mode: "Responses stream reused active output - * index N" (see OpenClaw issue #123342 / the RESPONSES_STARTUP_THINKING_FRAME - * missing-output_item.done incident this helper was added for). - */ - -export type ResponsesLifecycleEvent = { event?: string; data: Record }; - -export function assertResponsesOutputIndexLifecycle( - events: ResponsesLifecycleEvent[], - options: { requireAllClosed?: boolean } = {} -): void { - const open = new Map(); - - for (const { data } of events) { - const type = data?.type; - if (type !== "response.output_item.added" && type !== "response.output_item.done") continue; - - const outputIndex = data.output_index; - if (typeof outputIndex !== "number") continue; - - if (type === "response.output_item.added") { - if (open.has(outputIndex)) { - const item = data.item as { id?: unknown; type?: unknown } | undefined; - throw new Error( - `Responses stream reused active output index ${outputIndex} ` + - `(item id=${String(item?.id)} type=${String(item?.type)} was still open)` - ); - } - open.set(outputIndex, data.item); - } else { - open.delete(outputIndex); - } - } - - if (options.requireAllClosed !== false && open.size > 0) { - const stillOpen = [...open.keys()].join(", "); - throw new Error( - `Responses stream left output index(es) open with no output_item.done: ${stillOpen}` - ); - } -} diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index d37fa02a51..382dd00b6b 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -1112,7 +1112,8 @@ test("chat pipeline allows unauthenticated requests through to provider resoluti // handleChat does not enforce REQUIRE_API_KEY — that's the authz pipeline's job. // Without provider credentials seeded, the request falls through to the "no credentials" path. // Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through. - assert.equal(response.status, 404); + // #10797: single-model (non-combo) no-credentials now remaps 404 → 401. + assert.equal(response.status, 401); assert.match(json.error.message, /No active credentials for provider/i); }); @@ -1231,7 +1232,8 @@ test("chat pipeline returns current no-credentials contract when no provider con const json = (await response.json()) as any; // Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through. - assert.equal(response.status, 404); + // #10797: single-model (non-combo) no-credentials now remaps 404 → 401. + assert.equal(response.status, 401); assert.match(json.error.message, /No active credentials for provider: openai/); }); diff --git a/tests/integration/codex-account-pool-restart-http.test.ts b/tests/integration/codex-account-pool-restart-http.test.ts new file mode 100644 index 0000000000..66018cc93f --- /dev/null +++ b/tests/integration/codex-account-pool-restart-http.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-pool-http-")); +const PHASE_SCRIPT = path.join( + process.cwd(), + "tests/integration/fixtures/codex-account-pool-restart-phase.ts" +); + +type PhaseResult = { + phase: "before" | "after"; + connectionId: string; + upstreamModels: string[]; +}; + +function runPhase(phase: PhaseResult["phase"], connectionId?: string): PhaseResult { + const result = spawnSync(process.execPath, ["--import", "tsx/esm", PHASE_SCRIPT], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DATA_DIR: TEST_DATA_DIR, + CODEX_RESTART_PHASE: phase, + ...(connectionId ? { CODEX_EXPECTED_CONNECTION_ID: connectionId } : {}), + }, + maxBuffer: 50 * 1024 * 1024, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const line = result.stdout.split("\n").find((entry) => entry.startsWith("CODEX_RESTART_RESULT=")); + assert.ok(line, result.stdout); + return JSON.parse(line.slice("CODEX_RESTART_RESULT=".length)) as PhaseResult; +} + +test("Codex Spark cooldown survives a fresh process without creating child connections", () => { + try { + const before = runPhase("before"); + assert.ok(before.upstreamModels.length > 0); + + const after = runPhase("after", before.connectionId); + assert.equal(after.connectionId, before.connectionId); + assert.deepEqual(after.upstreamModels, ["gpt-5.5"]); + } finally { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); diff --git a/tests/integration/combo-routing-e2e.test.ts b/tests/integration/combo-routing-e2e.test.ts index fea0c16b8a..cd8e046ee0 100644 --- a/tests/integration/combo-routing-e2e.test.ts +++ b/tests/integration/combo-routing-e2e.test.ts @@ -365,8 +365,10 @@ test("unmapped custom model requests fail after combo resolution falls through", const json = (await response.json()) as any; // Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through - // to the next target when a provider has zero usable credentials. - assert.equal(response.status, 404); + // to the next target when a provider has zero usable credentials. This request + // never resolves to a combo target (unmapped model), so it takes the + // single-model path — #10797 remaps that 404 → 401. + assert.equal(response.status, 401); assert.match(json.error.message, /No active credentials for provider: tenant/); }); diff --git a/tests/integration/files-api-limit-validation.test.ts b/tests/integration/files-api-limit-validation.test.ts new file mode 100644 index 0000000000..a697e07967 --- /dev/null +++ b/tests/integration/files-api-limit-validation.test.ts @@ -0,0 +1,77 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { createFile, deleteFile } from "@/lib/db/files"; +import { GET, parseFilesListQuery } from "@/app/api/v1/files/route"; + +describe("GET /v1/files limit validation", () => { + it("defaults to 20 when limit is absent", () => { + const parsed = parseFilesListQuery(new URLSearchParams("order=asc")); + + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + assert.equal(parsed.limit, 20); + }); + + it("parses an explicit positive integer limit", () => { + const parsed = parseFilesListQuery(new URLSearchParams("limit=2&order=asc&purpose=batch")); + + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + assert.equal(parsed.limit, 2); + assert.equal(parsed.order, "asc"); + assert.equal(parsed.purpose, "batch"); + }); + + it("rejects non-integer, zero, and oversized limits", async () => { + for (const rawLimit of ["abc", "1.5", "-1", "0", "10001"]) { + const parsed = parseFilesListQuery( + new URLSearchParams(`limit=${encodeURIComponent(rawLimit)}`) + ); + assert.equal(parsed.ok, false, `limit=${rawLimit} should be rejected`); + if (parsed.ok) continue; + assert.equal(parsed.response.status, 400); + const body = await parsed.response.json(); + assert.equal(body.error.type, "invalid_request_error"); + } + }); + + it("returns only the requested number of files over HTTP", async () => { + const created = [ + createFile({ + bytes: 1, + filename: "test-files-limit-http-a.txt", + purpose: "assistants", + content: Buffer.from("a"), + mimeType: "text/plain", + }), + createFile({ + bytes: 1, + filename: "test-files-limit-http-b.txt", + purpose: "assistants", + content: Buffer.from("b"), + mimeType: "text/plain", + }), + ]; + + try { + const response = await GET( + new Request("http://localhost/v1/files?limit=1&purpose=assistants") + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.object, "list"); + assert.equal(body.data.length, 1); + assert.equal(body.has_more, true); + } finally { + for (const file of created) deleteFile(file.id); + } + }); + + it("returns 400 over HTTP for an invalid limit instead of listing files", async () => { + const response = await GET(new Request("http://localhost/v1/files?limit=-1")); + + assert.equal(response.status, 400); + const body = await response.json(); + assert.equal(body.error.type, "invalid_request_error"); + }); +}); diff --git a/tests/integration/fixtures/codex-account-pool-restart-phase.ts b/tests/integration/fixtures/codex-account-pool-restart-phase.ts new file mode 100644 index 0000000000..038f3a55f6 --- /dev/null +++ b/tests/integration/fixtures/codex-account-pool-restart-phase.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import { once } from "node:events"; + +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.API_KEY_SECRET = "codex-pool-http-e2e-secret-123456"; +process.env.REQUIRE_API_KEY = "false"; +process.env.OMNIROUTE_LOG_REQUEST_SHAPE = "0"; + +const providersDb = await import("../../../src/lib/db/providers.ts"); +const chatRoute = await import("../../../src/app/api/v1/chat/completions/route.ts"); + +const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; +const phase = process.env.CODEX_RESTART_PHASE; +const expectedId = process.env.CODEX_EXPECTED_CONNECTION_ID; +const originalFetch = globalThis.fetch; +const upstreamModels: string[] = []; + +async function readIncomingBody(request: http.IncomingMessage) { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +async function bridgeRouteResponse(response: Response, outgoing: http.ServerResponse) { + outgoing.writeHead(response.status, Object.fromEntries(response.headers.entries())); + if (!response.body) { + outgoing.end(); + return; + } + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!outgoing.write(value)) await once(outgoing, "drain"); + } + outgoing.end(); + } finally { + reader.releaseLock(); + } +} + +async function startRouteServer() { + const server = http.createServer(async (incoming, outgoing) => { + try { + if (incoming.method !== "POST" || incoming.url !== "/v1/chat/completions") { + outgoing.writeHead(404).end(); + return; + } + const body = await readIncomingBody(incoming); + const address = server.address(); + assert(address && typeof address !== "string"); + const headers = new Headers(); + for (const [name, value] of Object.entries(incoming.headers)) { + if (Array.isArray(value)) value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, value); + } + const request = new Request(`http://127.0.0.1:${address.port}${incoming.url}`, { + method: "POST", + headers, + body, + }); + await bridgeRouteResponse(await chatRoute.POST(request), outgoing); + } catch { + outgoing.writeHead(500, { "content-type": "text/plain" }); + outgoing.end("internal test route error"); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert(address && typeof address !== "string"); + return { server, url: `http://127.0.0.1:${address.port}/v1/chat/completions` }; +} + +async function closeServer(server: http.Server) { + if (!server.listening) return; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); +} + +function successResponsesSse(model: string) { + return ( + [ + { + type: "response.created", + response: { + id: `resp-${model}`, + object: "response", + status: "in_progress", + model, + output: [], + }, + }, + { + type: "response.completed", + response: { + id: `resp-${model}`, + object: "response", + status: "completed", + model, + output: [], + }, + }, + ] + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join("") + "data: [DONE]\n\n" + ); +} + +async function requestModel(url: string, model: string) { + return originalFetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model, + stream: true, + messages: [{ role: "user", content: "Say hello" }], + }), + }); +} + +if (phase !== "before" && phase !== "after") { + throw new Error("CODEX_RESTART_PHASE must be before or after"); +} + +let connectionId: string; +if (phase === "before") { + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-pool-restart", + email: "codex-pool-restart@example.test", + accessToken: "mock-codex-access-token", + refreshToken: "mock-codex-refresh-token", + tokenType: "Bearer", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + connectionId = connection.id; +} else { + const inventory = await providersDb.getProviderConnections({ provider: "codex" }); + assert.equal(inventory.length, 1); + assert.equal(inventory[0].id, expectedId); + connectionId = inventory[0].id; +} + +const resetAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); +globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url !== CODEX_RESPONSES_URL) return originalFetch(input, init); + const requestBody = JSON.parse(String(init?.body || "{}")) as { model?: string }; + const model = String(requestBody.model || ""); + upstreamModels.push(model); + if (model.includes("spark")) { + return new Response( + JSON.stringify({ error: { message: "Spark quota exhausted", type: "rate_limit_error" } }), + { + status: 429, + headers: { + "content-type": "application/json", + "x-codex-5h-usage": "100", + "x-codex-5h-limit": "100", + "x-codex-5h-reset-at": resetAt, + "x-codex-7d-usage": "1", + "x-codex-7d-limit": "1000", + "x-codex-7d-reset-at": new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + }, + } + ); + } + return new Response(successResponsesSse(model), { + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + }); +}; + +let server: http.Server | undefined; +try { + const started = await startRouteServer(); + server = started.server; + if (phase === "before") { + const spark = await requestModel(started.url, "codex/gpt-5.3-codex-spark"); + await spark.text(); + assert.ok(upstreamModels.length > 0); + assert.equal( + upstreamModels.every((model) => model === "gpt-5.3-codex-spark"), + true + ); + } else { + const spark = await requestModel(started.url, "codex/gpt-5.3-codex-spark"); + await spark.text(); + assert.equal( + upstreamModels.length, + 0, + "fresh process must restore Spark cooldown before fetch" + ); + + const normal = await requestModel(started.url, "codex/gpt-5.5"); + const body = await normal.text(); + assert.equal(normal.status, 200, body); + assert.match(body, /"model":"gpt-5.5"/); + assert.deepEqual(upstreamModels, ["gpt-5.5"]); + } + + const inventory = await providersDb.getProviderConnections({ provider: "codex" }); + assert.deepEqual( + inventory.map((connection) => connection.id), + [connectionId] + ); + console.log(`CODEX_RESTART_RESULT=${JSON.stringify({ phase, connectionId, upstreamModels })}`); +} finally { + globalThis.fetch = originalFetch; + if (server) await closeServer(server); +} diff --git a/tests/integration/integration-wiring.test.ts b/tests/integration/integration-wiring.test.ts index b0f3e8e3f8..624386fffb 100644 --- a/tests/integration/integration-wiring.test.ts +++ b/tests/integration/integration-wiring.test.ts @@ -46,36 +46,12 @@ function listProjectFiles(relPath: string): string[] { } // ─── Pipeline Wiring ───────────────────────────────── - -describe("Pipeline Wiring — server-init.ts", () => { - const src = readProjectFile("src/server-init.ts"); - - it("should initialize compliance audit log", () => { - assert.ok(src, "src/server-init.ts should exist"); - assert.match(src, /initAuditLog/); - }); - - it("should cleanup expired logs", () => { - assert.match(src, /cleanupExpiredLogs/); - }); - - it("should enforce secrets before startup", () => { - assert.match(src, /enforceSecrets/); - }); - - it("should enforce web runtime env before startup", () => { - assert.match(src, /enforceWebRuntimeEnv/); - }); - - it("should log server.start audit event", () => { - assert.match(src, /server\.start/); - }); - - it("should use the structured startup logger instead of direct console calls", () => { - assert.match(src, /createLogger\("server-init"\)/); - assert.doesNotMatch(src, /console\.(log|warn|error|info|debug)\(/); - }); -}); +// +// src/server-init.ts was removed: it was never imported anywhere and duplicated +// the wiring below, which is the boot path that actually runs (Next.js +// instrumentation hook). See tests/unit/credential-health-boot-wiring.test.ts and +// tests/unit/thinking-budget-boot-wiring-5312.test.ts for the incidents that +// wiring into the dead module caused. describe("Pipeline Wiring — instrumentation-node.ts", () => { const src = readProjectFile("src/instrumentation-node.ts"); diff --git a/tests/integration/live-ws-heartbeat-keepalive.test.ts b/tests/integration/live-ws-heartbeat-keepalive.test.ts index b3ba3f882c..1d04edcec5 100644 --- a/tests/integration/live-ws-heartbeat-keepalive.test.ts +++ b/tests/integration/live-ws-heartbeat-keepalive.test.ts @@ -1,8 +1,13 @@ -// Integration test for #10452: a server-emitted application-level pong must not -// keep a half-open client alive. Uses the real server harness from -// tests/integration/live-ws-startup.test.ts (serial, --test-concurrency=1 -// integration runner — this test needs a ~50s window to cross the server's -// HEARTBEAT_TIMEOUT_MS, which is intentionally NOT inflated here). +// Integration test for #10452 and its follow-up: a server-emitted application pong must +// not keep a half-open client alive (#10452), and a subscriber that never sends anything +// at the application level must not be evicted either. The server now also sends a +// protocol-level ping (RFC 6455 §5.5.2) that conformant clients auto-answer, which +// separates a client that stopped reading frames (simulated below by pausing the +// underlying socket) from one that is alive but silent at the application level. +// +// Uses the real server harness from tests/integration/live-ws-startup.test.ts (serial, +// --test-concurrency=1 integration runner — needs a ~50s window to cross the server's +// HEARTBEAT_TIMEOUT_MS, intentionally NOT inflated here). import assert from "node:assert/strict"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import net from "node:net"; @@ -71,7 +76,7 @@ function waitForStartup( } test( - "LiveWS removes a silent socket but keeps one answering protocol heartbeats (#10452)", + "LiveWS reaps a socket that stops reading frames, but keeps a quiet-but-alive subscriber", // A fresh DATA_DIR can spend up to the server-startup allowance running the // complete migration set before the 50s heartbeat observation window. { timeout: 95_000 }, @@ -108,19 +113,22 @@ test( try { await waitForStartup(child, () => output); - const connect = (answerHeartbeat: boolean) => { + type ConnectMode = "appHeartbeat" | "quiet" | "deadSocket"; + + const connect = (mode: ConnectMode) => { const ws = new WebSocket(`ws://127.0.0.1:${port}/live-ws`, { headers: { Authorization: `Bearer ${apiKey}`, Origin: origin }, }); let heartbeat: NodeJS.Timeout | undefined; - const welcome = new Promise((resolve, reject) => { + let sessionId: string | undefined; + const welcome = new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`Timed out waiting for welcome. Output:\n${output}`)); }, 5_000); ws.once("open", () => { ws.send(JSON.stringify({ type: "subscribe", channels: ["requests"] })); - if (answerHeartbeat) { + if (mode === "appHeartbeat") { heartbeat = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ping" })); @@ -130,9 +138,17 @@ test( }); ws.on("message", (data) => { - if (JSON.parse(data.toString()).type === "welcome") { + const message = JSON.parse(data.toString()); + if (message.type === "welcome") { clearTimeout(timeout); - resolve(); + sessionId = message.sessionId; + // "deadSocket": pause the underlying TCP socket so the `ws` receiver never + // sees (and auto-answers) the server's ping. It also cannot observe its own + // closure, so the assertion checks the server's disconnect log instead. + if (mode === "deadSocket") { + (ws as unknown as { _socket: import("net").Socket })._socket.pause(); + } + resolve(sessionId as string); } }); @@ -145,30 +161,41 @@ test( return { ws, welcome, stop: () => clearInterval(heartbeat) }; }; - const silent = connect(false); - const responsive = connect(true); - await Promise.all([silent.welcome, responsive.welcome]); + const deadSocket = connect("deadSocket"); + const quiet = connect("quiet"); + const appHeartbeat = connect("appHeartbeat"); + const [deadSocketId] = await Promise.all([ + deadSocket.welcome, + quiet.welcome, + appHeartbeat.welcome, + ]); // Wait past HEARTBEAT_TIMEOUT_MS (35s) plus one heartbeat interval (15s). - // The server emits application-level pong frames during this period, but - // only the responsive client sends the inbound { type: "ping" } signal. await new Promise((resolve) => setTimeout(resolve, 50_000)); - assert.equal( - silent.ws.readyState, - WebSocket.CLOSED, - `Silent socket remained alive after timeout — server pong renewed lastActivity. Output:\n${output}` + assert.ok( + output.includes(`Client disconnected: ${deadSocketId}`), + `Server never disconnected the socket that stopped reading frames — protocol ` + + `ping/pong must not mask a truly half-open connection (#10452). Output:\n${output}` ); assert.notEqual( - responsive.ws.readyState, + quiet.ws.readyState, WebSocket.CLOSED, - `Protocol-heartbeat client was terminated. Output:\n${output}` + `A real subscriber that sent no application-level messages was evicted — the protocol ` + + `ping/pong (auto-answered by every conformant client) must keep it alive. Output:\n${output}` + ); + assert.notEqual( + appHeartbeat.ws.readyState, + WebSocket.CLOSED, + `Application-heartbeat client was terminated. Output:\n${output}` ); - silent.stop(); - responsive.stop(); - silent.ws.close(); - responsive.ws.close(); + deadSocket.stop(); + quiet.stop(); + appHeartbeat.stop(); + quiet.ws.close(); + appHeartbeat.ws.close(); + deadSocket.ws.terminate(); } finally { terminateTree(child); } diff --git a/tests/integration/llama-cpp-provider.test.ts b/tests/integration/llama-cpp-provider.test.ts index 3c87261d32..7c1cf517d1 100644 --- a/tests/integration/llama-cpp-provider.test.ts +++ b/tests/integration/llama-cpp-provider.test.ts @@ -171,8 +171,9 @@ test("llama-cpp provider: alias matching works via model catalog prefix", async assert.equal(json.choices[0].message.content, "42"); }); -test("llama-cpp provider: returns 404 when no connection exists", async () => { +test("llama-cpp provider: returns 401 when no connection exists", async () => { // Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through. + // #10797: single-model (non-combo) no-credentials now remaps 404 → 401. const response = await handleChat( buildRequest({ body: { @@ -183,7 +184,7 @@ test("llama-cpp provider: returns 404 when no connection exists", async () => { }) ); - assert.equal(response.status, 404); + assert.equal(response.status, 401); const json = (await response.json()) as any; assert.match(json.error.message, /No active credentials for provider/); }); diff --git a/tests/integration/memory-pipeline.test.ts b/tests/integration/memory-pipeline.test.ts index 184601d3e1..06be356277 100644 --- a/tests/integration/memory-pipeline.test.ts +++ b/tests/integration/memory-pipeline.test.ts @@ -214,6 +214,74 @@ test("memory search ranks query-relevant memories first", async () => { assert.ok(result.data.memories.every((memory) => /TypeScript|backend/i.test(memory.content))); }); +test("MCP memory tools fall back to caller principal id when apiKeyId is omitted", async () => { + const apiKey = await seedApiKey(); + await enableMemory(400, "hybrid"); + + const prevEnvKey = process.env.OMNIROUTE_API_KEY; + process.env.OMNIROUTE_API_KEY = apiKey.key; + try { + const added = await memoryTools.omniroute_memory_add.handler({ + sessionId: "mcp-auto", + type: "factual", + key: "pref:auto-owner", + content: "Written without an explicit apiKeyId.", + metadata: {}, + }); + assert.equal(added.success, true); + assert.equal(added.data.memory.apiKeyId, "env-key"); + + const rows = await listMemories({ apiKeyId: "env-key", sessionId: "mcp-auto" }); + const list = Array.isArray(rows) ? rows : (rows.data ?? []); + assert.equal(list.length, 1); + assert.equal(list[0].key, "pref:auto-owner"); + + const searched = await memoryTools.omniroute_memory_search.handler({ + query: "explicit apiKeyId", + limit: 5, + }); + assert.equal(searched.success, true); + assert.equal(searched.data.count, 1); + assert.equal(searched.data.memories[0].apiKeyId, "env-key"); + } finally { + if (prevEnvKey === undefined) { + delete process.env.OMNIROUTE_API_KEY; + } else { + process.env.OMNIROUTE_API_KEY = prevEnvKey; + } + } +}); + +test("MCP memory tools reject explicit apiKeyId that does not match caller principal", async () => { + const prevEnvKey = process.env.OMNIROUTE_API_KEY; + process.env.OMNIROUTE_API_KEY = "sk-other-principal"; + try { + const added = await memoryTools.omniroute_memory_add.handler({ + apiKeyId: "principal-b", + sessionId: "mcp-mismatch", + type: "factual", + key: "pref:cross-tenant", + content: "Must not leak into another principal's store.", + metadata: {}, + }); + assert.equal(added.success, true); + assert.equal(added.data.memory.apiKeyId, "principal-b"); + + const searched = await memoryTools.omniroute_memory_search.handler({ + query: "cross-tenant", + limit: 5, + }); + assert.equal(searched.success, true); + assert.equal(searched.data.count, 0); + } finally { + if (prevEnvKey === undefined) { + delete process.env.OMNIROUTE_API_KEY; + } else { + process.env.OMNIROUTE_API_KEY = prevEnvKey; + } + } +}); + test("memory injection respects the configured token budget", async () => { await seedConnection("openai", { apiKey: "sk-openai-budget" }); const apiKey = await seedApiKey(); diff --git a/tests/integration/security-hardening.test.ts b/tests/integration/security-hardening.test.ts index 4160cb091c..479f3ed59e 100644 --- a/tests/integration/security-hardening.test.ts +++ b/tests/integration/security-hardening.test.ts @@ -144,12 +144,6 @@ test("chat handler wires guardrail pre-call validation", () => { ); }); -test("server-init.ts calls enforceSecrets", () => { - const content = readIfExists("src/server-init.ts"); - assert.ok(content, "src/server-init.ts should exist"); - assert.ok(content.includes("enforceSecrets"), "server-init.ts should call enforceSecrets"); -}); - test("instrumentation-node.ts validates runtime env after restoring secrets", () => { const content = readIfExists("src/instrumentation-node.ts"); assert.ok(content, "src/instrumentation-node.ts should exist"); diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index d0ce4024b5..ffe64f9429 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -190,11 +190,21 @@ "configSource": "cursor", "provider": "cursor" }, + "cua": { + "className": "CursorExecutor", + "configSource": "cursor-api", + "provider": "cursor-api" + }, "cursor": { "className": "CursorExecutor", "configSource": "cursor", "provider": "cursor" }, + "cursor-api": { + "className": "CursorExecutor", + "configSource": "cursor-api", + "provider": "cursor-api" + }, "cw-web": { "className": "ClaudeWebExecutor", "configSource": "", @@ -686,6 +696,6 @@ "provider": "zai-web" } }, - "keyCount": 137, + "keyCount": 139, "sharedInstances": [] } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 917bc7d086..1878b2ac41 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -1534,6 +1534,38 @@ "stream": "https://api2.cursor.sh" } }, + "cursor-api": { + "format": "cursor", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/connect+proto", + "User-Agent": "Cursor/3.9", + "connect-accept-encoding": "gzip", + "connect-protocol-version": "1" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/connect+proto", + "User-Agent": "Cursor/3.9", + "connect-accept-encoding": "gzip", + "connect-protocol-version": "1" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/connect+proto", + "User-Agent": "Cursor/3.9", + "connect-accept-encoding": "gzip", + "connect-protocol-version": "1" + } + }, + "url": { + "nonStream": "https://api2.cursor.sh", + "stream": "https://api2.cursor.sh" + } + }, "dahl": { "format": "openai", "headers": { @@ -2132,6 +2164,29 @@ "stream": "https://freeinference.org/v1/chat/completions" } }, + "freebuff": { + "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://www.codebuff.com/api/v1", + "stream": "https://www.codebuff.com/api/v1" + } + }, "freemodel-dev": { "format": "openai", "headers": { @@ -3740,6 +3795,52 @@ "stream": "https://models.mixlayer.ai/v1/chat/completions" } }, + "mlx-gemma": { + "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": "http://localhost:11435/v1", + "stream": "http://localhost:11435/v1" + } + }, + "mlx-qwen": { + "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": "http://localhost:11436/v1", + "stream": "http://localhost:11436/v1" + } + }, "mnn-ai": { "format": "openai", "headers": { @@ -5368,6 +5469,32 @@ "stream": "https://t3.chat/api/chat" } }, + "tabitoken": { + "format": "claude", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "" + }, + "nonStream": { + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "" + }, + "oauth": { + "Accept": "text/event-stream", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "" + } + }, + "url": { + "nonStream": "https://tabitoken.com/v1/messages", + "stream": "https://tabitoken.com/v1/messages" + } + }, "tencent": { "format": "openai", "headers": { @@ -5483,6 +5610,29 @@ "stream": "https://api.together.xyz/v1/chat/completions" } }, + "token-kiosk": { + "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://agent-router.gaib.ai/v1/chat/completions", + "stream": "https://agent-router.gaib.ai/v1/chat/completions" + } + }, "tokenreply": { "format": "openai", "headers": { diff --git a/tests/unit/10197-openrouter-image-edits-route.test.ts b/tests/unit/10197-openrouter-image-edits-route.test.ts new file mode 100644 index 0000000000..a99846ef3a --- /dev/null +++ b/tests/unit/10197-openrouter-image-edits-route.test.ts @@ -0,0 +1,185 @@ +// #10197 (tiangao88): route-level coverage for the built-in OpenRouter branch +// that /v1/images/edits gained in this PR. Exercises the actual POST(request) +// handler so the credentials / rate-limit / unified-Image-API forwarding branches +// added to route.ts itself are proven, not just the downstream service call. +// +// Before this change: POST /v1/images/edits rejected the built-in `openrouter` +// provider ("Image edit is not supported for built-in provider"), so image +// Combos routing through OpenRouter could generate but never edit. OpenRouter's +// current reference-image contract is POST /api/v1/images with input_references. +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-openrouter-edits-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "openrouter-edits-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +interface ErrorResponseBody { + error: { message: string; code?: string }; +} + +interface ImageResponseBody { + data: Array<{ b64_json?: string; url?: string }>; +} + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +function seedOpenRouterConnection(overrides: { rateLimitedUntil?: string | null } = {}) { + return providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "openrouter-test", + apiKey: "sk-or-test-openrouter-edits", + isActive: true, + testStatus: "active", + rateLimitedUntil: overrides.rateLimitedUntil ?? null, + }); +} + +function dataUrlPng(bytes: number[]): string { + return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`; +} + +const REF_A = dataUrlPng([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]); + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#10197 v1 image edit POST forwards built-in openrouter edits to the unified Image API", async () => { + await seedOpenRouterConnection(); + + let hitUrl: string | null = null; + let hitAuth: string | null = null; + let hitBody = ""; + + globalThis.fetch = async (url, init: RequestInit = {}) => { + hitUrl = String(url); + const headers = init.headers; + hitAuth = + headers instanceof Headers + ? String(headers.get("authorization") || "") + : String( + (headers as Record | undefined)?.authorization || + (headers as Record | undefined)?.Authorization || + "" + ); + // The OpenRouter adapter sends JSON with input_references, not multipart. + const raw = init.body; + if (typeof raw === "string") hitBody = raw; + else if (raw instanceof Uint8Array) hitBody = Buffer.from(raw).toString("utf8"); + else if (raw instanceof ArrayBuffer) hitBody = Buffer.from(raw).toString("utf8"); + else if (raw && typeof (raw as { arrayBuffer?: unknown }).arrayBuffer === "function") { + hitBody = Buffer.from(await (raw as { arrayBuffer(): Promise }).arrayBuffer()).toString("utf8"); + } + return new Response( + JSON.stringify({ data: [{ b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64") }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openrouter/google/gemini-3.1-flash-image-preview", + prompt: "add a red hat", + images: [REF_A], + }), + }) + ); + const body = (await response.json()) as ImageResponseBody; + + assert.equal(response.status, 200); + assert.ok(body.data[0].b64_json, "edit must return an image payload"); + + // OpenRouter's current image-to-image endpoint is the unified Image API. + assert.equal(hitUrl, "https://openrouter.ai/api/v1/images"); + // Must carry the OpenRouter connection key as a Bearer token. + assert.equal(hitAuth, "Bearer sk-or-test-openrouter-edits"); + assert.ok(hitBody, "JSON body must be captured"); + const forwarded = JSON.parse(hitBody) as { + model?: string; + prompt?: string; + input_references?: Array<{ image_url?: { url?: string } }>; + }; + assert.equal(forwarded.model, "google/gemini-3.1-flash-image-preview"); + assert.equal(forwarded.prompt, "add a red hat"); + assert.equal(forwarded.input_references?.length, 1); + assert.match(forwarded.input_references?.[0]?.image_url?.url || "", /^data:image\/png;base64,/); +}); + +test("#10197 v1 image edit POST surfaces missing openrouter credentials", async () => { + // No openrouter connection seeded at all. + globalThis.fetch = async () => { + throw new Error("Missing-credentials path must not reach upstream"); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openrouter/openai/gpt-5-image-mini", + prompt: "edit this", + images: [REF_A], + }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 401); + assert.match(body.error.message, /No credentials for provider: openrouter/); + // Hard Rule #12 — error responses must never leak a raw stack trace. + assert.ok(!body.error.message.includes("at /")); +}); + +test("#10197 v1 image edit POST surfaces openrouter rate-limit sentinel", async () => { + await seedOpenRouterConnection({ rateLimitedUntil: new Date(Date.now() + 60_000).toISOString() }); + globalThis.fetch = async () => { + throw new Error("Rate-limited path must not reach upstream"); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openrouter/openai/gpt-5.4-image-2", + prompt: "edit this", + images: [REF_A], + }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 429); + assert.match(body.error.message, /All accounts rate limited/); + assert.ok(!body.error.message.includes("at /")); +}); diff --git a/tests/unit/10303-healthz-lag.test.ts b/tests/unit/10303-healthz-lag.test.ts new file mode 100644 index 0000000000..afaab6e77f --- /dev/null +++ b/tests/unit/10303-healthz-lag.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + HEALTHZ_SLOW_LAG_MS, + formatHealthzLagWarning, + observeHealthzEventLoopLag, + resetHealthzLagWarnStateForTests, + shouldWarnHealthzLag, +} from "../../src/lib/healthzLag.ts"; + +test("shouldWarnHealthzLag ignores sub-threshold lag", () => { + resetHealthzLagWarnStateForTests(); + assert.equal(shouldWarnHealthzLag(0), false); + assert.equal(shouldWarnHealthzLag(HEALTHZ_SLOW_LAG_MS - 1), false); +}); + +test("shouldWarnHealthzLag fires once then debounce", () => { + resetHealthzLagWarnStateForTests(); + const t0 = 1_700_000_000_000; + assert.equal(shouldWarnHealthzLag(3748, t0), true); + assert.equal(shouldWarnHealthzLag(3748, t0 + 1000), false); + assert.equal(shouldWarnHealthzLag(3748, t0 + 10_000), true); +}); + +test("observeHealthzEventLoopLag logs when injected lag is high", () => { + resetHealthzLagWarnStateForTests(); + const messages: string[] = []; + assert.equal(observeHealthzEventLoopLag((m) => messages.push(m), 12), false); + assert.equal(messages.length, 0); + assert.equal(observeHealthzEventLoopLag((m) => messages.push(m), 3748), true); + assert.equal(messages[0], `[HEALTHZ] ${formatHealthzLagWarning(3748)}`); + assert.match(messages[0], /3748ms/); + assert.match(messages[0], /not healthy/); +}); diff --git a/tests/unit/10353-heap-limit-conflict.test.ts b/tests/unit/10353-heap-limit-conflict.test.ts new file mode 100644 index 0000000000..c1c7a1d7c3 --- /dev/null +++ b/tests/unit/10353-heap-limit-conflict.test.ts @@ -0,0 +1,73 @@ +/** + * #10353 — warn when OMNIROUTE_MEMORY_MB disagrees with NODE_OPTIONS heap. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + parseNodeOptionsHeapMb, + envHasExplicitOmnirouteMemoryMb, + warnConflictingHeapLimits, + buildStandaloneNodeOptions, +} = await import("../../scripts/build/runtime-env.mjs"); + +test("parseNodeOptionsHeapMb reads the last heap flag", () => { + assert.equal(parseNodeOptionsHeapMb(""), null); + assert.equal(parseNodeOptionsHeapMb("--enable-source-maps"), null); + assert.equal(parseNodeOptionsHeapMb("--max-old-space-size=512"), 512); + assert.equal( + parseNodeOptionsHeapMb("--max-old-space-size=512 --max-old-space-size=2048"), + 2048 + ); +}); + +test("envHasExplicitOmnirouteMemoryMb requires an in-range integer", () => { + assert.equal(envHasExplicitOmnirouteMemoryMb({}), false); + assert.equal(envHasExplicitOmnirouteMemoryMb({ OMNIROUTE_MEMORY_MB: "" }), false); + assert.equal(envHasExplicitOmnirouteMemoryMb({ OMNIROUTE_MEMORY_MB: "abc" }), false); + assert.equal(envHasExplicitOmnirouteMemoryMb({ OMNIROUTE_MEMORY_MB: "32" }), false); + assert.equal(envHasExplicitOmnirouteMemoryMb({ OMNIROUTE_MEMORY_MB: "2048" }), true); +}); + +test("#10353 dual-set disagree → warn + OMNIROUTE_MEMORY_MB wins", () => { + const messages: string[] = []; + const env = { + NODE_OPTIONS: "--max-old-space-size=512", + OMNIROUTE_MEMORY_MB: "2048", + }; + assert.equal(warnConflictingHeapLimits(env, 2048, (m: string) => messages.push(m)), true); + assert.match(messages[0], /OMNIROUTE_MEMORY_MB=2048/); + assert.match(messages[0], /--max-old-space-size=512/); + assert.match(messages[0], /effective V8 heap is 2048 MB/); + assert.equal( + buildStandaloneNodeOptions(env, 2048), + "--max-old-space-size=512 --max-old-space-size=2048" + ); +}); + +test("#10353 only one knob set → no conflict warn", () => { + const messages: string[] = []; + const log = (m: string) => messages.push(m); + assert.equal( + warnConflictingHeapLimits({ NODE_OPTIONS: "--max-old-space-size=512" }, 512, log), + false + ); + assert.equal( + warnConflictingHeapLimits({ OMNIROUTE_MEMORY_MB: "2048" }, 2048, log), + false + ); + assert.equal( + warnConflictingHeapLimits( + { NODE_OPTIONS: "--max-old-space-size=1024", OMNIROUTE_MEMORY_MB: "1024" }, + 1024, + log + ), + false + ); + assert.equal(messages.length, 0); +}); + +test("#10353 unset OMNIROUTE_MEMORY_MB keeps NODE_OPTIONS heap", () => { + const env = { NODE_OPTIONS: "--max-old-space-size=8192" }; + assert.equal(buildStandaloneNodeOptions(env, 512), "--max-old-space-size=8192"); +}); diff --git a/tests/unit/10840-file-token-context.test.ts b/tests/unit/10840-file-token-context.test.ts new file mode 100644 index 0000000000..68bd0f0eb3 --- /dev/null +++ b/tests/unit/10840-file-token-context.test.ts @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + estimateTokens, + isInlineBase64DocumentBlock, + isInlineBase64ImageBlock, + pruneOlderInlineImages, +} from "../../open-sse/services/contextManager.ts"; + +/** + * #10840 — a base64 file payload (PDF and friends) was measured as ordinary + * prompt text, so large documents were rejected on the context limit before + * ever reaching a provider's native document pipeline. + * + * The estimate must not depend on which wire shape the document arrived in: + * the Gemini `inlineData` matcher never inspected media type, so the SAME PDF + * was already budgeted at the bounded image estimate there while the OpenAI + * `file` and Claude `document` shapes were measured character by character. + */ + +function base64Payload(approxBytes: number): string { + return Buffer.alloc(approxBytes, 65).toString("base64"); +} + +const PDF_B64 = base64Payload(1_000_000); // ~1 MB document +const PDF_DATA_URL = `data:application/pdf;base64,${PDF_B64}`; + +const SHAPES: Array<[string, Record]> = [ + ["gemini inlineData", { inlineData: { mimeType: "application/pdf", data: PDF_B64 } }], + [ + "claude document", + { type: "document", source: { type: "base64", media_type: "application/pdf", data: PDF_B64 } }, + ], + ["openai file.file_data", { type: "file", file: { filename: "d.pdf", file_data: PDF_DATA_URL } }], + ["openai file.data", { type: "file", file: { filename: "d.pdf", data: PDF_DATA_URL } }], + ["responses input_file", { type: "input_file", filename: "d.pdf", file_data: PDF_DATA_URL }], +]; + +test("#10840: a base64 document is never measured as raw prompt text", () => { + for (const [name, block] of SHAPES) { + const tokens = estimateTokens({ + messages: [{ role: "user", content: [{ type: "text", text: "Summarise this." }, block] }], + }); + assert.ok( + tokens < 5_000, + `${name}: expected a bounded document estimate, got ${tokens} tokens for a ~1MB file` + ); + } +}); + +test("#10840: every wire shape of the same document agrees", () => { + const counts = SHAPES.map(([, block]) => estimateTokens(block)); + const unique = [...new Set(counts)]; + assert.equal( + unique.length, + 1, + `the same document must cost the same regardless of shape, got ${JSON.stringify( + SHAPES.map(([n], i) => `${n}=${counts[i]}`) + )}` + ); +}); + +test("#10840: a remote file URL still flows through the text path", () => { + // Not base64 transport — nothing to exclude, and it is short anyway. + const block = { type: "file", file: { filename: "d.pdf", file_data: "https://x.test/d.pdf" } }; + assert.equal(isInlineBase64DocumentBlock(block), false); +}); + +test("#10840: document detection stays separate from image detection", () => { + const doc = SHAPES[2][1]; + const img = { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }; + + assert.equal(isInlineBase64DocumentBlock(doc), true); + assert.equal(isInlineBase64ImageBlock(doc), false, "a document must not register as an image"); + assert.equal(isInlineBase64ImageBlock(img), true); + assert.equal(isInlineBase64DocumentBlock(img), false); +}); + +test("#10840: pruneOlderInlineImages still ignores documents", () => { + // Dropping an attached PDF is not the same decision as dropping an old + // screenshot, so the pruner must keep its image-only scope. + const messages = [ + { + role: "user", + content: [{ type: "file", file: { filename: "a.pdf", file_data: PDF_DATA_URL } }], + }, + { + role: "user", + content: [{ type: "file", file: { filename: "b.pdf", file_data: PDF_DATA_URL } }], + }, + { + role: "user", + content: [{ type: "file", file: { filename: "c.pdf", file_data: PDF_DATA_URL } }], + }, + ]; + + const { pruned, messages: after } = pruneOlderInlineImages(messages, { keepLatest: 1 }); + + assert.equal(pruned, 0, "documents must not be pruned by the image pruner"); + assert.deepEqual(after, messages); +}); diff --git a/tests/unit/8488-capability-filter-fail-closed.test.ts b/tests/unit/8488-capability-filter-fail-closed.test.ts index 6e3807ce2b..691353e792 100644 --- a/tests/unit/8488-capability-filter-fail-closed.test.ts +++ b/tests/unit/8488-capability-filter-fail-closed.test.ts @@ -19,6 +19,7 @@ const { } = await import("../../open-sse/services/combo/comboStructure.ts"); const { resolveAutoStrategyOrder } = await import("../../open-sse/services/combo/resolveAutoStrategy.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); function capabilityEntry(limit_context: number, overrides: Record = {}) { return { @@ -129,10 +130,7 @@ test("#8488 filter: chatgpt-web emulation providers stay eligible for tools (#52 assert.equal(providerSupportsEmulatedToolCalling("openai"), false); const kept = filterTargetsByRequestCompatibility( - [ - target("chatgpt-web", "chatgpt-web/gpt-5.5"), - target("chatgpt-web", "chatgpt-web/o3"), - ], + [target("chatgpt-web", "chatgpt-web/gpt-5.5"), target("chatgpt-web", "chatgpt-web/o3")], { messages: [{ role: "user", content: "Use a tool." }], tools: [{ type: "function", function: { name: "lookup", parameters: {} } }], @@ -146,10 +144,7 @@ test("#8488 filter: chatgpt-web emulation providers stay eligible for tools (#52 ); const exhaustion = describeCapabilityFilterExhaustion( - [ - target("chatgpt-web", "chatgpt-web/gpt-5.5"), - target("chatgpt-web", "chatgpt-web/o3"), - ], + [target("chatgpt-web", "chatgpt-web/gpt-5.5"), target("chatgpt-web", "chatgpt-web/o3")], { messages: [{ role: "user", content: "Use a tool." }], tools: [{ type: "function", function: { name: "lookup", parameters: {} } }], @@ -175,7 +170,10 @@ test("#8488 auto: chatgpt-web emulation survives tool pre-filter (#5240)", async buildAutoCandidates: (async () => []) as never, }); - assert.ok(!("earlyResponse" in result), "must not 400 capability_mismatch for emulation providers"); + assert.ok( + !("earlyResponse" in result), + "must not 400 capability_mismatch for emulation providers" + ); if ("orderedTargets" in result) { assert.equal(result.orderedTargets.length, 1); assert.equal(result.orderedTargets[0].modelStr, "chatgpt-web/gpt-5.5"); @@ -287,7 +285,7 @@ test("#8488 auto: tool pre-filter fail-open opt-in keeps full pool", async () => } }); -test("#8488 auto: context pre-filter fail closed when all known limits too small", async () => { +test("auto context estimate still dispatches when all known limits look too small", async () => { saveModelsDevCapabilities({ openai: { tiny: capabilityEntry(100, { tool_call: true }), @@ -295,22 +293,24 @@ test("#8488 auto: context pre-filter fail closed when all known limits too small }); const hugePrompt = "x".repeat(4000); // ~1000 tokens at 4 chars/token - const result = await resolveAutoStrategyOrder({ - orderedTargets: [target("openai", "openai/tiny")] as never, + const dispatches: string[] = []; + const result = await handleComboChat({ body: { messages: [{ role: "user", content: hugePrompt }] }, - combo: { id: "c1", name: "auto-ctx", config: {} } as never, + combo: { id: "c1", name: "auto-ctx", strategy: "auto", models: ["openai/tiny"] }, + handleSingleModel: async (_body, modelStr) => { + dispatches.push(modelStr); + return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + isModelAvailable: async () => true, + log, settings: null, - config: {}, relayOptions: null, - resilienceSettings: { quotaPreflight: { enabled: false } } as never, - log: log as never, - buildAutoCandidates: (async () => []) as never, + allCombos: null, }); - assert.ok("earlyResponse" in result); - if ("earlyResponse" in result) { - assert.equal(result.earlyResponse.status, 400); - const body = await result.earlyResponse.json(); - assert.equal(body?.error?.code, "context_length_exceeded"); - } + assert.equal(result.status, 200); + assert.deepEqual(dispatches, ["openai/tiny"]); }); diff --git a/tests/unit/agent-bridge-cert-regenerate-force-10467.test.ts b/tests/unit/agent-bridge-cert-regenerate-force-10467.test.ts new file mode 100644 index 0000000000..fc600982c6 --- /dev/null +++ b/tests/unit/agent-bridge-cert-regenerate-force-10467.test.ts @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { X509Certificate } from "node:crypto"; + +// #10467: POST /api/tools/agent-bridge/cert/regenerate called generateCert() with no +// arguments. generateCert() short-circuits when server.key and server.crt already exist, +// so the endpoint was a no-op on every machine that had ever started the bridge — the +// download route kept serving the old file, with the same md5 and, for anyone whose cert +// predates #6494, still missing the extra SANs. generateCert now takes { force } and the +// regenerate route is the one caller that passes it. + +const certModule = "../../src/mitm/cert/generate.ts"; + +async function withTempDataDir(fn: (dir: string) => Promise): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cert-10467-")); + const previous = process.env.DATA_DIR; + process.env.DATA_DIR = dir; + try { + return await fn(dir); + } finally { + if (previous === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previous; + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +const read = (p: string) => fs.readFileSync(p, "utf8"); + +test("generateCert() keeps the existing certificate when it is already on disk", async () => { + await withTempDataDir(async () => { + const { generateCert } = await import(certModule); + + const first = await generateCert(); + const certBefore = read(first.cert); + const keyBefore = read(first.key); + + const second = await generateCert(); + + assert.equal(second.cert, first.cert, "path should be stable"); + assert.equal(read(second.cert), certBefore, "certificate should not be replaced"); + assert.equal(read(second.key), keyBefore, "key should not be replaced"); + }); +}); + +test("generateCert({ force: true }) mints a new certificate over the existing one", async () => { + await withTempDataDir(async () => { + const { generateCert } = await import(certModule); + + const first = await generateCert(); + const certBefore = read(first.cert); + const keyBefore = read(first.key); + + const forced = await generateCert({ force: true }); + + assert.equal(forced.cert, first.cert, "path should be stable"); + assert.notEqual(read(forced.cert), certBefore, "certificate should be replaced"); + assert.notEqual(read(forced.key), keyBefore, "key should be replaced"); + }); +}); + +test("the forced certificate is still valid and carries every antigravity SAN", async () => { + await withTempDataDir(async () => { + const { generateCert } = await import(certModule); + const { ANTIGRAVITY_TARGET } = await import("../../src/mitm/targets/antigravity.ts"); + + await generateCert(); + const forced = await generateCert({ force: true }); + + const x509 = new X509Certificate(fs.readFileSync(forced.cert)); + const sans = (x509.subjectAltName ?? "") + .split(",") + .map((entry) => entry.trim().replace(/^DNS:/, "")) + .filter(Boolean); + + for (const host of ANTIGRAVITY_TARGET.hosts) { + assert.ok(sans.includes(host), `forced cert is missing SAN for ${host}`); + } + }); +}); + +test("generateCert({ force: true }) creates the certificate when none exists yet", async () => { + await withTempDataDir(async () => { + const { generateCert } = await import(certModule); + + const result = await generateCert({ force: true }); + + assert.ok(fs.existsSync(result.cert), "certificate should exist"); + assert.ok(fs.existsSync(result.key), "key should exist"); + }); +}); diff --git a/tests/unit/agentSkills-generator.test.ts b/tests/unit/agentSkills-generator.test.ts index df0f5d8d47..9391af4a7f 100644 --- a/tests/unit/agentSkills-generator.test.ts +++ b/tests/unit/agentSkills-generator.test.ts @@ -15,13 +15,10 @@ import os from "node:os"; // ── Dynamic imports (tsx/esm resolves TS imports) ──────────────────────────── -const { generateAgentSkills, buildSkillMarkdown, __testing } = await import( - "../../src/lib/agentSkills/generator.ts" -); +const { generateAgentSkills, buildSkillMarkdown, __testing } = + await import("../../src/lib/agentSkills/generator.ts"); -const { getCatalog, refreshCatalog } = await import( - "../../src/lib/agentSkills/catalog.ts" -); +const { getCatalog, refreshCatalog } = await import("../../src/lib/agentSkills/catalog.ts"); // ── Helpers ────────────────────────────────────────────────────────────────── @@ -66,7 +63,7 @@ test("dry-run (default) returns report without writing any files", async () => { assert.equal( report.generated.length + report.unchanged.length, 46, - `Expected 46 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`, + `Expected 46 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}` ); assert.equal(report.errors.length, 0, `Unexpected errors: ${JSON.stringify(report.errors)}`); @@ -75,7 +72,7 @@ test("dry-run (default) returns report without writing any files", async () => { assert.equal( entries.length, 0, - `Dry-run wrote ${entries.length} entries to ${tmpDir}: ${entries.join(", ")}`, + `Dry-run wrote ${entries.length} entries to ${tmpDir}: ${entries.join(", ")}` ); } finally { rmTmpDir(tmpDir); @@ -128,7 +125,7 @@ test("apply mode writes SKILL.md with valid frontmatter for omni-providers", asy // Generated comment present assert.ok( content.includes("\nMy custom content here.\n"; + const customBlock = + "\nMy custom content here.\n"; const contentWithCustom = originalContent + "\n" + customBlock + "\n"; fs.writeFileSync(skillFile, contentWithCustom, "utf-8"); @@ -355,27 +410,17 @@ test("marker preservation: custom block survives regeneration", async () => { onlyIds: ["omni-providers"], }); - assert.equal( - report2.errors.length, - 0, - `Errors: ${JSON.stringify(report2.errors)}`, - ); + assert.equal(report2.errors.length, 0, `Errors: ${JSON.stringify(report2.errors)}`); const newContent = fs.readFileSync(skillFile, "utf-8"); // Custom block should still be present assert.ok( newContent.includes("My custom content here."), - "Custom content was lost during regeneration", - ); - assert.ok( - newContent.includes(""), - "Custom start marker missing", - ); - assert.ok( - newContent.includes(""), - "Custom end marker missing", + "Custom content was lost during regeneration" ); + assert.ok(newContent.includes(""), "Custom start marker missing"); + assert.ok(newContent.includes(""), "Custom end marker missing"); } finally { rmTmpDir(tmpDir); } @@ -391,7 +436,10 @@ test("buildSkillMarkdown returns valid frontmatter + body for omni-providers", ( assert.ok(typeof result.frontmatter === "object", "frontmatter must be an object"); assert.equal(result.frontmatter.name, "omni-providers"); assert.ok(result.frontmatter.description.length > 0, "description must be non-empty"); - assert.ok(typeof result.body === "string" && result.body.length > 0, "body must be a non-empty string"); + assert.ok( + typeof result.body === "string" && result.body.length > 0, + "body must be a non-empty string" + ); }); test("buildSkillMarkdown body has no erroneously escaped characters", () => { @@ -406,20 +454,11 @@ test("buildSkillMarkdown body has no erroneously escaped characters", () => { // Check for common escape errors: \\n in rendered text, &, <, > assert.ok( !result.body.includes("\\\\n"), - `Skill ${id}: body contains \\\\n (double-escaped newline)`, - ); - assert.ok( - !result.body.includes("&"), - `Skill ${id}: body contains HTML entity &`, - ); - assert.ok( - !result.body.includes("<"), - `Skill ${id}: body contains HTML entity <`, - ); - assert.ok( - !result.body.includes(">"), - `Skill ${id}: body contains HTML entity >`, + `Skill ${id}: body contains \\\\n (double-escaped newline)` ); + assert.ok(!result.body.includes("&"), `Skill ${id}: body contains HTML entity &`); + assert.ok(!result.body.includes("<"), `Skill ${id}: body contains HTML entity <`); + assert.ok(!result.body.includes(">"), `Skill ${id}: body contains HTML entity >`); } }); @@ -430,7 +469,7 @@ test("buildSkillMarkdown throws for unknown skillId", () => { assert.throws( () => buildSkillMarkdown("non-existent-skill", sources), /non-existent-skill/, - "Should throw with skill ID in message", + "Should throw with skill ID in message" ); }); @@ -464,7 +503,7 @@ test("buildSkillMarkdown description is at most 2000 chars", () => { const result = buildSkillMarkdown(skill.id, sources); assert.ok( result.frontmatter.description.length <= 2000, - `Skill ${skill.id}: description too long (${result.frontmatter.description.length} > 2000)`, + `Skill ${skill.id}: description too long (${result.frontmatter.description.length} > 2000)` ); } }); @@ -509,8 +548,10 @@ test("generated SKILL.md contains the mandatory generated comment", async () => const content = fs.readFileSync(path.join(tmpDir, "omni-providers", "SKILL.md"), "utf-8"); assert.ok( - content.includes(""), - "Missing mandatory generated comment", + content.includes( + "" + ), + "Missing mandatory generated comment" ); } finally { rmTmpDir(tmpDir); diff --git a/tests/unit/agy-gemini-3696-tier-passthrough.test.ts b/tests/unit/agy-gemini-3696-tier-passthrough.test.ts index 53f70b5fbc..0092681808 100644 --- a/tests/unit/agy-gemini-3696-tier-passthrough.test.ts +++ b/tests/unit/agy-gemini-3696-tier-passthrough.test.ts @@ -14,10 +14,13 @@ test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-low through unchan assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro-low"); }); -test("(#3696) no two ANTIGRAVITY_PUBLIC_MODELS entries resolve to the same upstream id", () => { +test("(#3696) non-tiered ANTIGRAVITY_PUBLIC_MODELS entries resolve to distinct upstream ids", () => { const seen = new Map(); const collisions: string[] = []; for (const model of ANTIGRAVITY_PUBLIC_MODELS) { + // Gemini 3.7 Flash tiers intentionally share the upstream `gemini-3.7-flash-tiered` + // endpoint with different reasoning token budgets. + if (model.id.startsWith("gemini-3.7-flash")) continue; const upstream = resolveAntigravityModelId(model.id); if (seen.has(upstream)) { collisions.push(`${model.id} and ${seen.get(upstream)} both resolve to "${upstream}"`); diff --git a/tests/unit/alibaba-image-media.test.ts b/tests/unit/alibaba-image-media.test.ts index 539df1437e..4a9e3e0a23 100644 --- a/tests/unit/alibaba-image-media.test.ts +++ b/tests/unit/alibaba-image-media.test.ts @@ -49,7 +49,7 @@ test("Alibaba registration preserves existing bare duplicate-model routing", () model: "z-image-turbo", }); assert.deepEqual(parseImageModel("qwen-image-2.0"), { - provider: "lmarena", + provider: "bailian-coding-plan", model: "qwen-image-2.0", }); assert.deepEqual(parseImageModel("qwen-image-3.0-pro"), { diff --git a/tests/unit/antigravity-byop-account-rotation.test.ts b/tests/unit/antigravity-byop-account-rotation.test.ts new file mode 100644 index 0000000000..cba1318d16 --- /dev/null +++ b/tests/unit/antigravity-byop-account-rotation.test.ts @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +// Regression guard for the Antigravity BYOP account-rotation follow-up: +// a GCP_PROJECT_REQUIRED 422 is account-specific (that Google account lacks +// a GCP Project ID), so chatCore must mark the account excluded and rotate +// to a sibling antigravity account instead of surfacing the error. When no +// sibling exists, the actionable 422 fast-fail is surfaced and the connection +// is excluded so selection prefers any other account. +const harness = await createChatPipelineHarness("antigravity-byop-rotation"); +const { BaseExecutor, buildRequest, handleChat, resetStorage, settingsDb } = harness; +const providersDb = await import("../../src/lib/db/providers.ts"); +const { clearAntigravityProjectCache } = + await import("../../open-sse/services/antigravityProjectBootstrap.ts"); +const { seedAntigravityIdeVersionCache, seedAntigravityCliVersionCache } = + await import("../../open-sse/services/antigravityVersion.ts"); + +test.beforeEach(async () => { + BaseExecutor.RETRY_CONFIG.delayMs = 0; + process.env.ANTIGRAVITY_CREDITS = "off"; + await resetStorage(); + await settingsDb.updateSettings({ requestRetry: 0, maxRetryIntervalSec: 0 }); + clearAntigravityProjectCache(); + seedAntigravityIdeVersionCache("2026.04.17-byop-rotation-test"); + seedAntigravityCliVersionCache("2026.04.17-byop-rotation-test"); +}); + +test.afterEach(() => { + clearAntigravityProjectCache(); + delete process.env.ANTIGRAVITY_CREDITS; +}); + +test.after(async () => { + await harness.cleanup(); +}); + +async function createAntigravityAccount(options: { + name: string; + email: string; + accessToken: string; + refreshToken: string; + priority?: number; +}) { + const connection = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: options.name, + email: options.email, + accessToken: options.accessToken, + refreshToken: options.refreshToken, + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + providerSpecificData: {}, + isActive: true, + testStatus: "active", + priority: options.priority, + }); + assert(connection && typeof connection.id === "string"); + return connection; +} + +test("Antigravity BYOP 422 rotates to a sibling account and the request succeeds", async () => { + const byopAccount = await createAntigravityAccount({ + name: "antigravity-byop-a", + email: "byop-a@example.test", + accessToken: "fake-byop-account-a-token", + refreshToken: "fake-byop-account-a-refresh", + priority: 1, // selected first — forces the rotation path + }); + const healthyAccount = await createAntigravityAccount({ + name: "antigravity-healthy-b", + email: "byop-b@example.test", + accessToken: "fake-healthy-account-b-token", + refreshToken: "fake-healthy-account-b-refresh", + priority: 2, + }); + + let onboardCallsForA = 0; + const modelCalls: Array<{ token: string }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url.startsWith("https://oauth2.googleapis.com/token")) { + // Antigravity OAuth refreshes mid-flow; echo each account's own token so + // the executor keeps using the per-account identity below. + const form = await request.text().catch(() => ""); + const refreshMatch = form.match(/refresh_token=([^&]+)/); + const refreshToken = refreshMatch ? decodeURIComponent(refreshMatch[1]) : ""; + const accessToken = + refreshToken === "fake-byop-account-a-refresh" + ? "fake-byop-account-a-token" + : "fake-healthy-account-b-token"; + return new Response(JSON.stringify({ access_token: accessToken, expires_in: 3600 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (request.url.endsWith(":loadCodeAssist")) { + const token = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, ""); + if (token === "fake-healthy-account-b-token") { + // Sibling account owns a Cloud Code project — discovery succeeds. + return new Response( + JSON.stringify({ cloudaicompanionProject: "projects/healthy-b-project" }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + // BYOP account: empty discovery — forces the onboardUser path. + return new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (request.url.endsWith(":onboardUser")) { + const token = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, ""); + if (token === "fake-byop-account-a-token") { + onboardCallsForA += 1; + // 200 done WITHOUT cloudaicompanionProject → Google BYOP (tracked #8491). + return new Response(JSON.stringify({ done: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + // Healthy account: onboarding creates the project. + return new Response( + JSON.stringify({ + done: true, + cloudaicompanionProject: { name: "projects/healthy-b-project" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (request.url.includes("cloudcode-pa.googleapis.com")) { + modelCalls.push({ + token: (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, ""), + }); + // The executor always uses the SSE endpoint (streamGenerateContent?alt=sse), + // even for non-streaming requests. + return new Response( + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"ok from account B"}]},"finishReason":"STOP"}]}}\n\n', + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + } + throw new Error(`Unexpected external fetch: ${request.url}`); + }; + + try { + const response = await handleChat( + buildRequest({ + body: { + model: "antigravity/gemini-2.5-flash", + stream: false, + messages: [{ role: "user", content: "hello" }], + }, + }) + ); + + assert.equal(response.status, 200); + const bodyText = await response.text().catch(() => ""); + assert.match(bodyText, /ok from account B/); + + // The model call must have gone out with the SIBLING account's token. + assert.ok(modelCalls.length >= 1, "model call should have been made"); + assert.equal(modelCalls[0].token, "fake-healthy-account-b-token"); + + // BYOP detection ran exactly once for account A (cached per token). + assert.equal(onboardCallsForA, 1); + + // Account A is excluded (rateLimitedUntil set in the future). + const updatedA = await providersDb.getProviderConnectionById(byopAccount.id); + assert.ok( + updatedA && Number(updatedA.rateLimitedUntil) > Date.now(), + "BYOP account should be excluded from selection" + ); + // Account B must NOT be excluded. + const updatedB = await providersDb.getProviderConnectionById(healthyAccount.id); + assert.ok( + !updatedB || + !Number(updatedB.rateLimitedUntil) || + Number(updatedB.rateLimitedUntil) <= Date.now(), + "healthy sibling account must not be excluded" + ); + } finally { + globalThis.fetch = originalFetch; + clearAntigravityProjectCache(); + } +}); + +test("Antigravity BYOP with no sibling account surfaces the actionable 422 and excludes the connection", async () => { + const byopAccount = await createAntigravityAccount({ + name: "antigravity-byop-only", + email: "byop-only@example.test", + accessToken: "fake-byop-only-token", + refreshToken: "fake-byop-only-refresh", + priority: 1, + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url.startsWith("https://oauth2.googleapis.com/token")) { + return new Response( + JSON.stringify({ access_token: "fake-byop-only-token", expires_in: 3600 }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (request.url.endsWith(":loadCodeAssist")) { + return new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (request.url.endsWith(":onboardUser")) { + // BYOP: 200 done WITHOUT cloudaicompanionProject. + return new Response(JSON.stringify({ done: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected external fetch: ${request.url}`); + }; + + try { + const response = await handleChat( + buildRequest({ + body: { + model: "antigravity/gemini-2.5-flash", + stream: false, + messages: [{ role: "user", content: "hello" }], + }, + }) + ); + const payload = (await response.json()) as { + error?: { code?: string; message?: string }; + }; + + assert.equal(response.status, 422); + // chatCore's error formatter rebuilds the body, so the code may be + // generic — the actionable message must survive (same assertion as the + // existing BYOP chat test). + assert.match(String(payload.error?.message), /GCP_PROJECT_REQUIRED/); + + // No sibling exists, so the connection is excluded for future requests. + const updated = await providersDb.getProviderConnectionById(byopAccount.id); + assert.ok( + updated && Number(updated.rateLimitedUntil) > Date.now(), + "BYOP account should be excluded from selection" + ); + } finally { + globalThis.fetch = originalFetch; + clearAntigravityProjectCache(); + } +}); diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index 542573b3a4..82ad399215 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -57,12 +57,11 @@ test("toClientAntigravityQuotaModelId preserves upstream Gemini Flash bucket IDs test("resolveAntigravityModelId maps the documented Antigravity aliases to upstream IDs", () => { assert.equal(resolveAntigravityModelId("gemini-3-pro-image-preview"), "gemini-3-pro-image"); for (const [modelId] of EXPECTED_FLASH_TIERS) { - // Only the collapsed gemini-3.7-flash id is aliased to the live upstream - // gemini-3.7-flash-tiered id; the suffixed gemini-3.7-flash-high/medium tier ids - // (like the 3.6/3.5 tiers) have no alias entry and pass through verbatim. - const expected = modelId === "gemini-3.7-flash" ? "gemini-3.7-flash-tiered" : modelId; - assert.equal(resolveAntigravityModelId(modelId), expected); + assert.equal(resolveAntigravityModelId(modelId), "gemini-3.7-flash-tiered"); } + assert.equal(resolveAntigravityModelId("gemini-3.7-flash"), "gemini-3.7-flash-tiered"); + assert.equal(resolveAntigravityModelId("gemini-3.7-flash-tiered"), "gemini-3.7-flash-tiered"); + assert.equal(resolveAntigravityModelId("gpt-oss-120b"), "gpt-oss-120b-medium"); assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5"), "claude-sonnet-4-6"); assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5-thinking"), "claude-sonnet-4-6"); assert.equal( diff --git a/tests/unit/antigravity-retired-public-models.test.ts b/tests/unit/antigravity-retired-public-models.test.ts index 5f778d5965..890509bb73 100644 --- a/tests/unit/antigravity-retired-public-models.test.ts +++ b/tests/unit/antigravity-retired-public-models.test.ts @@ -34,6 +34,7 @@ const EXPECTED_LEADING_MODEL_ORDER = [ "gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", + "gemini-3.7-flash-tiered", "gemini-pro-agent", "gemini-3.1-pro-low", "gemini-3.1-flash-lite", diff --git a/tests/unit/api/settings-audit.test.ts b/tests/unit/api/settings-audit.test.ts index e23296713b..20844c741a 100644 --- a/tests/unit/api/settings-audit.test.ts +++ b/tests/unit/api/settings-audit.test.ts @@ -108,6 +108,28 @@ test("AC-9: successful PATCH writes settings.update with diff of changed keys", }); }); +test("CLI subject stamp preserves actor attribution after the raw token is stripped", async () => { + await bootstrapWithPassword("initial-pass-cli-actor"); + await settingsDb.updateSettings({ theme: "light" }); + + const response = await settingsRoute.PATCH( + new Request("http://localhost/api/settings", { + method: "PATCH", + headers: { + "content-type": "application/json", + "x-omniroute-auth-kind": "management_key", + "x-omniroute-auth-label": "local-cli-token", + }, + body: JSON.stringify({ theme: "dark" }), + }) + ); + + assert.equal(response.status, 200); + const rows = settingsRows().filter((r) => r.action === "settings.update"); + assert.equal(rows.length, 1); + assert.equal(rows[0].actor, "cli"); +}); + // ─── AC-10 — failure rows for each rejection path ──────────────────────── test("AC-10a: PASSWORD_REQUIRED failure writes settings.update_failed", async () => { diff --git a/tests/unit/api/v1/relay-completions-errors.test.ts b/tests/unit/api/v1/relay-completions-errors.test.ts new file mode 100644 index 0000000000..96b19d5d6f --- /dev/null +++ b/tests/unit/api/v1/relay-completions-errors.test.ts @@ -0,0 +1,253 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + checkIpRateLimit, + getClientIp, + sanitizeForensicHeader, +} from "../../../../src/app/api/v1/relay/chat/completions/relaySecurity.ts"; +import { getDbInstance } from "../../../../src/lib/db/core.ts"; +import { getRelayLogs } from "../../../../src/lib/db/relayProxies.ts"; + +// ─── Relay completions route: Bifrost upstream error normalization ────────── +// +// T-issues: (1) a plain-text/HTML non-OK Bifrost response must be normalized +// into a valid OpenAI JSON error instead of leaking raw text (which produces +// client-side "invalid character 'd'" parse failures); (3) upstream 4xx must be +// recorded as analytics "error", never "success". + +const ORIGINAL_BIFROST_BASE_URL = process.env.BIFROST_BASE_URL; +const ORIGINAL_BIFROST_API_KEY = process.env.BIFROST_API_KEY; +const ORIGINAL_BIFROST_OMNI_KEY = process.env.OMNIROUTE_BIFROST_KEY; +const ORIGINAL_BIFROST_TIMEOUT = process.env.BIFROST_TIMEOUT_MS; +const ORIGINAL_BIFROST_STREAMING = process.env.BIFROST_STREAMING_ENABLED; +const ORIGINAL_RELAY_BACKEND = process.env.OMNIROUTE_RELAY_BACKEND; +const ORIGINAL_FETCH = globalThis.fetch; + +function seedRelayToken(rawToken: string) { + const id = `rl_test_${Date.now()}_${Math.random().toString(16).slice(2)}`; + const now = Math.floor(Date.now() / 1000); + getDbInstance() + .prepare( + ` + INSERT INTO relay_tokens (id, name, token_hash, token_prefix, description, combo_id, + allowed_models, max_tokens_per_request, max_requests_per_minute, max_requests_per_day, + max_cost_per_day, enabled, created_at, updated_at, expires_at, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + ` + ) + .run( + id, + "relay-completions-err", + createHash("sha256").update(rawToken).digest("hex"), + "rl_test", + "", + null, + JSON.stringify(["*"]), + 128000, + 60, + 10000, + 0, + now, + now, + null, + "{}" + ); + return { id, rawToken }; +} + +function restoreEnv() { + if (ORIGINAL_BIFROST_BASE_URL === undefined) delete process.env.BIFROST_BASE_URL; + else process.env.BIFROST_BASE_URL = ORIGINAL_BIFROST_BASE_URL; + if (ORIGINAL_BIFROST_API_KEY === undefined) delete process.env.BIFROST_API_KEY; + else process.env.BIFROST_API_KEY = ORIGINAL_BIFROST_API_KEY; + if (ORIGINAL_BIFROST_OMNI_KEY === undefined) delete process.env.OMNIROUTE_BIFROST_KEY; + else process.env.OMNIROUTE_BIFROST_KEY = ORIGINAL_BIFROST_OMNI_KEY; + if (ORIGINAL_BIFROST_TIMEOUT === undefined) delete process.env.BIFROST_TIMEOUT_MS; + else process.env.BIFROST_TIMEOUT_MS = ORIGINAL_BIFROST_TIMEOUT; + if (ORIGINAL_BIFROST_STREAMING === undefined) delete process.env.BIFROST_STREAMING_ENABLED; + else process.env.BIFROST_STREAMING_ENABLED = ORIGINAL_BIFROST_STREAMING; + if (ORIGINAL_RELAY_BACKEND === undefined) delete process.env.OMNIROUTE_RELAY_BACKEND; + else process.env.OMNIROUTE_RELAY_BACKEND = ORIGINAL_RELAY_BACKEND; + globalThis.fetch = ORIGINAL_FETCH; +} + +function setupBifrostEnv() { + process.env.OMNIROUTE_RELAY_BACKEND = "bifrost"; + process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080"; + process.env.BIFROST_TIMEOUT_MS = "5000"; + delete process.env.BIFROST_API_KEY; + delete process.env.OMNIROUTE_BIFROST_KEY; + delete process.env.BIFROST_STREAMING_ENABLED; +} + +test("relay route: normalizes plain-text Bifrost 404 into JSON error (Issue #1)", async () => { + setupBifrostEnv(); + const relayToken = seedRelayToken(`relay_err_${Date.now()}`); + + // Bifrost sidecar returns a raw HTML/plain-text non-OK response — the exact + // "invalid character 'd'" scenario behind client JSON parse failures. + globalThis.fetch = async () => { + return new Response("404 page not found", { + status: 404, + headers: { "content-type": "text/html" }, + }); + }; + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "relay-err-404", + }, + body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }), + }); + + const res = await POST(req); + // Status preserved from upstream (404), but body is valid JSON, not HTML. + assert.equal(res.status, 404); + assert.equal(res.headers.get("content-type"), "application/json"); + // The critical fix: the client receives parseable JSON, NOT a raw HTML body + // (which previously caused "invalid character 'd'" JSON.parse failures). + const raw = await res.text(); + assert.doesNotMatch(String(raw), /^ { + setupBifrostEnv(); + const relayToken = seedRelayToken(`relay_err_${Date.now()}`); + + globalThis.fetch = async () => { + return new Response( + "502 Bad Gateway
invalid character 'd'
", + { status: 502, headers: { "content-type": "text/html" } } + ); + }; + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "relay-err-502", + }, + body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }), + }); + + const res = await POST(req); + assert.equal(res.status, 502); + assert.equal(res.headers.get("content-type"), "application/json"); + const body = await res.json(); + assert.ok(body?.error?.message); + + // Upstream 4xx/5xx must be recorded as analytics "error" (Issue #3). + const logs = getRelayLogs(relayToken.id, 10); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, "error"); + assert.equal(logs[0].status_code, 502); + + restoreEnv(); +}); + +test("relay route: strips stale upstream content-length before serializing JSON error body", async () => { + setupBifrostEnv(); + const relayToken = seedRelayToken(`relay_err_${Date.now()}`); + + // The upstream Response carries an EXPLICIT content-length for its own (HTML) + // body. Once the route replaces that body with a freshly-serialized JSON error, + // a stale content-length copied verbatim onto the outgoing Response would + // mismatch the real byte length of the new body. + globalThis.fetch = async () => { + const html = "404 page not found, upstream sidecar unreachable"; + return new Response(html, { + status: 404, + headers: { + "content-type": "text/html", + "content-length": String(Buffer.byteLength(html)), + "content-encoding": "gzip", + "transfer-encoding": "chunked", + }, + }); + }; + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "relay-err-stale-length", + }, + body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }), + }); + + const res = await POST(req); + assert.equal(res.status, 404); + assert.equal(res.headers.get("content-encoding"), null, "stale content-encoding must be stripped"); + assert.equal(res.headers.get("transfer-encoding"), null, "stale transfer-encoding must be stripped"); + + const raw = await res.text(); + const declaredLength = res.headers.get("content-length"); + if (declaredLength !== null) { + assert.equal( + Number(declaredLength), + Buffer.byteLength(raw), + "content-length, if present, must match the actual serialized JSON error body" + ); + } + + restoreEnv(); +}); + +test("relay route: upstream 401 recorded as analytics error not success (Issue #3)", async () => { + setupBifrostEnv(); + const relayToken = seedRelayToken(`relay_err_${Date.now()}`); + + globalThis.fetch = async () => { + return new Response(JSON.stringify({ error: { message: "unauthorized" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + }; + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "relay-err-401", + }, + body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }), + }); + + const res = await POST(req); + assert.equal(res.status, 401); + + const logs = getRelayLogs(relayToken.id, 10); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, "error"); + assert.equal(logs[0].status_code, 401); + + restoreEnv(); +}); diff --git a/tests/unit/audio-speech-ogg-alias-10587.test.ts b/tests/unit/audio-speech-ogg-alias-10587.test.ts new file mode 100644 index 0000000000..409406657e --- /dev/null +++ b/tests/unit/audio-speech-ogg-alias-10587.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { normalizeSpeechResponseFormat, handleAudioSpeech } = await import( + "../../open-sse/handlers/audioSpeech.ts" +); + +test("normalizeSpeechResponseFormat aliases ogg to opus (#10587)", () => { + assert.equal(normalizeSpeechResponseFormat("ogg"), "opus"); + assert.equal(normalizeSpeechResponseFormat("OGG"), "opus"); + assert.equal(normalizeSpeechResponseFormat("opus"), "opus"); + assert.equal(normalizeSpeechResponseFormat("mp3"), "mp3"); + assert.equal(normalizeSpeechResponseFormat(undefined), "mp3"); +}); + +test("OpenAI-compat speech path remaps ogg to opus before upstream", async () => { + const originalFetch = globalThis.fetch; + let captured; + globalThis.fetch = async (_url, options = {}) => { + captured = JSON.parse(String(options.body || "{}")); + return new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "audio/opus" }, + }); + }; + try { + const response = await handleAudioSpeech({ + body: { + model: "openai/tts-1", + input: "format check", + voice: "alloy", + response_format: "ogg", + }, + credentials: { apiKey: "openai-key" }, + }); + assert.equal(response.status, 200); + assert.equal(captured.response_format, "opus"); + assert.equal(captured.model, "tts-1"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/auto-combo-scoring-clamp.test.ts b/tests/unit/auto-combo-scoring-clamp.test.ts index 1e00aca021..a73d24cf8b 100644 --- a/tests/unit/auto-combo-scoring-clamp.test.ts +++ b/tests/unit/auto-combo-scoring-clamp.test.ts @@ -36,6 +36,7 @@ const ONES: ScoringFactors = { contextAffinity: 1, resetWindowAffinity: 1, connectionDensity: 1, + quality: 1, }; function candidate(partial: Partial = {}): ProviderCandidate { diff --git a/tests/unit/auto-empty-pool-warn-once.test.ts b/tests/unit/auto-empty-pool-warn-once.test.ts index 016b2e0997..9d2436aad1 100644 --- a/tests/unit/auto-empty-pool-warn-once.test.ts +++ b/tests/unit/auto-empty-pool-warn-once.test.ts @@ -1,18 +1,24 @@ +import test from "node:test"; import assert from "node:assert/strict"; -import { test } from "node:test"; import { - EMPTY_POOL_WARN_INTERVAL_MS, resetEmptyAutoPoolWarnStateForTests, warnEmptyAutoPoolOnce, } from "../../open-sse/services/autoCombo/virtualFactory.ts"; -test("warnEmptyAutoPoolOnce emits at most once per label per interval", () => { +test("warnEmptyAutoPoolOnce emits at most once per label per process", () => { resetEmptyAutoPoolWarnStateForTests(); - const t0 = 1_000_000; + const t0 = 1_700_000_000_000; assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0), true); assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + 1), false); - assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + EMPTY_POOL_WARN_INTERVAL_MS - 1), false); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + 60_000), false); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + 3_600_000), false); assert.equal(warnEmptyAutoPoolOnce("auto/other", "empty", t0 + 1), true); - assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + EMPTY_POOL_WARN_INTERVAL_MS), true); +}); + +test("resetEmptyAutoPoolWarnStateForTests allows a later warn (emptiness reappeared)", () => { + resetEmptyAutoPoolWarnStateForTests(); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty"), true); + resetEmptyAutoPoolWarnStateForTests(); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty"), true); }); diff --git a/tests/unit/auto-routing-analytics-db.test.ts b/tests/unit/auto-routing-analytics-db.test.ts new file mode 100644 index 0000000000..8216f535f3 --- /dev/null +++ b/tests/unit/auto-routing-analytics-db.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const originalDataDir = process.env.DATA_DIR; +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auto-routing-analytics-")); +process.env.DATA_DIR = testDataDir; + +const core = await import("../../src/lib/db/core.ts"); +const usageLogs = await import("../../src/lib/db/usageLogs.ts"); + +test.before(() => { + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(testDataDir, { recursive: true, force: true }); + if (originalDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = originalDataDir; + } +}); + +test("auto-routing analytics use requested models from the runtime schema", () => { + const db = core.getDbInstance(); + const insert = db.prepare( + `INSERT INTO call_logs (id, model, requested_model, provider, timestamp) + VALUES (?, ?, ?, ?, ?)` + ); + const timestamp = new Date().toISOString(); + + insert.run("auto-default", "claude-opus-5", "auto", "anthropic", timestamp); + insert.run("auto-fast-1", "gpt-5.6-luna", "auto/fast", "openai", timestamp); + insert.run("auto-fast-2", "gpt-5.6-terra", "auto/fast", "openai", timestamp); + insert.run("direct", "gpt-4", "gpt-4", "openai", timestamp); + + assert.deepEqual(usageLogs.getAutoRoutingTotalCount(), { count: 3 }); + assert.deepEqual(usageLogs.getAutoRoutingVariantBreakdown(), [ + { variant: "fast", count: 2 }, + { variant: "default", count: 1 }, + ]); + assert.deepEqual(usageLogs.getAutoRoutingTopProviders(), [ + { provider: "openai", count: 2 }, + { provider: "anthropic", count: 1 }, + ]); +}); diff --git a/tests/unit/build/assemble-standalone.test.ts b/tests/unit/build/assemble-standalone.test.ts index 14c7d890b9..14a8de854b 100644 --- a/tests/unit/build/assemble-standalone.test.ts +++ b/tests/unit/build/assemble-standalone.test.ts @@ -216,3 +216,76 @@ test("every relative import of standalone-server-ws.mjs is shipped into the bund } fs.rmSync(tmp, { recursive: true, force: true }); }); + +// Regression guard (deploy 2026-08-19): under heavy concurrent build I/O the bulk +// "standalone -> outDir" tree copy can already have carried a prior pass's result into +// an EXTRA_MODULE_ENTRIES/NATIVE_ASSET_ENTRIES `dest` BEFORE that entry's own copy runs +// — either an absolute symlink resolving to the exact same real path as `src` (a pnpm +// store layout), or a stale node of a different type (file/symlink vs directory). Node's +// fs.cpSync/fs.cp refuse both cases even with force:true, throwing ERR_FS_CP_EINVAL +// ("src and dest cannot be the same") or ERR_FS_CP_DIR_TO_NON_DIR/ERR_FS_CP_NON_DIR_TO_DIR +// respectively, crashing every one of copyNativeAssetsAndExtraModules, +// repairEmptyExternalPackageDirs, syncNativeAssetsToDir, and syncExtraModulesToDir. +test("copy passes tolerate a dest that already resolves to src, or a stale-typed dest", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-race-")); + const projectRoot = path.join(tmp, "src-root"); + seedSidecarSources(projectRoot); + + // Case 1 (sync path): dest already an absolute symlink resolving to src's realpath — + // simulates the wreq-js entry after the .build/next/standalone bulk copy already + // carried an absolute symlink over from an earlier standalone build. + const distDir = path.join(projectRoot, ".build/next"); + fs.mkdirSync(path.join(distDir, "standalone"), { recursive: true }); + fs.writeFileSync(path.join(distDir, "standalone", "server.js"), "// server"); + const outSync = path.join(tmp, "out-sync"); + fs.mkdirSync(path.join(outSync, "node_modules"), { recursive: true }); + fs.symlinkSync( + path.join(projectRoot, "node_modules/wreq-js"), + path.join(outSync, "node_modules/wreq-js") + ); + // Case 2 (sync path): dest already a plain FILE where src is a directory — + // simulates @swc/helpers landing as a stray file from an unrelated earlier copy. + fs.mkdirSync(path.join(outSync, "node_modules/@swc"), { recursive: true }); + fs.writeFileSync(path.join(outSync, "node_modules/@swc/helpers"), "stale file, not a dir"); + + assert.doesNotThrow(() => { + assembleStandalone({ + distDir, + outDir: outSync, + projectRoot, + sanitizePaths: false, + copyNatives: true, + }); + }, "assembleStandalone must not throw on a same-realpath symlink or a stale-typed dest"); + + assert.ok( + fs.existsSync(path.join(outSync, "node_modules/wreq-js/rust/lib.so")), + "wreq-js content reachable through the pre-existing symlink" + ); + assert.ok( + fs.statSync(path.join(outSync, "node_modules/@swc/helpers")).isDirectory(), + "the stale file at @swc/helpers was replaced by the real directory" + ); + assert.ok( + fs.existsSync(path.join(outSync, "node_modules/@swc/helpers/package.json")), + "@swc/helpers content copied after clearing the stale file" + ); + + // Case 3 (async path): same real-path-symlink collision hits syncStandaloneExtraModules. + const outAsync = path.join(tmp, "out-async"); + fs.mkdirSync(path.join(outAsync, "node_modules"), { recursive: true }); + fs.symlinkSync( + path.join(projectRoot, "node_modules/sql.js"), + path.join(outAsync, "node_modules/sql.js") + ); + await assert.doesNotReject( + () => syncStandaloneExtraModules(projectRoot, fs.promises, { log() {} }, outAsync), + "syncStandaloneExtraModules must not throw on a same-realpath symlink" + ); + assert.ok( + fs.existsSync(path.join(outAsync, "node_modules/sql.js/dist/sql-wasm.js")), + "sql.js content reachable through the pre-existing symlink" + ); + + fs.rmSync(tmp, { recursive: true, force: true }); +}); diff --git a/tests/unit/cache-stats-reports-semantic-cache.test.ts b/tests/unit/cache-stats-reports-semantic-cache.test.ts new file mode 100644 index 0000000000..3c7bade142 --- /dev/null +++ b/tests/unit/cache-stats-reports-semantic-cache.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { getPromptCache } from "../../src/lib/cacheLayer.ts"; +import { + clearMemoryCache, + getMemoryCacheStats, + setCachedResponse, +} from "../../src/lib/semanticCache.ts"; + +// Regression guard for /api/cache/stats. +// +// The route used to read getPromptCache() — an LRU that no request path writes +// to. It answered "0 hit / 0 miss, size 0" no matter how much traffic the +// semantic cache served, and two dashboard pages rendered that as fact. +// +// The first assertion fails against the old wiring: caching a response fills the +// semantic cache and leaves the prompt cache empty. +describe("cache stats report the cache that requests actually use", () => { + it("counts an entry written through the semantic cache", () => { + clearMemoryCache(); + getPromptCache().clear(); + + const before = getMemoryCacheStats(); + setCachedResponse("sig-cache-stats-guard", "gpt-4.1", { choices: [] }, 42); + const after = getMemoryCacheStats(); + + assert.equal(after.size, before.size + 1); + assert.equal(getPromptCache().getStats().size, 0); + }); + + it("keeps the shape the dashboards read, with a numeric hit rate", () => { + const stats = getMemoryCacheStats(); + + for (const key of ["size", "maxSize", "hits", "misses", "hitRate"]) { + assert.ok(key in stats, `missing ${key}`); + } + // Both dashboard pages call hitRate.toFixed(1); a string would throw there. + assert.equal(typeof stats.hitRate, "number"); + assert.equal(typeof stats.size, "number"); + assert.equal(typeof stats.maxSize, "number"); + }); + + it("clears the in-memory entries", () => { + setCachedResponse("sig-cache-stats-clear", "gpt-4.1", { choices: [] }, 1); + assert.ok(getMemoryCacheStats().size > 0); + + clearMemoryCache(); + + assert.equal(getMemoryCacheStats().size, 0); + }); +}); diff --git a/tests/unit/call-log-error-type.test.ts b/tests/unit/call-log-error-type.test.ts new file mode 100644 index 0000000000..848e51952c --- /dev/null +++ b/tests/unit/call-log-error-type.test.ts @@ -0,0 +1,192 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getDbInstance } from "../../src/lib/db/core.ts"; +import { classifyCallLogError } from "../../src/lib/usage/callLogs/format.ts"; +import { saveCallLog } from "../../src/lib/usage/callLogs.ts"; +import { getErrorTypeBreakdown } from "../../src/lib/db/callLogStats.ts"; + +test("call_logs table has error_type column", () => { + const db = getDbInstance(); + const columns = db.prepare("PRAGMA table_info(call_logs)").all() as { name: string }[]; + const colNames = columns.map((c) => c.name); + assert.ok(colNames.includes("error_type"), "call_logs should have error_type column"); +}); + +test("classifyCallLogError maps status+body to the provider error family", () => { + assert.equal(classifyCallLogError(402, "whatever body", "openai"), "quota_exhausted"); + assert.equal(classifyCallLogError(500, "Internal Server Error", "openai"), "server_error"); + assert.equal(classifyCallLogError(429, "rate limit", "openai"), "rate_limited"); + assert.equal(classifyCallLogError(404, "model not found", "openai"), "model_not_found"); + assert.equal(classifyCallLogError(401, "bad key", "openai"), "unauthorized"); +}); + +test("classifyCallLogError classifies only failures", () => { + assert.equal(classifyCallLogError(200, "", "openai"), null); + assert.equal(classifyCallLogError(200, "some body", "openai"), null); + assert.equal(classifyCallLogError(0, "boom", "test-provider"), null); +}); + +test("classifyCallLogError extracts message from Error object", () => { + assert.equal( + classifyCallLogError(403, new Error("browser_signature_banned"), "openai"), + "fingerprint_rejection" + ); +}); + +test("classifyCallLogError returns null for unclassifiable provider-403 (api key)", () => { + assert.equal(classifyCallLogError(403, "some other 403 body", "openai"), null); +}); + +test("saveCallLog persists error_type from failure", async () => { + const db = getDbInstance(); + const testId = `test-errtype-${Date.now()}`; + + await saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 402, + error: "exceeded your current quota", + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + }); + + const row = db.prepare("SELECT error_type FROM call_logs WHERE id = ?").get(testId) as { + error_type: string | null; + }; + assert.equal(row.error_type, "quota_exhausted"); + + db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId); +}); + +test("saveCallLog persists null error_type for success", async () => { + const db = getDbInstance(); + const testId = `test-errtype-ok-${Date.now()}`; + + await saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + }); + + const row = db.prepare("SELECT error_type FROM call_logs WHERE id = ?").get(testId) as { + error_type: string | null; + }; + assert.equal(row.error_type, null); + + db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId); +}); + +test("saveCallLog normalizes Error object before classifying", async () => { + const db = getDbInstance(); + const testId = `test-errtype-err-${Date.now()}`; + + await saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: new Error("browser_signature_banned"), + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + }); + + const row = db.prepare("SELECT error_type FROM call_logs WHERE id = ?").get(testId) as { + error_type: string | null; + }; + assert.equal(row.error_type, "fingerprint_rejection"); + + db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId); +}); + +test("getErrorTypeBreakdown groups failures by family, excludes successes", async () => { + const db = getDbInstance(); + const ids = [ + `test-errbd-q1-${Date.now()}`, + `test-errbd-q2-${Date.now()}`, + `test-errbd-s5-${Date.now()}`, + `test-errbd-403-${Date.now()}`, + `test-errbd-ok-${Date.now()}`, + ]; + + await saveCallLog({ + id: ids[0], + method: "POST", + path: "/v1/chat/completions", + status: 402, + error: "exceeded your current quota", + model: "m", + provider: "test-provider", + duration: 100, + tokens: { in: 1, out: 1 }, + }); + await saveCallLog({ + id: ids[1], + method: "POST", + path: "/v1/chat/completions", + status: 402, + error: "insufficient balance", + model: "m", + provider: "test-provider", + duration: 100, + tokens: { in: 1, out: 1 }, + }); + await saveCallLog({ + id: ids[2], + method: "POST", + path: "/v1/chat/completions", + status: 500, + error: "Internal Server Error", + model: "m", + provider: "test-provider", + duration: 100, + tokens: { in: 1, out: 1 }, + }); + await saveCallLog({ + id: ids[3], + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "test-provider", + duration: 100, + tokens: { in: 1, out: 1 }, + }); + await saveCallLog({ + id: ids[4], + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "m", + provider: "test-provider", + duration: 100, + tokens: { in: 1, out: 1 }, + }); + + const whereClause = `WHERE id IN (${ids.map((_, i) => `@id${i}`).join(", ")})`; + const params = Object.fromEntries(ids.map((id, i) => [`id${i}`, id])); + const breakdown = getErrorTypeBreakdown(whereClause, params); + + assert.deepEqual(breakdown, [ + { errorType: "quota_exhausted", count: 2 }, + { errorType: "server_error", count: 1 }, + { errorType: "unclassified", count: 1 }, + ]); + + ids.forEach((id) => db.prepare("DELETE FROM call_logs WHERE id = ?").run(id)); +}); + +test("getErrorTypeBreakdown with empty whereClause does not crash", () => { + const breakdown = getErrorTypeBreakdown("", {}); + assert.ok(Array.isArray(breakdown)); +}); diff --git a/tests/unit/call-logs-exclude-tests-allowlist.test.ts b/tests/unit/call-logs-exclude-tests-allowlist.test.ts new file mode 100644 index 0000000000..78aa5728af --- /dev/null +++ b/tests/unit/call-logs-exclude-tests-allowlist.test.ts @@ -0,0 +1,132 @@ +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"; + +/** + * Home "Recent Requests" feed passes `excludeTests` to getCallLogs so the panel shows + * ONLY real provider inference — never backend/management log rows. The filter is an + * ALLOWLIST of the public gateway namespaces (`/v1/%` and `/api/v1/%`), applied before + * LIMIT, rather than a blacklist of individual known noise types. This guards against + * the reported regression where model-sync rows (request_type 'model-sync', path + * `/api/providers/*`) leaked into the feed because the old blacklist only dropped + * connection-test rows. + */ + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-calllogs-allowlist-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.CALL_LOG_RETENTION_DAYS = "3650"; + +const core = await import("../../src/lib/db/core.ts"); +const callLogs = await import("../../src/lib/usage/callLogs.ts"); + +type SeedRow = { + id: string; + timestamp: string; + path: string; + model: string; + provider: string; + source_format?: string; + request_type?: string | null; +}; + +function insertCallLog(row: SeedRow) { + const db = core.getDbInstance(); + db.prepare( + ` + INSERT INTO call_logs ( + id, timestamp, method, path, status, model, provider, source_format, request_type, detail_state + ) + VALUES ( + @id, @timestamp, 'POST', @path, 200, @model, @provider, @source_format, @request_type, 'none' + ) + ` + ).run({ + source_format: row.source_format ?? null, + request_type: row.request_type ?? null, + ...row, + }); +} + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("excludeTests keeps only /v1 and /api/v1 inference rows, drops all backend/management rows", async () => { + const base = Date.parse("2026-01-01T00:00:00.000Z"); + const iso = (i: number) => new Date(base + i * 1000).toISOString(); + + // Two REAL provider inference rows — the only rows the feed should keep. + insertCallLog({ + id: "real-v1", + timestamp: iso(4), + path: "/v1/chat/completions", + model: "openai/gpt-4.1", + provider: "openai", + }); + insertCallLog({ + id: "real-api-v1", + timestamp: iso(3), + path: "/api/v1/chat/completions", + model: "anthropic/claude-opus-4-8", + provider: "anthropic", + }); + + // Backend/management NOISE — must never appear in the feed. + insertCallLog({ + id: "noise-model-sync", + timestamp: iso(2), + path: "/api/providers/openai/models", + model: "model-sync", + provider: "openai", + source_format: "-", + request_type: "model-sync", + }); + insertCallLog({ + id: "noise-connection-test", + timestamp: iso(1), + path: "/api/providers/test", + model: "connection-test", + provider: "openai", + source_format: "test", + }); + + const rows = await callLogs.getCallLogs({ excludeTests: true, limit: 50 }); + const ids = rows.map((row) => row.id).sort(); + + assert.deepEqual( + ids, + ["real-api-v1", "real-v1"], + "only the /v1 and /api/v1 inference rows survive the allowlist" + ); +}); + +test("without excludeTests every row is returned (allowlist is opt-in)", async () => { + const base = Date.parse("2026-02-01T00:00:00.000Z"); + insertCallLog({ + id: "real", + timestamp: new Date(base).toISOString(), + path: "/v1/chat/completions", + model: "openai/gpt-4.1", + provider: "openai", + }); + insertCallLog({ + id: "sync", + timestamp: new Date(base + 1000).toISOString(), + path: "/api/providers/openai/models", + model: "model-sync", + provider: "openai", + request_type: "model-sync", + }); + + const rows = await callLogs.getCallLogs({ limit: 50 }); + assert.equal(rows.length, 2, "no allowlist → backend rows are not filtered out"); +}); diff --git a/tests/unit/catalog-auto-routing-disabled-10831.test.ts b/tests/unit/catalog-auto-routing-disabled-10831.test.ts new file mode 100644 index 0000000000..f2f96ef95f --- /dev/null +++ b/tests/unit/catalog-auto-routing-disabled-10831.test.ts @@ -0,0 +1,98 @@ +/** + * #10831 — when auto routing is switched off, `auto/*` ids must not be + * advertised in `/v1/models`. + * + * Unlike `hideAutoCombos` (#9418), which only unadvertises ids that still route + * when a client sends them explicitly, `autoRoutingEnabled: false` makes the + * router reject every `auto/*` id with + * "Auto routing is disabled. Enable it in Settings > Routing." (see + * src/sse/handlers/autoRouting.ts). Listing them therefore offers the picker a + * choice that can only fail. + */ +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-auto-routing-10831-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +async function fetchCatalog(): Promise> { + const res = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models", { method: "GET" }) + ); + if (res.status !== 200) { + const body = await res.text(); + assert.fail(`Expected 200, got ${res.status}: ${body.slice(0, 500)}`); + } + const body = (await res.json()) as { data: Array<{ id: string }> }; + return body.data; +} + +const isAutoId = (m: { id: string }) => m.id.startsWith("auto/"); + +test.after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + /* best-effort */ + } +}); + +test("autoRoutingEnabled=false removes auto/* ids from /v1/models", async () => { + await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "openai-main", + apiKey: "sk-test", + isActive: true, + }); + + // Baseline: routing on, ids advertised. + await settingsDb.updateSettings({ autoRoutingEnabled: true, hideAutoCombos: false }); + const on = await fetchCatalog(); + const autoWhenOn = on.filter(isAutoId).map((m) => m.id); + assert.equal( + autoWhenOn.length > 0, + true, + `expected auto/* ids while auto routing is enabled, got ${autoWhenOn.length}` + ); + + // Routing off: none may remain. + await settingsDb.updateSettings({ autoRoutingEnabled: false, hideAutoCombos: false }); + const off = await fetchCatalog(); + const leaked = off.filter(isAutoId).map((m) => m.id); + assert.deepEqual( + leaked, + [], + `auto/* ids leaked while auto routing is disabled: ${leaked.join(", ")}` + ); + + // Everything else must survive — this is a filter, not a catalog wipe. + const hasProviderModel = off.some((m) => m.id.startsWith("openai/") || m.id.startsWith("oa/")); + assert.equal(hasProviderModel, true, "provider models must remain when auto routing is disabled"); +}); + +test("re-enabling auto routing brings auto/* ids back (cache key varies on the flag)", async () => { + await settingsDb.updateSettings({ autoRoutingEnabled: false, hideAutoCombos: false }); + const off = await fetchCatalog(); + assert.deepEqual( + off.filter(isAutoId).map((m) => m.id), + [] + ); + + await settingsDb.updateSettings({ autoRoutingEnabled: true, hideAutoCombos: false }); + const back = await fetchCatalog(); + assert.equal( + back.filter(isAutoId).length > 0, + true, + "auto/* ids must return once auto routing is re-enabled — a stale cached catalog would fail here" + ); +}); diff --git a/tests/unit/catalog-helpers-extraction.test.ts b/tests/unit/catalog-helpers-extraction.test.ts index 42062bf7b3..6eb0dafd66 100644 --- a/tests/unit/catalog-helpers-extraction.test.ts +++ b/tests/unit/catalog-helpers-extraction.test.ts @@ -16,6 +16,7 @@ import { minKnownNumber, maybeOmitCatalogModelName, getThinkingCapabilityFields, + getConnectionScopedEffortTiers, } from "../../src/app/api/v1/models/catalogHelpers.ts"; import { qualifyOpenRouterModelId, @@ -84,6 +85,73 @@ test("catalogHelpers: Kiro GPT-5.6 models expose the native Max tier", () => { } }); +test("catalogHelpers: connection-scoped combo efforts honor dynamic, pinned, and allowlisted scopes", () => { + const modelsByConnection = { + first: [{ id: "grok-4.6", supportedThinkingEfforts: ["low", "medium", "high"] }], + second: [{ id: "grok-4.6", supportedThinkingEfforts: ["medium", "high"] }], + unknown: [{ id: "other-model", supportedThinkingEfforts: ["low"] }], + }; + + assert.deepEqual( + getConnectionScopedEffortTiers("grok-4.6", {}, ["first", "second"], modelsByConnection), + ["medium", "high"] + ); + assert.deepEqual( + getConnectionScopedEffortTiers( + "grok-4.6", + { connectionId: "first" }, + ["first", "second"], + modelsByConnection + ), + ["low", "medium", "high"] + ); + assert.deepEqual( + getConnectionScopedEffortTiers( + "grok-4.6", + { allowedConnectionIds: ["second"] }, + ["first", "second"], + modelsByConnection + ), + ["medium", "high"] + ); + assert.deepEqual( + getConnectionScopedEffortTiers("grok-4.6", {}, undefined, modelsByConnection), + [], + "a dynamic target fails closed when any catalog-backed connection lacks the model" + ); + assert.deepEqual( + getConnectionScopedEffortTiers("grok-4.6", {}, ["first", "unknown"], modelsByConnection), + [] + ); + assert.deepEqual( + getConnectionScopedEffortTiers("grok-4.6", {}, ["first", "no-tiers"], { + ...modelsByConnection, + "no-tiers": [{ id: "grok-4.6" }], + }), + [] + ); + assert.deepEqual( + getConnectionScopedEffortTiers("grok-4.6", {}, ["unknown"], modelsByConnection), + [] + ); + assert.deepEqual( + getConnectionScopedEffortTiers("missing-model", {}, undefined, { + nodeCatalog: [{ id: "other-model" }], + }), + [] + ); + assert.equal(getConnectionScopedEffortTiers("grok-4.6", {}, ["first"], {}), undefined); + assert.deepEqual( + getConnectionScopedEffortTiers("grok-4.6", { connectionId: "stale" }, ["first"], {}), + [] + ); + assert.deepEqual( + getConnectionScopedEffortTiers("grok-4.6", { allowedConnectionIds: ["stale"] }, ["first"], {}), + [] + ); + assert.deepEqual(getConnectionScopedEffortTiers("grok-4.6", {}, [], {}), []); +}); + test("catalogHelpers: minKnownNumber ignores non-positive/unknown", () => { assert.equal(minKnownNumber([3, 1, 2]), 1); assert.equal(minKnownNumber([undefined, 0, -5, 7]), 7); diff --git a/tests/unit/chat-body-admission.test.ts b/tests/unit/chat-body-admission.test.ts index a4e0503db8..e4707402a1 100644 --- a/tests/unit/chat-body-admission.test.ts +++ b/tests/unit/chat-body-admission.test.ts @@ -80,6 +80,24 @@ test("a byte-light request above the message threshold acquires heavyweight capa assert.equal(controller.activeHeavy, 0); }); +test("Responses input items count toward heavyweight admission", async () => { + const controller = new ChatAdmissionController(1); + const result = await admitChatStructure( + { + input: [ + { role: "user", content: "one" }, + { role: "user", content: "two" }, + ], + }, + null, + { controller, maxMessages: 10, heavyMessages: 2, heavyTools: 10, heavyTokens: 10_000 } + ); + + assert.equal(result.admit, true); + assert.equal(controller.activeHeavy, 1); + if (result.admit) result.lease?.release(); +}); + test("a byte-light request above the tool threshold is rejected when heavy capacity is busy AND the heap is genuinely under pressure (#10183/#10268)", async () => { const controller = new ChatAdmissionController(1); const occupied = controller.tryAcquireHeavy(); @@ -201,6 +219,21 @@ test("a conservative token estimate classifies string messages and tool schemas if (result.admit) result.lease?.release(); }); +test("Responses string input contributes to the conservative token estimate", async () => { + const controller = new ChatAdmissionController(1); + const result = await admitChatStructure({ messages: [], input: "abcdefgh" }, null, { + controller, + maxMessages: 10, + heavyMessages: 10, + heavyTools: 10, + heavyTokens: 2, + }); + + assert.equal(result.admit, true); + assert.equal(controller.activeHeavy, 1); + if (result.admit) result.lease?.release(); +}); + test("exhausting the bounded structural inspection is conservatively heavyweight", async () => { const controller = new ChatAdmissionController(1); const result = await admitChatStructure( diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index 6b85e58291..dc4990d7f3 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -308,7 +308,18 @@ test("handleNoCredentials reports missing provider credentials and exhausted acc // open-sse/services/accountFallback.ts:1593-1599) so the next combo target is // tried. We surface "no active credentials" as 404 so combo can skip past a // disabled-credentials provider instead of failing the whole request. - const missing = handleNoCredentials(null, null, "openai", "gpt-4o-mini", null, null); + // In combo routing the no-credentials branch must stay 404 NOT_FOUND so the + // combo target loop can fall through to the next target. Pass isCombo=true. + const missing = handleNoCredentials( + null, + null, + "openai", + "gpt-4o-mini", + null, + null, + undefined, + true + ); const exhausted = handleNoCredentials( null, "conn_123", @@ -327,6 +338,65 @@ test("handleNoCredentials reports missing provider credentials and exhausted acc assert.match(exhaustedJson.error.message, /Primary account failed/); }); +test("handleNoCredentials remaps leaked 404 to 401/503 for single-model requests", async () => { + // Issue #2: a direct (non-combo) API client must not receive a misleading 404 + // "No active credentials" error — remap to an explicit auth/credential status. + const forKnownProvider = handleNoCredentials( + null, + null, + "byNara", + "claude-sonnet-4.6", + null, + null, + undefined, + /* isCombo */ false + ); + assert.equal(forKnownProvider.status, 401); + const knownJson = (await forKnownProvider.json()) as { error?: { message?: string } }; + assert.match(knownJson.error?.message ?? "", /No active credentials for provider: byNara/); + + const forUnknownProvider = handleNoCredentials( + null, + null, + "", + "gpt-4o-mini", + null, + null, + undefined, + /* isCombo */ false + ); + assert.equal(forUnknownProvider.status, 503); +}); + +test("handleNoCredentials still leaks 404 (combo fall-through) only when combo", async () => { + // Regression guard: the 404 is intentionally preserved for combo routing so it + // can skip a disabled-credentials leg. Explicitly assert isCombo=true keeps 404 + // and isCombo=false does not. (Issue #2) + const combo = handleNoCredentials( + null, + null, + "kiro", + "claude-opus-5", + null, + null, + undefined, + true + ); + assert.equal(combo.status, 404); + + const single = handleNoCredentials( + null, + null, + "byNara", + "claude-opus-5", + null, + null, + undefined, + false + ); + assert.notEqual(single.status, 404); +}); + test("handleNoCredentials returns Retry-After when every account is rate limited", async () => { const retryAfter = new Date(Date.now() + 45_000).toISOString(); const response = handleNoCredentials( @@ -506,7 +576,7 @@ test("executeChatWithBreaker preserves account TLS scope when a proxy bypasses t ], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), - { headers: { "content-type": "application/json" } }, + { headers: { "content-type": "application/json" } } ); }, }); diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index e0b71badc9..ff82a1caa2 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -316,6 +316,87 @@ test("handleChat keeps protected combo fallback separate from Global Fallback Mo assert.equal(json.choices[0].message.content, "Global fallback answered"); }); +test("handleChat defaults a Combo's incompatible reasoning fallback to drop", async () => { + await seedConnection("deepseek", { apiKey: "sk-deepseek-reasoning-drop" }); + await combosDb.createCombo({ + name: "reasoning-transport-drop", + strategy: "priority", + config: { + maxRetries: 0, + retryDelayMs: 0, + }, + models: ["deepseek/deepseek-v4-flash"], + }); + + let upstreamBody: { input?: unknown } | null = null; + globalThis.fetch = async (_url, init = {}) => { + upstreamBody = JSON.parse(String(init.body)); + return new Response( + JSON.stringify({ + id: "resp_reasoning_drop", + object: "response", + status: "completed", + model: "deepseek-v4-flash", + output: [ + { + id: "msg_reasoning_drop", + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: "continued without prior reasoning", + annotations: [], + }, + ], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }; + + const response = await handleChat( + buildRequest({ + url: "http://localhost/v1/responses", + body: { + model: "reasoning-transport-drop", + stream: false, + input: [ + { id: "rs_opaque", type: "reasoning", encrypted_content: "provider-state" }, + { + id: "fc_call", + type: "function_call", + call_id: "call_1", + name: "search", + arguments: "{}", + }, + { type: "function_call_output", call_id: "call_1", output: "done" }, + ], + }, + }) + ); + + assert.equal(response.status, 200); + assert.ok(upstreamBody && Array.isArray(upstreamBody.input)); + const upstreamInput = upstreamBody.input; + assert.equal( + upstreamInput.some( + (item) => + item !== null && typeof item === "object" && "type" in item && item.type === "reasoning" + ), + false + ); + assert.equal( + upstreamInput.some( + (item) => + item !== null && typeof item === "object" && "type" in item && item.type === "function_call" + ), + true + ); +}); + test("handleChat keeps the combo error when the global fallback throws", async () => { await seedConnection("openai", { apiKey: "sk-openai-combo-fail" }); await seedConnection("claude", { apiKey: "sk-claude-fallback-throw" }); @@ -357,11 +438,13 @@ test("handleChat keeps the combo error when the global fallback throws", async ( assert.match(json.error.message, /primary combo failed/i); }); -test("handleChat returns 404 when no provider credentials exist", async () => { +test("handleChat returns 401 when no provider credentials exist (single-model)", async () => { // Upstream port decolua/9router#336 (Ibrahim Ryan): the no-credentials branch - // of handleNoCredentials now surfaces 404 NOT_FOUND so combo routing can fall - // through to the next target instead of being killed by the combo 400-hard-stop - // guard (open-sse/services/combo.ts, PR #4316 / issue #4279). + // of handleNoCredentials originally surfaced 404 NOT_FOUND unconditionally so + // combo routing could fall through to the next target (open-sse/services/combo.ts, + // PR #4316 / issue #4279). #10797 remaps that 404 to 401 for single-model + // (non-combo) requests — a direct client should see an auth/credential failure, + // not "not found"; combo routing still gets the 404 (see combo-routing-e2e.test.ts). const response = await handleChat( buildRequest({ body: { @@ -373,7 +456,7 @@ test("handleChat returns 404 when no provider credentials exist", async () => { ); const json = (await response.json()) as any; - assert.equal(response.status, 404); + assert.equal(response.status, 401); assert.match(json.error.message, /No active credentials for provider: openai/); }); diff --git a/tests/unit/chatcore-codex-account-pool.test.ts b/tests/unit/chatcore-codex-account-pool.test.ts new file mode 100644 index 0000000000..a828d7357c --- /dev/null +++ b/tests/unit/chatcore-codex-account-pool.test.ts @@ -0,0 +1,315 @@ +// @ts-nocheck +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-chatcore-codex-pool-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); + +const originalFetch = globalThis.fetch; + +function noopLog() { + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + }; +} + +function toPlainHeaders(headers) { + if (!headers) return {}; + if (headers instanceof Headers) return Object.fromEntries(headers.entries()); + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)]) + ); +} + +function buildResponsesResponse(text = "ok") { + return new Response( + JSON.stringify({ + id: "resp_123", + object: "response", + status: "completed", + model: "gpt-5.1-codex", + output: [ + { + id: "msg_123", + type: "message", + role: "assistant", + content: [{ type: "output_text", text, annotations: [] }], + }, + ], + usage: { + input_tokens: 4, + output_tokens: 2, + total_tokens: 6, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); +} + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function waitForAsyncSideEffects() { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setTimeout(resolve, 10)); +} + +async function invokeChatCore({ + body, + provider = "codex", + model, + endpoint = "/v1/responses", + credentials, + responseFactory, + connectionId = null, + isCombo = false, +}: { + body: unknown; + provider?: string; + model: string; + endpoint?: string; + credentials: Record; + responseFactory: (captured: unknown, calls: unknown[]) => Response; + connectionId?: string | null; + isCombo?: boolean; +}) { + const calls: unknown[] = []; + globalThis.fetch = async (url, init = {}) => { + const headers = toPlainHeaders(init.headers); + const captured = { + url: String(url), + method: init.method || "GET", + headers, + body: init.body ? JSON.parse(String(init.body)) : null, + }; + calls.push(captured); + return responseFactory(captured, calls); + }; + + try { + const result = await handleChatCore({ + body: structuredClone(body), + modelInfo: { provider, model, extendedContext: false }, + credentials, + log: noopLog(), + clientRawRequest: { + endpoint, + body: structuredClone(body), + headers: new Headers({ accept: "application/json" }), + }, + connectionId, + userAgent: "unit-test", + isCombo, + }); + await waitForAsyncSideEffects(); + return { calls, result }; + } finally { + globalThis.fetch = originalFetch; + } +} + +test.afterEach(async () => { + globalThis.fetch = originalFetch; + await waitForAsyncSideEffects(); + await resetStorage(); +}); + +test.after(async () => { + globalThis.fetch = originalFetch; + await waitForAsyncSideEffects(); + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("chatCore persists child cooldown for each rotated Codex attempt", async () => { + const first = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "codex-rotation-first@example.com", + accessToken: "codex-rotation-first", + isActive: true, + providerSpecificData: {}, + }); + const second = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "codex-rotation-second@example.com", + accessToken: "codex-rotation-second", + isActive: true, + providerSpecificData: {}, + }); + const liveCredentials = { + accessToken: "codex-rotation-first", + connectionId: first.id, + providerSpecificData: {}, + }; + + const { result } = await invokeChatCore({ + provider: "codex", + model: "gpt-5.3-codex-spark", + endpoint: "/v1/responses", + connectionId: first.id, + credentials: liveCredentials, + body: { + model: "gpt-5.3-codex-spark", + input: "rotate twice", + stream: false, + }, + responseFactory() { + return new Response( + JSON.stringify({ error: { message: "The usage limit has been reached" } }), + { status: 429, headers: { "Content-Type": "application/json", "Retry-After": "60" } } + ); + }, + }); + const firstPersisted = await providersDb.getProviderConnectionById(first.id); + const secondPersisted = await providersDb.getProviderConnectionById(second.id); + + assert.equal(result.success, false); + assert.equal(result.status, 429); + assert.equal( + typeof firstPersisted.providerSpecificData.codexScopeRateLimitedUntil.spark, + "string" + ); + assert.equal( + typeof secondPersisted.providerSpecificData.codexScopeRateLimitedUntil.spark, + "string" + ); + assert.equal(liveCredentials.connectionId, second.id); +}); + +test("chatCore retains exact quota resets from intermediate rotated Codex 429s", async () => { + const first = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "codex-exact-reset-first@example.com", + accessToken: "codex-exact-reset-first", + isActive: true, + providerSpecificData: {}, + }); + await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "codex-exact-reset-second@example.com", + accessToken: "codex-exact-reset-second", + isActive: true, + providerSpecificData: {}, + }); + const exactReset = new Date(Date.now() + 300_000).toISOString(); + const weeklyReset = new Date(Date.now() + 3_600_000).toISOString(); + const { result } = await invokeChatCore({ + provider: "codex", + model: "gpt-5.3-codex-spark", + endpoint: "/v1/responses", + connectionId: first.id, + isCombo: true, + credentials: { + accessToken: "codex-exact-reset-first", + connectionId: first.id, + providerSpecificData: {}, + }, + body: { + model: "gpt-5.3-codex-spark", + input: "persist exact reset before rotation", + stream: false, + }, + responseFactory(_captured: unknown, calls: unknown[]) { + if (calls.length < 4) { + return new Response(JSON.stringify({ error: { message: "Codex quota exceeded" } }), { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": "60", + "x-codex-5h-usage": "100", + "x-codex-5h-limit": "100", + "x-codex-5h-reset-at": exactReset, + "x-codex-7d-usage": "10", + "x-codex-7d-limit": "100", + "x-codex-7d-reset-at": weeklyReset, + }, + }); + } + return buildResponsesResponse("rotated account succeeded"); + }, + }); + const persisted = await providersDb.getProviderConnectionById(first.id); + + assert.ok(persisted); + assert.equal(result.success, true); + assert.equal(persisted.providerSpecificData.codexScopeRateLimitedUntil.spark, exactReset); + assert.equal(persisted.providerSpecificData.codexExhaustedWindowByScope.spark, "5h"); + assert.equal(persisted.providerSpecificData.codexQuotaStateByScope.spark.resetAt5h, exactReset); +}); + +test("chatCore keeps a Codex Spark 429 scoped so Sol remains selectable", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "codex-scope@example.com", + accessToken: "codex-scope-token", + isActive: true, + providerSpecificData: {}, + }); + + const { result } = await invokeChatCore({ + provider: "codex", + model: "gpt-5.3-codex-spark", + endpoint: "/v1/responses", + connectionId: connection.id, + credentials: { + accessToken: "codex-scope-token", + connectionId: connection.id, + providerSpecificData: {}, + }, + body: { + model: "gpt-5.3-codex-spark", + input: "scope this cooldown", + stream: false, + }, + responseFactory() { + return new Response( + JSON.stringify({ error: { message: "The usage limit has been reached" } }), + { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": "60", + }, + } + ); + }, + }); + + const updated = await providersDb.getProviderConnectionById(connection.id); + const sparkSelected = await auth.getProviderCredentials( + "codex", + null, + null, + "gpt-5.3-codex-spark" + ); + const solSelected = await auth.getProviderCredentials("codex", null, null, "gpt-5.6-sol"); + + assert.equal(result.success, false); + assert.equal(result.status, 429); + assert.equal(updated.rateLimitedUntil, undefined); + assert.equal(typeof updated.providerSpecificData.codexScopeRateLimitedUntil.spark, "string"); + assert.equal(sparkSelected.allRateLimited, true); + assert.equal(solSelected.connectionId, connection.id); +}); diff --git a/tests/unit/chatcore-codex-quota.test.ts b/tests/unit/chatcore-codex-quota.test.ts deleted file mode 100644 index 04582356b4..0000000000 --- a/tests/unit/chatcore-codex-quota.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -// tests/unit/chatcore-codex-quota.test.ts -// Characterization of buildCodexQuotaPersistence — the pure core of handleChatCore's -// persistCodexQuotaState, extracted during the chatCore god-file decomposition (#3501). Locks the -// shape of the persisted providerSpecificData: the codexQuotaState snapshot, the existing-data -// passthrough, and the 429 dual-window exhaustion fields (codexScopeRateLimitedUntil / -// codexExhaustedWindow) plus the debug-log message. The handler keeps the DB write, the -// preflight-cache invalidation, and the log emission; this function only builds the data. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildCodexQuotaPersistence } from "../../open-sse/handlers/chatCore/codexQuota.ts"; -import { getCodexModelScope } from "../../open-sse/executors/codex.ts"; - -const MODEL = "gpt-5-codex"; -const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - -function quotaHeaders(over: Record = {}) { - return { - "x-codex-5h-usage": "50", - "x-codex-5h-limit": "100", - "x-codex-5h-reset-at": "2999-01-01T00:00:00.000Z", - "x-codex-7d-usage": "10", - "x-codex-7d-limit": "100", - "x-codex-7d-reset-at": "2999-01-08T00:00:00.000Z", - ...over, - }; -} - -test("returns null when the response carries no codex quota headers", () => { - assert.equal( - buildCodexQuotaPersistence({ headers: {}, existingProviderData: {}, modelForScope: MODEL, status: 200 }), - null - ); - assert.equal( - buildCodexQuotaPersistence({ headers: { "content-type": "application/json" }, existingProviderData: {}, modelForScope: MODEL, status: 200 }), - null - ); -}); - -test("builds codexQuotaState (parsed numbers + scope + updatedAt) and preserves existing provider data", () => { - const built = buildCodexQuotaPersistence({ - headers: quotaHeaders(), - existingProviderData: { keepMe: "yes", apiKeyHealth: { primary: {} } }, - modelForScope: MODEL, - status: 200, - }); - assert.ok(built); - const qs = built.nextProviderData.codexQuotaState as Record; - assert.equal(qs.usage5h, 50); - assert.equal(qs.limit5h, 100); - assert.equal(qs.usage7d, 10); - assert.equal(qs.limit7d, 100); - assert.equal(qs.scope, getCodexModelScope(MODEL)); - assert.match(String(qs.updatedAt), ISO); - // existing keys passed through, not dropped - assert.equal(built.nextProviderData.keepMe, "yes"); - assert.deepEqual(built.nextProviderData.apiKeyHealth, { primary: {} }); - // non-429 → no exhaustion fields, no log - assert.equal(built.exhaustionLog, null); - assert.equal(built.nextProviderData.codexScopeRateLimitedUntil, undefined); - assert.equal(built.nextProviderData.codexExhaustedWindow, undefined); -}); - -test("429 with a near-exhausted 5h window records the per-scope cooldown + window + log", () => { - const built = buildCodexQuotaPersistence({ - headers: quotaHeaders({ "x-codex-5h-usage": "100" }), // ratio 1.0 >= 0.95, reset far in the future - existingProviderData: {}, - modelForScope: MODEL, - status: 429, - }); - assert.ok(built); - assert.equal(built.nextProviderData.codexExhaustedWindow, "5h"); - const scope = getCodexModelScope(MODEL); - const scopeMap = built.nextProviderData.codexScopeRateLimitedUntil as Record; - assert.ok(scopeMap[scope]?.startsWith("2999-01-01T00:00:00")); - assert.match( - String(built.exhaustionLog), - /^Quota exhaustion on 5h window, cooldown until 2999-01-01T00:00:00/ - ); -}); - -test("429 merges into an existing codexScopeRateLimitedUntil map without dropping other scopes", () => { - const built = buildCodexQuotaPersistence({ - headers: quotaHeaders({ "x-codex-5h-usage": "100" }), - existingProviderData: { codexScopeRateLimitedUntil: { "other-scope": "2999-12-31T00:00:00.000Z" } }, - modelForScope: MODEL, - status: 429, - }); - assert.ok(built); - const scopeMap = built.nextProviderData.codexScopeRateLimitedUntil as Record; - assert.equal(scopeMap["other-scope"], "2999-12-31T00:00:00.000Z"); - assert.ok(scopeMap[getCodexModelScope(MODEL)]); -}); - -test("429 below the exhaustion threshold builds the snapshot but no cooldown / no log", () => { - const built = buildCodexQuotaPersistence({ - headers: quotaHeaders({ "x-codex-5h-usage": "1", "x-codex-7d-usage": "1" }), // ratios well under 0.95 - existingProviderData: {}, - modelForScope: MODEL, - status: 429, - }); - assert.ok(built); - assert.ok(built.nextProviderData.codexQuotaState); - assert.equal(built.exhaustionLog, null); - assert.equal(built.nextProviderData.codexScopeRateLimitedUntil, undefined); - assert.equal(built.nextProviderData.codexExhaustedWindow, undefined); -}); diff --git a/tests/unit/chatcore-memory-skills-injection.test.ts b/tests/unit/chatcore-memory-skills-injection.test.ts index b72ce2534b..5ecf886dc8 100644 --- a/tests/unit/chatcore-memory-skills-injection.test.ts +++ b/tests/unit/chatcore-memory-skills-injection.test.ts @@ -126,3 +126,119 @@ test("injectMemoryAndSkills resolves cleanly for a CLAUDE-format body with no ow assert.equal(result.memorySettings, null); assert.equal(result.body, body); }); + +test("injectMemoryAndSkills injects memory tools when memory is enabled", async () => { + const { updateSettings } = await import("../../src/lib/db/settings.ts"); + const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts"); + const { MEMORY_BUILTIN_TOOL_NAMES } = await import("../../src/lib/skills/memoryBuiltins.ts"); + + await updateSettings({ memoryEnabled: true, memoryMaxTokens: 2000 }); + invalidateMemorySettingsCache(); + + const body: Record = { + model: "gpt-4o", + messages: [{ role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "some_client_tool", description: "x" } }], + }; + + const result = await injectMemoryAndSkills({ + body, + memoryOwnerId: "owner-mem-on", + provider: "openai", + effectiveModel: "gpt-4o", + sourceFormat: FORMATS.OPENAI, + targetFormat: FORMATS.OPENAI, + backgroundReason: null, + log: { debug: () => {} }, + }); + + assert.equal(result.memorySettings?.enabled, true); + const toolNames = (result.body.tools as { function?: { name?: string }; name?: string }[]).map( + (tool) => tool.function?.name ?? tool.name + ); + for (const memoryTool of MEMORY_BUILTIN_TOOL_NAMES) { + assert.ok( + toolNames.includes(memoryTool), + `expected ${memoryTool} to be injected into body.tools` + ); + } + assert.ok(toolNames.includes("some_client_tool"), "client tools are preserved"); + + invalidateMemorySettingsCache(); +}); + +test("injectMemoryAndSkills does not inject server memory tools for stream requests", async () => { + const { updateSettings } = await import("../../src/lib/db/settings.ts"); + const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts"); + const { MEMORY_BUILTIN_TOOL_NAMES } = await import("../../src/lib/skills/memoryBuiltins.ts"); + + await updateSettings({ memoryEnabled: true, memoryMaxTokens: 2000 }); + invalidateMemorySettingsCache(); + + const body: Record = { + model: "gpt-4o", + stream: true, + messages: [{ role: "user", content: "hello" }], + }; + + const result = await injectMemoryAndSkills({ + body, + memoryOwnerId: "owner-stream", + provider: "openai", + effectiveModel: "gpt-4o", + sourceFormat: FORMATS.OPENAI, + targetFormat: FORMATS.OPENAI, + backgroundReason: null, + log: { debug: () => {} }, + }); + + assert.equal(result.memorySettings?.enabled, true); + const tools = (result.body.tools as { function?: { name?: string }; name?: string }[] | undefined) ?? []; + const toolNames = tools.map((tool) => tool.function?.name ?? tool.name); + for (const memoryTool of MEMORY_BUILTIN_TOOL_NAMES) { + assert.equal( + toolNames.includes(memoryTool), + false, + `expected ${memoryTool} to be absent for stream requests (client-side MCP path)` + ); + } + + invalidateMemorySettingsCache(); +}); + +test("injectMemoryAndSkills does not inject memory tools when memory is disabled", async () => { + const { updateSettings } = await import("../../src/lib/db/settings.ts"); + const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts"); + const { MEMORY_BUILTIN_TOOL_NAMES } = await import("../../src/lib/skills/memoryBuiltins.ts"); + + await updateSettings({ memoryEnabled: false }); + invalidateMemorySettingsCache(); + + const body: Record = { + model: "gpt-4o", + messages: [{ role: "user", content: "hello" }], + }; + + const result = await injectMemoryAndSkills({ + body, + memoryOwnerId: "owner-mem-off", + provider: "openai", + effectiveModel: "gpt-4o", + sourceFormat: FORMATS.OPENAI, + targetFormat: FORMATS.OPENAI, + backgroundReason: null, + log: { debug: () => {} }, + }); + + const tools = (result.body.tools as { function?: { name?: string }; name?: string }[] | undefined) ?? []; + const toolNames = tools.map((tool) => tool.function?.name ?? tool.name); + for (const memoryTool of MEMORY_BUILTIN_TOOL_NAMES) { + assert.equal( + toolNames.includes(memoryTool), + false, + `expected ${memoryTool} to be absent when memory is disabled` + ); + } + + invalidateMemorySettingsCache(); +}); diff --git a/tests/unit/chatcore-model-output-cap-wiring.test.ts b/tests/unit/chatcore-model-output-cap-wiring.test.ts index 0331aeda67..9cc0042a3e 100644 --- a/tests/unit/chatcore-model-output-cap-wiring.test.ts +++ b/tests/unit/chatcore-model-output-cap-wiring.test.ts @@ -12,6 +12,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const overridesDb = await import("../../src/lib/db/modelCapabilityOverrides.ts"); +const featureFlagsDb = await import("../../src/lib/db/featureFlags.ts"); const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); const PROVIDER = "capwire-testprov"; @@ -77,6 +78,7 @@ test.before(() => { test.after(() => { globalThis.fetch = originalFetch; + featureFlagsDb.removeFeatureFlagOverride("DISABLE_CONTEXT_WINDOW_CHECKS"); core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); @@ -114,3 +116,31 @@ test("handleChatCore dispatches input within the model input cap", async () => { assert.equal(fetchCalls, 1); assert.ok(dispatchedBody, "input below the cap must reach the upstream"); }); + +test("DISABLE_CONTEXT_WINDOW_CHECKS lets direct-model input exceed the declared input cap", async () => { + dispatchedBody = null; + fetchCalls = 0; + featureFlagsDb.setFeatureFlagOverride("DISABLE_CONTEXT_WINDOW_CHECKS", "true"); + try { + const result = await handleChatCore(buildRequest(1, "x".repeat(200))); + assert.equal(result.success, true); + assert.equal(fetchCalls, 1, "disabled context checks must let the upstream decide"); + assert.ok(dispatchedBody, "oversized input must reach the upstream when the flag is enabled"); + } finally { + featureFlagsDb.removeFeatureFlagOverride("DISABLE_CONTEXT_WINDOW_CHECKS"); + } +}); + +test("DISABLE_CONTEXT_WINDOW_CHECKS keeps the direct model output cap active", async () => { + dispatchedBody = null; + fetchCalls = 0; + featureFlagsDb.setFeatureFlagOverride("DISABLE_CONTEXT_WINDOW_CHECKS", "true"); + try { + const result = await handleChatCore(buildRequest(REQUESTED_MAX_TOKENS, "x".repeat(200))); + assert.equal(result.success, true); + assert.equal(fetchCalls, 1); + assert.equal(dispatchedBody?.max_tokens, OUTPUT_CAP); + } finally { + featureFlagsDb.removeFeatureFlagOverride("DISABLE_CONTEXT_WINDOW_CHECKS"); + } +}); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 482f273332..08cc564cc4 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -36,7 +36,8 @@ const { setBackgroundDegradationConfig, resetStats: resetBackgroundStats, } = await import("../../open-sse/services/backgroundTaskDetector.ts"); -const { getCallLogs, getCallLogById } = await import("../../src/lib/usage/callLogs.ts"); +const { getCallLogs, getCallLogById, waitForCallLogSaves } = + await import("../../src/lib/usage/callLogs.ts"); const { handleChatCore, shouldUseNativeCodexPassthrough, @@ -211,6 +212,64 @@ function buildResponsesResponse(text = "ok") { ); } +function buildDeepSeekResponsesToolResponse({ + stream, + callId, + reasoning, +}: { + stream: boolean; + callId: string; + reasoning: string; +}) { + const reasoningItem = { + id: "rs_deepseek_tool", + type: "reasoning", + status: "completed", + summary: [], + content: [{ type: "reasoning_text", text: reasoning }], + }; + const functionCall = { + id: "fc_deepseek_tool", + type: "function_call", + status: "completed", + call_id: callId, + name: "inspect", + arguments: "{}", + }; + const response = { + id: "resp_deepseek_tool", + object: "response", + status: "completed", + model: "deepseek-v4-flash", + output: [reasoningItem, functionCall], + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }; + + if (!stream) { + return new Response(JSON.stringify(response), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const events = [ + { + type: "response.created", + response: { id: response.id, model: response.model, status: "in_progress" }, + }, + { type: "response.output_item.done", output_index: 0, item: reasoningItem }, + { type: "response.output_item.done", output_index: 1, item: functionCall }, + { type: "response.completed", response }, + ]; + return new Response( + `${events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}`).join("\n\n")}\n\ndata: [DONE]\n\n`, + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + } + ); +} + function capabilityEntry(limitContext) { return { tool_call: true, @@ -286,6 +345,7 @@ async function flushAsyncSideEffects() { } async function getLatestCallLog() { + await waitForCallLogSaves(5000); const rows = await getCallLogs({ limit: 5 }); if (!Array.isArray(rows) || rows.length === 0) return null; return getCallLogById(rows[0].id); @@ -309,6 +369,7 @@ async function invokeChatCore({ onCredentialsRefreshed = null, onRequestSuccess = null, sessionAffinityKey = null, + reasoningTransportFallback = "skip", managedLease = null, cachedSettings = null, }: any = {}) { @@ -357,6 +418,7 @@ async function invokeChatCore({ sessionAffinityKey, isCombo, comboStrategy, + reasoningTransportFallback, managedLease, cachedSettings, onCredentialsRefreshed, @@ -569,7 +631,7 @@ test("chatCore translates a streaming Responses upstream for a Chat client", asy assert.match(streamed, /"content":"ok"/); assert.match(streamed, /data: \[DONE\]/); }); -test("chatCore applies Responses input policy to openai-compatible targets", async () => { +test("chatCore rejects opaque reasoning for unknown Responses targets unless explicitly enabled", async () => { const reasoningItems = [ { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }, { type: "reasoning", encrypted_content: "" }, @@ -578,38 +640,300 @@ test("chatCore applies Responses input policy to openai-compatible targets", asy { id: "fc_call", type: "function_call", call_id: "call_1", name: "search", arguments: "{}" }, ]; - for (const preserveEncryptedReasoning of [false, true]) { - const { call, result } = await invokeChatCore({ + const rejected = await invokeChatCore({ + provider: "openai-compatible-sp-openai", + model: "gpt-5.4", + endpoint: "/v1/responses", + credentials: { + apiKey: "sk-test", + providerSpecificData: { + apiType: "responses", + baseUrl: "https://proxy.example.com/v1", + prefix: "sp-openai", + }, + }, + body: { model: "gpt-5.4", stream: false, input: reasoningItems }, + responseFormat: "openai-responses", + }); + + assert.equal(rejected.result.success, false); + assert.equal(rejected.result.status, 400); + assert.equal(rejected.calls.length, 0); + + const enabled = await invokeChatCore({ + provider: "openai-compatible-sp-openai", + model: "gpt-5.4", + endpoint: "/v1/responses", + credentials: { + apiKey: "sk-test", + providerSpecificData: { + apiType: "responses", + baseUrl: "https://proxy.example.com/v1", + prefix: "sp-openai", + preserveEncryptedReasoning: true, + }, + }, + body: { model: "gpt-5.4", stream: false, input: reasoningItems }, + responseFormat: "openai-responses", + }); + + assert.equal(enabled.result.success, true); + const input = enabled.call.body.input as Array>; + assert.deepEqual( + input.filter((item) => item.type === "reasoning"), + [ + { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }, + { type: "reasoning", summary: [{ text: "not self-contained" }] }, + ] + ); + assert.equal( + input.some((item) => item.type === "item_reference"), + false + ); + assert.equal(input.find((item) => item.type === "function_call")?.id, undefined); +}); + +test("chatCore applies Chat reasoning compatibility before stream mode diverges", async () => { + for (const stream of [false, true]) { + const rejected = await invokeChatCore({ provider: "openai-compatible-sp-openai", model: "gpt-5.4", - endpoint: "/v1/responses", + endpoint: "/v1/chat/completions", credentials: { apiKey: "sk-test", providerSpecificData: { - apiType: "responses", + apiType: "openai", baseUrl: "https://proxy.example.com/v1", prefix: "sp-openai", - preserveEncryptedReasoning, }, }, - body: { model: "gpt-5.4", stream: false, input: reasoningItems }, - responseFormat: "openai-responses", + body: { + model: "gpt-5.4", + stream, + messages: [ + { + role: "assistant", + content: null, + reasoning_details: [{ type: "reasoning.encrypted", data: "provider-state" }], + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "search", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "result" }, + ], + }, }); - assert.equal(result.success, true); - const input = call.body.input as Array>; - assert.deepEqual( - input.filter((item) => item.type === "reasoning"), - preserveEncryptedReasoning ? [{ type: "reasoning", encrypted_content: "encrypted-blob" }] : [] - ); - assert.equal( - input.some((item) => item.type === "item_reference"), - false - ); - assert.equal(input.find((item) => item.type === "function_call")?.id, undefined); + assert.equal(rejected.result.success, false, `stream=${stream}`); + assert.equal(rejected.result.status, 400, `stream=${stream}`); + assert.equal(rejected.calls.length, 0, `stream=${stream}`); } }); +test("chatCore can drop incompatible reasoning for an opted-in Combo attempt", async () => { + const dropped = await invokeChatCore({ + provider: "openai-compatible-sp-openai", + model: "gpt-5.4", + endpoint: "/v1/responses", + credentials: { + apiKey: "sk-test", + providerSpecificData: { + apiType: "responses", + baseUrl: "https://proxy.example.com/v1", + prefix: "sp-openai", + }, + }, + body: { + model: "gpt-5.4", + stream: false, + input: [ + { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }, + { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, + ], + }, + responseFormat: "openai-responses", + isCombo: true, + reasoningTransportFallback: "drop", + }); + + assert.equal(dropped.result.success, true); + assert.equal(dropped.calls.length, 1); + assert.equal( + dropped.call.body.input.some((item) => item.type === "reasoning"), + false + ); +}); + +test("chatCore carries Chat reasoning_content into official DeepSeek Responses input", async () => { + const { call, result } = await invokeChatCore({ + provider: "deepseek", + model: "deepseek-v4-pro", + endpoint: "/v1/chat/completions", + body: { + model: "deepseek-v4-pro", + stream: false, + messages: [ + { + role: "assistant", + content: null, + reasoning_content: "Inspect before calling the tool", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "search", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "found" }, + ], + }, + responseFormat: "openai-responses", + }); + + assert.equal(result.success, true); + assert.match(call.url, /\/responses$/); + assert.deepEqual(call.body.input.slice(0, 3), [ + { + type: "reasoning", + content: [{ type: "reasoning_text", text: "Inspect before calling the tool" }], + }, + { + type: "function_call", + call_id: "call_1", + name: "search", + arguments: "{}", + status: "completed", + }, + { type: "function_call_output", call_id: "call_1", output: "found", status: "completed" }, + ]); +}); + +test("chatCore replays nonstream DeepSeek Responses reasoning across a Chat tool turn", async () => { + const callId = "call_deepseek_nonstream_replay"; + const reasoning = "Authentic nonstream DeepSeek reasoning"; + const apiKeyInfo = { id: "deepseek-nonstream-chat-key" }; + const first = await invokeChatCore({ + provider: "deepseek", + model: "deepseek-v4-flash", + endpoint: "/v1/chat/completions", + body: { + model: "deepseek-v4-flash", + stream: false, + reasoning_effort: "high", + messages: [{ role: "user", content: "Inspect the repository" }], + tools: [ + { + type: "function", + function: { name: "inspect", description: "Inspect", parameters: { type: "object" } }, + }, + ], + }, + apiKeyInfo, + responseFactory: () => buildDeepSeekResponsesToolResponse({ stream: false, callId, reasoning }), + }); + + assert.equal(first.result.success, true); + const firstPayload = (await first.result.response.json()) as { + choices: Array<{ message: Record & { reasoning_content?: string } }>; + }; + assert.equal(firstPayload.choices[0].message.reasoning_content, reasoning); + const assistant = structuredClone(firstPayload.choices[0].message); + delete assistant.reasoning_content; + + const second = await invokeChatCore({ + provider: "deepseek", + model: "deepseek-v4-flash", + endpoint: "/v1/chat/completions", + body: { + model: "deepseek-v4-flash", + stream: false, + reasoning_effort: "high", + messages: [ + { role: "user", content: "Inspect the repository" }, + assistant, + { role: "tool", tool_call_id: callId, content: "inspection complete" }, + ], + }, + apiKeyInfo, + responseFactory: () => buildResponsesResponse("done"), + }); + + assert.equal(second.result.success, true); + assert.deepEqual( + second.call.body.input.find((item) => item.type === "reasoning"), + { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }] } + ); +}); + +test("chatCore replays streamed DeepSeek Responses reasoning across a Chat tool turn", async () => { + const callId = "call_deepseek_stream_replay"; + const reasoning = "Authentic streamed DeepSeek reasoning"; + const apiKeyInfo = { id: "deepseek-stream-chat-key" }; + const first = await invokeChatCore({ + provider: "deepseek", + model: "deepseek-v4-flash", + endpoint: "/v1/chat/completions", + body: { + model: "deepseek-v4-flash", + stream: true, + reasoning_effort: "high", + messages: [{ role: "user", content: "Inspect the repository" }], + tools: [ + { + type: "function", + function: { name: "inspect", description: "Inspect", parameters: { type: "object" } }, + }, + ], + }, + apiKeyInfo, + responseFactory: () => buildDeepSeekResponsesToolResponse({ stream: true, callId, reasoning }), + }); + + assert.equal(first.result.success, true); + const streamed = await first.result.response.text(); + assert.match(streamed, new RegExp(reasoning)); + await flushAsyncSideEffects(); + + const second = await invokeChatCore({ + provider: "deepseek", + model: "deepseek-v4-flash", + endpoint: "/v1/chat/completions", + body: { + model: "deepseek-v4-flash", + stream: false, + reasoning_effort: "high", + messages: [ + { role: "user", content: "Inspect the repository" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: callId, + type: "function", + function: { name: "inspect", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: callId, content: "inspection complete" }, + ], + }, + apiKeyInfo, + responseFactory: () => buildResponsesResponse("done"), + }); + + assert.equal(second.result.success, true); + assert.deepEqual( + second.call.body.input.find((item) => item.type === "reasoning"), + { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }] } + ); +}); + test("chatCore replays no-tool reasoning across public Responses turns", async () => { // Direct DeepSeek now speaks Responses upstream. Keep this regression on a // Chat-compatible DeepSeek host so it continues to exercise the Responses-to-Chat replay path. @@ -783,14 +1107,14 @@ test("chatCore captures streaming no-tool reasoning for Responses replay", async assert.equal(second.result.success, true); assert.equal(second.call.body.messages[1].reasoning_content, "Authentic streaming reasoning"); }); -test("chatCore preserves opted-in encrypted reasoning for Codex", async () => { +test("chatCore automatically preserves provider-generated opaque reasoning for Codex", async () => { const { call, result } = await invokeChatCore({ provider: "codex", model: "gpt-5.1-codex", endpoint: "/v1/responses", credentials: { accessToken: "codex-token", - providerSpecificData: { preserveEncryptedReasoning: true }, + providerSpecificData: {}, }, body: { model: "gpt-5.1-codex", @@ -808,7 +1132,7 @@ test("chatCore preserves opted-in encrypted reasoning for Codex", async () => { assert.equal(result.success, true); assert.deepEqual( call.body.input.filter((item) => item.type === "reasoning"), - [{ type: "reasoning", encrypted_content: "encrypted-blob" }] + [{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }] ); assert.equal( call.body.input.some((item) => item.type === "item_reference"), @@ -1083,13 +1407,16 @@ test("chatCore preserves Opus 5 mid-conversation system cache breakpoints", asyn ); assert.deepEqual(call.body.messages[2].content[0].cache_control, { type: "ephemeral", - ttl: "1h", + ttl: "5m", }); assert.equal( call.body.system.some((block: { text?: string }) => block.text === "compact continuation"), false ); - assert.equal(call.body.messages[3].content[0].cache_control, undefined); + assert.deepEqual(call.body.messages[3].content[0].cache_control, { + type: "ephemeral", + ttl: "5m", + }); }); test("chatCore keeps Claude normalization for non-Claude-Code Claude passthrough", async () => { const { call, result } = await invokeChatCore({ @@ -1262,12 +1589,12 @@ test("chatCore preserves cache_control automatically for Claude Code single-mode assert.deepEqual(call.body.system[2].cache_control, { type: "ephemeral", ttl: "5m" }); assert.deepEqual(call.body.messages[0].content[0].cache_control, { type: "ephemeral", - ttl: "1h", + ttl: "5m", }); // base.ts executor explicitly strips cache_control from tools for Claude Code clients assert.equal(call.body.tools[0].cache_control, undefined); }); -test("chatCore supplements a missing message cache breakpoint for native Claude Code requests", async () => { +test("chatCore advances a message cache breakpoint for native Claude Code requests", async () => { await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" }); invalidateCacheControlSettingsCache(); @@ -1292,7 +1619,16 @@ test("chatCore supplements a missing message cache breakpoint for native Claude }, ], messages: [ - { role: "user", content: [{ type: "text", text: "first turn" }] }, + { + role: "user", + content: [ + { + type: "text", + text: "first turn", + cache_control: { type: "ephemeral" }, + }, + ], + }, { role: "assistant", content: [{ type: "text", text: "first response" }] }, { role: "user", content: [{ type: "text", text: "latest turn" }] }, ], @@ -1311,7 +1647,7 @@ test("chatCore supplements a missing message cache breakpoint for native Claude assert.deepEqual(call.body.messages[2].content[0].cache_control, { type: "ephemeral", - ttl: "1h", + ttl: "5m", }); assert.equal(call.body.tools[0].cache_control, undefined); }); @@ -1399,10 +1735,12 @@ test("chatCore disables raw Claude passthrough when cache preservation is off an ), true ); - // Cache preservation is on for native Claude, so cache markers are intact + // Cache preservation is on for native Claude, so cache markers are intact. This PR: + // an omitted TTL now defaults to "5m" once a "5m" boundary breakpoint (the system + // block above) has already appeared, instead of always defaulting to "1h". assert.deepEqual(call.body.messages[0].content[0].cache_control, { type: "ephemeral", - ttl: "1h", + ttl: "5m", }); // Tools disable flag is applied assert.equal("_disableToolPrefix" in call.body, false); diff --git a/tests/unit/chatgpt-web-citations.test.ts b/tests/unit/chatgpt-web-citations.test.ts index 9141d2755c..736e613814 100644 --- a/tests/unit/chatgpt-web-citations.test.ts +++ b/tests/unit/chatgpt-web-citations.test.ts @@ -10,12 +10,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import( - "../../open-sse/executors/chatgpt-web.ts" -); -const { __setTlsFetchOverrideForTesting } = await import( - "../../open-sse/services/chatgptTlsClient.ts" -); +const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = + await import("../../open-sse/executors/chatgpt-web.ts"); +const { __setTlsFetchOverrideForTesting } = + await import("../../open-sse/services/chatgptTlsClient.ts"); // ─── Minimal TLS-fetch mock ────────────────────────────────────────────────── // Tailored to the citation flow: root/DPL, session→accessToken, sentinel→token @@ -81,7 +79,7 @@ function installMockFetch({ if (u.includes("/sentinel/chat-requirements")) { return json({ token: "req-token", proofofwork: { required: false } }); } - // /backend-api/conversation/ — detail poll used by GPT-5.5 Pro handoff. + // /backend-api/conversation/ — detail poll used by GPT-5.6 Sol Pro handoff. if (conversationDetail) { const m1 = u.match(/\/backend-api\/conversation\/([^/?#]+)$/); if (m1) { @@ -171,7 +169,7 @@ test("Non-streaming: resolves ChatGPT web citation markers into markdown links", try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "latest Tesla FSD in Australia" }] }, stream: false, credentials: { apiKey: "test" }, @@ -260,7 +258,7 @@ test("Streaming: buffers split ChatGPT citation markers until metadata can link try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "latest Tesla FSD in Australia" }], stream: true, @@ -297,7 +295,7 @@ test("Streaming: buffers split ChatGPT citation markers until metadata can link } }); -test("GPT-5.5 Pro non-streaming: stream_handoff polls conversation detail for final answer", async () => { +test("GPT-5.6 Sol Pro non-streaming: stream_handoff polls conversation detail for final answer", async () => { __resetChatGptWebCachesForTesting(); const citationMarker = "citeturn0search0"; const m = installMockFetch({ @@ -369,7 +367,7 @@ test("GPT-5.5 Pro non-streaming: stream_handoff polls conversation detail for fi try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }] }, stream: false, credentials: { apiKey: "cookie-pro-poll" }, diff --git a/tests/unit/chatgpt-web-handoff-resume.test.ts b/tests/unit/chatgpt-web-handoff-resume.test.ts index 29b549c628..bb309e9952 100644 --- a/tests/unit/chatgpt-web-handoff-resume.test.ts +++ b/tests/unit/chatgpt-web-handoff-resume.test.ts @@ -164,8 +164,8 @@ function installHandoffMock( }; } -test("ChatGPT Web Pro models resume Temporary Chat handoffs through native SSE", async (t) => { - for (const model of ["gpt-5.6-pro", "gpt-5.5-pro", "gpt-5.5-pro-extended"]) { +test("ChatGPT Web GPT-5.6 Sol Pro resumes Temporary Chat handoffs through native SSE", async (t) => { + for (const model of ["gpt-5.6-sol-pro"]) { await t.test(model, async () => { __resetChatGptWebCachesForTesting(); const expected = `RESUMED_${model}`; @@ -204,7 +204,7 @@ test("ChatGPT Web handoff retries the next resume offset after a 404", async () try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.6-pro", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }] }, stream: false, credentials: { apiKey: "cookie-offset" }, @@ -231,7 +231,7 @@ test("ChatGPT Web streaming appends the native resumed Pro answer", async () => try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }], stream: true }, stream: true, credentials: { apiKey: "cookie-stream" }, diff --git a/tests/unit/chatgpt-web-max-thinking-effort.test.ts b/tests/unit/chatgpt-web-max-thinking-effort.test.ts index f245c8aad2..8d42972b38 100644 --- a/tests/unit/chatgpt-web-max-thinking-effort.test.ts +++ b/tests/unit/chatgpt-web-max-thinking-effort.test.ts @@ -2,158 +2,33 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - normalizeThinkingEffort, resolveChatGptModel, + resolveChatGptSystemHints, } from "../../open-sse/executors/chatgpt-web/models.ts"; -import { - ChatGptWebExecutor, - __resetChatGptWebCachesForTesting, -} from "../../open-sse/executors/chatgpt-web.ts"; -import { __setTlsFetchOverrideForTesting } from "../../open-sse/services/chatgptTlsClient.ts"; -function mockResponse(status: number, body: unknown, contentType = "application/json") { - return { - status, - headers: new Headers({ "Content-Type": contentType }), - text: typeof body === "string" ? body : JSON.stringify(body), - body: null, - }; -} - -function installMockFetch() { - const calls: { - userConfigUrl: string | null; - userConfigMethod: string | null; - conversationBody: string | null; - } = { - userConfigUrl: null, - userConfigMethod: null, - conversationBody: null, - }; - - __setTlsFetchOverrideForTesting(async (url, opts = {}) => { - const u = String(url); - - if (u === "https://chatgpt.com/" || u === "https://chatgpt.com") { - return mockResponse( - 200, - '', - "text/html" - ); - } - - if (u.includes("/api/auth/session")) { - return mockResponse(200, { - accessToken: "jwt-test", - expires: new Date(Date.now() + 3_600_000).toISOString(), - user: { id: "user-test" }, - }); - } - - if (u.includes("/backend-api/settings/user_last_used_model_config")) { - calls.userConfigUrl = u; - calls.userConfigMethod = (opts.method || "GET").toUpperCase(); - return mockResponse(200, { is_disabled: false }); - } - - if (u.includes("/backend-api/sentinel/chat-requirements")) { - return mockResponse(200, { - token: "requirements-test", - proofofwork: { required: false }, - }); - } - - if (u.endsWith("/backend-api/f/conversation")) { - calls.conversationBody = opts.body ?? null; - return mockResponse( - 200, - [ - `data: ${JSON.stringify({ - conversation_id: "conv-test", - message: { - id: "msg-test", - author: { role: "assistant" }, - content: { content_type: "text", parts: ["ok"] }, - status: "finished_successfully", - }, - })}`, - "", - "data: [DONE]", - "", - ].join("\r\n"), - "text/event-stream" - ); - } - - // Browser-like warmup endpoints are best-effort. Returning a normal 200 - // keeps this focused test independent from their response details. - return mockResponse(200, {}); - }); - - return { - calls, - restore() { - __setTlsFetchOverrideForTesting(null); - }, - }; -} - -test("ChatGPT Web thinking effort aliases map to the three native tiers", () => { +test("ChatGPT Web performance lanes use their native model and effort pairs", () => { const cases = [ - ["minimal", "standard"], - ["low", "standard"], - ["medium", "standard"], - ["standard", "standard"], - ["high", "extended"], - ["extended", "extended"], - ["xhigh", "max"], - ["max", "max"], + ["gpt-5.6-luna-free", "auto", null, false], + ["gpt-5.6-luna-free-thinking", "auto", null, false], + ["gpt-5.6-sol-instant", "gpt-5-6", null, false], + ["gpt-5.6-sol-medium", "gpt-5-6-thinking", "standard", false], + ["gpt-5.6-sol-high", "gpt-5-6-thinking", "extended", false], + ["gpt-5.6-sol-xhigh", "gpt-5-6-thinking", "max", false], + ["gpt-5.6-sol-pro", "gpt-5-6-pro", "standard", true], + ["gpt-5.5-instant", "gpt-5-5", null, false], + ["gpt-5.5-medium", "gpt-5-5-thinking", "standard", false], + ["gpt-5.5-high", "gpt-5-5-thinking", "extended", false], + ["gpt-5.5-xhigh", "gpt-5-5-thinking", "max", false], + ["gpt-5.5-pro", "gpt-5-5-pro", "standard", true], + ["gpt-5.5-pro-extended", "gpt-5-5-pro", "extended", true], ] as const; - for (const [input, expected] of cases) { - assert.equal(normalizeThinkingEffort(input), expected, input); + for (const [model, slug, effort, isPro] of cases) { + assert.deepEqual(resolveChatGptModel(model), { slug, effort, isPro }, model); } }); -test("providerSpecificData can request native max with highest precedence", () => { - const resolved = resolveChatGptModel( - "gpt-5.6-thinking", - { reasoning_effort: "low" }, - { thinkingEffort: "max" } - ); - assert.equal(resolved.effort, "max"); +test("ChatGPT Web Free Luna Think uses the captured reason system hint", () => { + assert.deepEqual(resolveChatGptSystemHints("gpt-5.6-luna-free"), []); + assert.deepEqual(resolveChatGptSystemHints("gpt-5.6-luna-free-thinking"), ["reason"]); }); - -for (const effort of ["xhigh", "max"] as const) { - test(`ChatGPT Web executor sends ${effort} as thinking_effort=max`, async () => { - __resetChatGptWebCachesForTesting(); - const mock = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - const result = await executor.execute({ - model: "gpt-5.6-thinking", - body: { - messages: [{ role: "user", content: "hi" }], - reasoning_effort: effort, - }, - stream: false, - credentials: { apiKey: `cookie-${effort}` }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - - assert.equal(result.response.status, 200); - assert.equal(mock.calls.userConfigMethod, "PATCH"); - assert.ok(mock.calls.userConfigUrl); - const settingsUrl = new URL(mock.calls.userConfigUrl); - assert.equal(settingsUrl.searchParams.get("model_slug"), "gpt-5-6-thinking"); - assert.equal(settingsUrl.searchParams.get("thinking_effort"), "max"); - - assert.ok(mock.calls.conversationBody); - const conversationBody = JSON.parse(mock.calls.conversationBody) as Record; - assert.equal(conversationBody.thinking_effort, "max"); - } finally { - mock.restore(); - } - }); -} diff --git a/tests/unit/chatgpt-web-models-split.test.ts b/tests/unit/chatgpt-web-models-split.test.ts index f25f78556d..ef9ff79a77 100644 --- a/tests/unit/chatgpt-web-models-split.test.ts +++ b/tests/unit/chatgpt-web-models-split.test.ts @@ -5,16 +5,16 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; // Split-guard for the chatgpt-web model-mapping extraction. -// The static model maps + pure thinking-effort resolvers live in the pure leaf -// chatgpt-web/models.ts (no module state). Host imports the two it uses back. +// The static model maps + pure model resolver live in the pure leaf +// chatgpt-web/models.ts (no module state). Host imports it back. const HERE = dirname(fileURLToPath(import.meta.url)); const EXE = join(HERE, "../../open-sse/executors"); const HOST = join(EXE, "chatgpt-web.ts"); const LEAF = join(EXE, "chatgpt-web/models.ts"); -test("leaf hosts the model maps + resolvers and does not import the host", () => { +test("leaf hosts the model maps + resolver and does not import the host", () => { const src = readFileSync(LEAF, "utf8"); - for (const sym of ["MODEL_MAP", "resolveChatGptModel", "resolveThinkingEffort"]) { + for (const sym of ["MODEL_MAP", "MODEL_FORCED_EFFORT", "resolveChatGptModel"]) { assert.match(src, new RegExp(`export (const|function) ${sym}\\b`)); } assert.doesNotMatch(src, /from "\.\.\/chatgpt-web\.ts"/); diff --git a/tests/unit/chatgpt-web-tools-5240.test.ts b/tests/unit/chatgpt-web-tools-5240.test.ts index 5fc9ec06c3..ac6bc9046a 100644 --- a/tests/unit/chatgpt-web-tools-5240.test.ts +++ b/tests/unit/chatgpt-web-tools-5240.test.ts @@ -8,15 +8,13 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import( - "../../open-sse/executors/chatgpt-web.ts" -); -const { __setTlsFetchOverrideForTesting } = await import( - "../../open-sse/services/chatgptTlsClient.ts" -); +const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = + await import("../../open-sse/executors/chatgpt-web.ts"); +const { __setTlsFetchOverrideForTesting } = + await import("../../open-sse/services/chatgptTlsClient.ts"); // ─── Minimal TLS-fetch mock ────────────────────────────────────────────────── -// Tailored to the tool-call flow (gpt-5.3-instant, non-thinking): root/DPL, +// Tailored to the tool-call flow (gpt-5.5, non-thinking): root/DPL, // session→accessToken, sentinel→token (no PoW), conv→SSE. Warmup GETs fall // through to 404, which the executor tolerates. @@ -68,7 +66,10 @@ function installMockFetch(convEvents: unknown[]) { body: null, }); - if ((u === "https://chatgpt.com/" || u === "https://chatgpt.com") && (opts.method || "GET") === "GET") { + if ( + (u === "https://chatgpt.com/" || u === "https://chatgpt.com") && + (opts.method || "GET") === "GET" + ) { return { status: 200, headers: makeHeaders({ "Content-Type": "text/html" }), @@ -127,7 +128,7 @@ const TOOL_CALL_TEXT = '{"name":"get_weather","arguments":{"location":"Tok function baseOpts(extra: Record) { return { - model: "gpt-5.3-instant", + model: "gpt-5.5", credentials: { apiKey: "test" }, signal: AbortSignal.timeout(10_000), log: null, diff --git a/tests/unit/chatgpt-web-tools-7679.test.ts b/tests/unit/chatgpt-web-tools-7679.test.ts index f042f9ab7d..0ce60ba21f 100644 --- a/tests/unit/chatgpt-web-tools-7679.test.ts +++ b/tests/unit/chatgpt-web-tools-7679.test.ts @@ -1,6 +1,6 @@ -// Tool contract serialization for chatgpt-web thinking models (#7679). +// Tool contract serialization for ChatGPT Web performance models (#7679). // -// GPT-5.6 Thinking via chatgpt-web ignores the injected `` pseudo-contract +// GPT-5.6 Sol via chatgpt-web ignores the injected `` pseudo-contract // and replies in prose claiming tools are unavailable. This test covers the // nonce-bound serialization that clearly describes client-side tools and places // the full contract at the tail of the effective message list. @@ -161,7 +161,7 @@ test("parseToolCallsFromText returns null when hardened text has no tool blocks }); test("parseToolCallsFromText handles blocks line-boundary crossing in hardened text (#7679)", () => { - // Some thinking models may emit the tool block adjacent to explanatory text + // Some high-performance lanes may emit the tool block adjacent to explanatory text // with no preceding newline const text = [ 'I will use the weather tool. {"name":"get_weather","arguments":{"location":"Paris"}}', diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts index cc97ecd69d..267c0eb6dc 100644 --- a/tests/unit/chatgpt-web.test.ts +++ b/tests/unit/chatgpt-web.test.ts @@ -81,13 +81,11 @@ type MockFetchOptions = { attachmentDownload?: MockTlsConfig; conversationDetail?: MockTlsConfig | MockTlsConfig[]; signedDownload?: MockTlsConfig; - userConfig?: MockTlsConfig; onSession?: (opts: TlsFetchOptions) => void; onSentinel?: (opts: TlsFetchOptions) => void; onConv?: (opts: TlsFetchOptions) => void; onFileDownload?: (opts: TlsFetchOptions, fileId: string) => void; onAttachmentDownload?: (opts: TlsFetchOptions, fileId: string) => void; - onUserConfig?: (opts: TlsFetchOptions, url: string) => void; }; type MockFetchCalls = { @@ -99,9 +97,6 @@ type MockFetchCalls = { attachmentDownload: number; conversationDetail: number; signedDownload: number; - userConfig: number; - userConfigUrls: string[]; - userConfigMethods: string[]; urls: string[]; headers: Array | undefined>; bodies: Array; @@ -118,13 +113,11 @@ function installMockFetch({ attachmentDownload, conversationDetail, signedDownload, - userConfig, onSession, onSentinel, onConv, onFileDownload, onAttachmentDownload, - onUserConfig, }: MockFetchOptions = {}) { const calls: MockFetchCalls = { session: 0, @@ -135,9 +128,6 @@ function installMockFetch({ attachmentDownload: 0, conversationDetail: 0, signedDownload: 0, - userConfig: 0, - userConfigUrls: [], - userConfigMethods: [], urls: [], headers: [], bodies: [], @@ -188,22 +178,6 @@ function installMockFetch({ }; } - // /backend-api/settings/user_last_used_model_config?model_slug=...&thinking_effort=... - // Match before sentinel since /settings/* is its own surface. - if (u.includes("/backend-api/settings/user_last_used_model_config")) { - calls.userConfig++; - calls.userConfigUrls.push(u); - calls.userConfigMethods.push((opts.method || "GET").toUpperCase()); - if (onUserConfig) onUserConfig(opts, u); - const cfg = userConfig ?? { status: 200, body: { is_disabled: false } }; - return { - status: cfg.status, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}), - body: null, - }; - } - if (u.includes("/sentinel/chat-requirements")) { calls.sentinel++; if (onSentinel) onSentinel(opts); @@ -288,7 +262,7 @@ function installMockFetch({ }; } - // /backend-api/conversation/ — detail poll used by GPT-5.5 Pro handoff. + // /backend-api/conversation/ — detail poll used by GPT-5.6 Sol Pro handoff. { const m1 = u.match(/\/backend-api\/conversation\/([^/?#]+)$/); if (m1) { @@ -480,7 +454,7 @@ test("Token exchange: cookie sent to /api/auth/session, accessToken used as Bear try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "my-cookie-value" }, @@ -518,7 +492,7 @@ test("Token cache: two calls within TTL only hit /api/auth/session once", async try { const executor = new ChatGptWebExecutor(); const opts = { - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-v1" }, @@ -552,7 +526,7 @@ test("Refreshed cookie: surfaced via onCredentialsRefreshed callback", async () let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "old-cookie" }, @@ -585,7 +559,7 @@ test("Sentinel: chat-requirements is hit before /backend-api/conversation", asyn try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -606,7 +580,7 @@ test("Sentinel: chat-requirements token forwarded on conv request", async () => try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -635,7 +609,7 @@ test("PoW: when required, proof token is sent with valid prefix", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -670,7 +644,7 @@ test("Turnstile: required flag does NOT block — conv endpoint accepts requests try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -692,7 +666,7 @@ test("Non-streaming: returns OpenAI chat.completion JSON", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -743,7 +717,7 @@ test("Streaming: produces valid SSE chunks ending with [DONE]", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }], stream: true }, stream: true, credentials: { apiKey: "test" }, @@ -807,7 +781,7 @@ test("Streaming: cumulative parts are diffed into non-overlapping deltas", async try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }], stream: true }, stream: true, credentials: { apiKey: "test" }, @@ -835,7 +809,7 @@ test("Streaming: cumulative parts are diffed into non-overlapping deltas", async } }); -test("GPT-5.5 Pro streaming: preserves interim reasoning and appends final polled answer", async () => { +test("GPT-5.6 Sol Pro streaming: preserves interim reasoning and appends final polled answer", async () => { reset(); const m = installMockFetch({ conv: { @@ -878,7 +852,7 @@ test("GPT-5.5 Pro streaming: preserves interim reasoning and appends final polle try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }], stream: true }, stream: true, credentials: { apiKey: "cookie-pro-stream" }, @@ -907,7 +881,7 @@ test("Error: 401 on /api/auth/session returns 401 with re-paste hint", async () try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "expired-cookie" }, @@ -928,7 +902,7 @@ test("Error: 200 with no accessToken returns 401", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "stale-cookie" }, @@ -948,7 +922,7 @@ test("Error: 403 from sentinel returns 403 SENTINEL_BLOCKED", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -970,7 +944,7 @@ test("Error: 429 from conversation returns 429 with rate-limit message", async ( try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -991,7 +965,7 @@ test("Error: empty messages returns 400 without any fetch", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [] }, stream: false, credentials: { apiKey: "test" }, @@ -1011,7 +985,7 @@ test("Error: missing apiKey returns 401 without any fetch", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: {}, @@ -1033,7 +1007,7 @@ test("Cookie: bare value gets prepended with cookie name", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "rawValue" }, @@ -1052,7 +1026,7 @@ test("Cookie: unchunked cookie line is passed through verbatim", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "__Secure-next-auth.session-token=actualvalue" }, @@ -1071,7 +1045,7 @@ test("Cookie: chunked .0/.1 cookies are passed through verbatim (NextAuth reasse try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1096,7 +1070,7 @@ test("Cookie: 'Cookie: ' DevTools prefix is stripped", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1127,7 +1101,7 @@ test("Session continuity: each call starts a fresh conversation (Temporary Chat try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "First question" }] }, stream: false, credentials: { apiKey: "test" }, @@ -1135,7 +1109,7 @@ test("Session continuity: each call starts a fresh conversation (Temporary Chat log: null, }); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "user", content: "First question" }, @@ -1178,7 +1152,7 @@ test("Request: conversation POST has correct browser-like headers", async () => try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -1204,7 +1178,7 @@ test("Request: payload has correct ChatGPT shape", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "system", content: "Be concise" }, @@ -1219,7 +1193,7 @@ test("Request: payload has correct ChatGPT shape", async () => { const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); assert.equal(body.action, "next"); - assert.equal(body.model, "gpt-5-3-instant"); + assert.equal(body.model, "gpt-5-5"); // Plain text request → Temporary Chat stays ON. We disable it only for // image-gen prompts (see "Image gen: image-intent prompts" tests below). assert.equal(body.history_and_training_disabled, true); @@ -1245,26 +1219,23 @@ test("Provider registry: chatgpt-web exposes the current ChatGPT Web model catal assert.equal(entry.authHeader, "cookie"); const ids = (entry.models || []).map((m) => m.id); - // Retired GPT-5.4 and older entries stay out of the advertised catalog. + // Free accounts expose Luna with an optional Think toggle; paid accounts + // expose five GPT-5.6 Sol performance lanes plus GPT-5.5. assert.deepEqual(ids, [ - "gpt-5.6-pro", - "gpt-5.6-thinking", + "gpt-5.6-sol-pro", + "gpt-5.6-sol-xhigh", + "gpt-5.6-sol-high", + "gpt-5.6-sol-medium", + "gpt-5.6-sol-instant", + "gpt-5.6-luna-free-thinking", + "gpt-5.6-luna-free", "gpt-5.5-pro-extended", "gpt-5.5-pro", - "gpt-5.5-thinking", - "gpt-5.5", - "o3", + "gpt-5.5-xhigh", + "gpt-5.5-high", + "gpt-5.5-medium", + "gpt-5.5-instant", ]); - assert.equal( - ids.some((id) => id.startsWith("gpt-5.4")), - false - ); - - const { MODEL_MAP } = await import("../../open-sse/executors/chatgpt-web/models.ts"); - assert.equal( - Object.keys(MODEL_MAP).some((id) => id.startsWith("gpt-5.4") || id.startsWith("gpt-5-4")), - false - ); }); test("Executor MODEL_MAP: OmniRoute IDs translate to ChatGPT backend slugs", async () => { @@ -1273,19 +1244,22 @@ test("Executor MODEL_MAP: OmniRoute IDs translate to ChatGPT backend slugs", asy try { const cases: Array<[string, string]> = [ // Public catalog ids. - ["gpt-5.6-pro", "gpt-5-6-pro"], - ["gpt-5.6-thinking", "gpt-5-6-thinking"], - ["gpt-5.5-thinking", "gpt-5-5-thinking"], - ["gpt-5.5", "gpt-5-5"], + ["gpt-5.6-luna-free", "auto"], + ["gpt-5.6-luna-free-thinking", "auto"], + ["gpt-5.6-sol-instant", "gpt-5-6"], + ["gpt-5.6-sol-medium", "gpt-5-6-thinking"], + ["gpt-5.6-sol-high", "gpt-5-6-thinking"], + ["gpt-5.6-sol-xhigh", "gpt-5-6-thinking"], + ["gpt-5.6-sol-pro", "gpt-5-6-pro"], + ["gpt-5.5-instant", "gpt-5-5"], + ["gpt-5.5-medium", "gpt-5-5-thinking"], + ["gpt-5.5-high", "gpt-5-5-thinking"], + ["gpt-5.5-xhigh", "gpt-5-5-thinking"], ["gpt-5.5-pro", "gpt-5-5-pro"], ["gpt-5.5-pro-extended", "gpt-5-5-pro"], - ["o3", "o3"], // Backend dash-form slugs are still accepted for direct provider/model callers. - ["gpt-5-3", "gpt-5-3"], - ["gpt-5-5-thinking", "gpt-5-5-thinking"], - ["gpt-5-6-pro", "gpt-5-6-pro"], - ["gpt-5-5-pro", "gpt-5-5-pro"], - ["gpt-5-5-pro-extended", "gpt-5-5-pro"], + ["gpt-5-6", "gpt-5-6"], + ["gpt-5-5", "gpt-5-5"], ]; for (const [omniId, expectedSlug] of cases) { m.calls.urls.length = 0; @@ -1308,18 +1282,58 @@ test("Executor MODEL_MAP: OmniRoute IDs translate to ChatGPT backend slugs", asy } }); +test("GPT-5.6 Luna Free Think sends the captured auto-router reason hints", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + for (const [model, expectedHints] of [ + ["gpt-5.6-luna-free", undefined], + ["gpt-5.6-luna-free-thinking", ["reason"]], + ] as const) { + m.calls.urls.length = 0; + m.calls.bodies.length = 0; + await executor.execute({ + model, + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "cookie-free-luna" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); + const body = JSON.parse(m.calls.bodies[convIdx]); + const userMessage = body.messages.find( + (message: { author?: { role?: string } }) => message.author?.role === "user" + ); + + assert.equal(body.model, "auto"); + assert.deepEqual(body.system_hints, expectedHints); + assert.deepEqual(userMessage?.metadata?.system_hints, expectedHints); + } + } finally { + m.restore(); + } +}); + test("MODEL_MAP drift guard: every advertised catalog id reaches ChatGPT as a backend slug", async () => { reset(); const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); const ids = (getRegistryEntry("chatgpt-web")?.models || []).map((m) => m.id); const expectedSlugById: Record = { - "gpt-5.6-pro": "gpt-5-6-pro", - "gpt-5.6-thinking": "gpt-5-6-thinking", - "gpt-5.5-pro-extended": "gpt-5-5-pro", + "gpt-5.6-luna-free": "auto", + "gpt-5.6-luna-free-thinking": "auto", + "gpt-5.6-sol-instant": "gpt-5-6", + "gpt-5.6-sol-medium": "gpt-5-6-thinking", + "gpt-5.6-sol-high": "gpt-5-6-thinking", + "gpt-5.6-sol-xhigh": "gpt-5-6-thinking", + "gpt-5.6-sol-pro": "gpt-5-6-pro", + "gpt-5.5-instant": "gpt-5-5", + "gpt-5.5-medium": "gpt-5-5-thinking", + "gpt-5.5-high": "gpt-5-5-thinking", + "gpt-5.5-xhigh": "gpt-5-5-thinking", "gpt-5.5-pro": "gpt-5-5-pro", - "gpt-5.5-thinking": "gpt-5-5-thinking", - "gpt-5.5": "gpt-5-5", - o3: "o3", + "gpt-5.5-pro-extended": "gpt-5-5-pro", }; const m = installMockFetch(); try { @@ -1348,15 +1362,15 @@ test("MODEL_MAP drift guard: every advertised catalog id reaches ChatGPT as a ba } }); -// ─── thinking_effort PATCH user_last_used_model_config ───────────────────── +// ─── GPT-5.6 Sol picker request contract ────────────────────────────────── -test("GPT-5.5 Pro Extended sends base slug with extended effort and Temporary Chat", async () => { +test("GPT-5.6 Sol XHigh sends the captured thinking-model/max pair", async () => { reset(); const m = installMockFetch(); try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-xhigh", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-pro-extended" }, @@ -1366,26 +1380,25 @@ test("GPT-5.5 Pro Extended sends base slug with extended effort and Temporary Ch assert.equal(result.response.status, 200); const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); - assert.equal(body.model, "gpt-5-5-pro"); - assert.equal(body.thinking_effort, "extended"); + assert.equal(body.model, "gpt-5-6-thinking"); + assert.equal(body.thinking_effort, "max"); assert.equal(body.history_and_training_disabled, true); - assert.equal( - m.calls.userConfig, - 0, - "Pro effort is sent with the turn, not PATCHed as a thinking-model preference" + assert.ok( + !m.calls.urls.some((url) => url.includes("/settings/user_last_used_model_config")), + "the captured browser request uses no settings PATCH" ); } finally { m.restore(); } }); -test("GPT-5.5 Pro standard sends standard effort", async () => { +test("GPT-5.6 Sol High sends the captured thinking-model/extended pair", async () => { reset(); const m = installMockFetch(); try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.5-pro", + model: "gpt-5.6-sol-high", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-pro-standard" }, @@ -1394,21 +1407,21 @@ test("GPT-5.5 Pro standard sends standard effort", async () => { }); const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); - assert.equal(body.model, "gpt-5-5-pro"); - assert.equal(body.thinking_effort, "standard"); + assert.equal(body.model, "gpt-5-6-thinking"); + assert.equal(body.thinking_effort, "extended"); assert.equal(body.history_and_training_disabled, true); } finally { m.restore(); } }); -test("GPT-5.5 Pro store:false keeps Temporary Chat enabled for background utility calls", async () => { +test("GPT-5.6 Sol XHigh store:false keeps Temporary Chat enabled", async () => { reset(); const m = installMockFetch(); try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-xhigh", body: { store: false, messages: [ @@ -1424,8 +1437,8 @@ test("GPT-5.5 Pro store:false keeps Temporary Chat enabled for background utilit assert.equal(result.response.status, 200); const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); - assert.equal(body.model, "gpt-5-5-pro"); - assert.equal(body.thinking_effort, "extended"); + assert.equal(body.model, "gpt-5-6-thinking"); + assert.equal(body.thinking_effort, "max"); assert.equal(body.history_and_training_disabled, true); assert.equal( m.calls.conversationDetail, @@ -1437,242 +1450,6 @@ test("GPT-5.5 Pro store:false keeps Temporary Chat enabled for background utilit } }); -test("thinking_effort: high → PATCH user_last_used_model_config with extended", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.5-thinking", - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - stream: false, - credentials: { apiKey: "cookie-1" }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 1, "exactly one PATCH issued"); - assert.equal(m.calls.userConfigMethods[0], "PATCH"); - const u = m.calls.userConfigUrls[0]; - assert.match(u, /model_slug=gpt-5-5-thinking/); - assert.match(u, /thinking_effort=extended/); - } finally { - m.restore(); - } -}); - -test("thinking_effort: low/medium → PATCH with standard", async () => { - for (const effort of ["low", "medium", "minimal"]) { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.6-thinking", - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: effort }, - stream: false, - credentials: { apiKey: `cookie-${effort}` }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 1, `effort=${effort} should issue exactly one PATCH`); - assert.match(m.calls.userConfigUrls[0], /thinking_effort=standard/, `${effort} → standard`); - assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-6-thinking/); - } finally { - m.restore(); - } - } -}); - -test("thinking_effort: instant model never triggers PATCH even with reasoning_effort", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.3-instant", - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - stream: false, - credentials: { apiKey: "cookie-instant" }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 0, "instant slug must not PATCH thinking_effort"); - } finally { - m.restore(); - } -}); - -test("thinking_effort: bare chatgpt.com thinking slugs still PATCH", async () => { - for (const bareSlug of ["gpt-5-6-thinking", "gpt-5-5-thinking", "o3"]) { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: bareSlug, - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - stream: false, - credentials: { apiKey: `cookie-bare-${bareSlug}` }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal( - m.calls.userConfig, - 1, - `bare slug ${bareSlug} must trigger thinking_effort PATCH` - ); - assert.ok( - m.calls.userConfigUrls[0].includes(`model_slug=${bareSlug}`), - `URL should contain model_slug=${bareSlug}` - ); - } finally { - m.restore(); - } - } -}); - -test("thinking_effort: thinking model without reasoning_effort skips PATCH", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.5-thinking", - body: { messages: [{ role: "user", content: "hi" }] }, - stream: false, - credentials: { apiKey: "cookie-noeffort" }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 0, "no effort requested → no PATCH"); - } finally { - m.restore(); - } -}); - -test("thinking_effort: providerSpecificData.thinkingEffort=extended overrides body", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.6-thinking", - body: { - messages: [{ role: "user", content: "hi" }], - reasoning_effort: "low", // would normally map to standard - }, - stream: false, - credentials: { - apiKey: "cookie-override", - providerSpecificData: { thinkingEffort: "extended" }, - }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 1); - assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-6-thinking/); - assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/); - } finally { - m.restore(); - } -}); - -test("thinking_effort: nested body.reasoning.effort=high → extended", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.5-thinking", - body: { - messages: [{ role: "user", content: "hi" }], - reasoning: { effort: "high" }, - }, - stream: false, - credentials: { apiKey: "cookie-nested" }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 1); - assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-5-thinking/); - assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/); - } finally { - m.restore(); - } -}); - -test("thinking_effort: cached per (cookie, slug, effort) — second identical call skips PATCH", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - const opts = { - model: "gpt-5.5-thinking", - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - stream: false, - credentials: { apiKey: "cookie-cache" }, - signal: AbortSignal.timeout(10_000), - log: null, - }; - await executor.execute(opts); - await executor.execute(opts); - assert.equal(m.calls.userConfig, 1, "second identical request hits cache"); - } finally { - m.restore(); - } -}); - -test("thinking_effort: switching effort within TTL triggers a fresh PATCH", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - const base = { - model: "gpt-5.5-thinking", - stream: false, - credentials: { apiKey: "cookie-switch" }, - signal: AbortSignal.timeout(10_000), - log: null, - }; - await executor.execute({ - ...base, - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - }); - await executor.execute({ - ...base, - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "low" }, - }); - assert.equal(m.calls.userConfig, 2, "different effort key bypasses cache"); - assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/); - assert.match(m.calls.userConfigUrls[1], /thinking_effort=standard/); - } finally { - m.restore(); - } -}); - -test("thinking_effort: PATCH failure is non-fatal — conversation request still fires", async () => { - reset(); - const m = installMockFetch({ - userConfig: { status: 500, body: { error: "boom" } }, - }); - try { - const executor = new ChatGptWebExecutor(); - const result = await executor.execute({ - model: "gpt-5.5-thinking", - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - stream: false, - credentials: { apiKey: "cookie-fail" }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 1); - assert.equal(m.calls.conv, 1, "conversation still issued despite settings PATCH 500"); - assert.equal(result.response.status, 200); - } finally { - m.restore(); - } -}); - test("Image registry: cgpt-web/gpt-5.5 routes to ChatGPT Web image handler", async () => { const { parseImageModel, getImageProvider } = await import("../../open-sse/config/imageRegistry.ts"); @@ -1709,7 +1486,7 @@ test("Cookie rotation: full DevTools blob keeps cf_clearance/__cf_bm/_cfuvid", a let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1764,7 +1541,7 @@ test("Cookie rotation: unchunked → chunked drops stale unchunked variant", asy let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1811,7 +1588,7 @@ test("Cookie rotation: chunked → unchunked drops stale chunks", async () => { let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1855,7 +1632,7 @@ test("Cookie rotation: returns null when Set-Cookie has no session-token", async let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-v1" }, @@ -1919,7 +1696,7 @@ test("Stream parser: echoed prior assistant turn is suppressed (streaming)", asy try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }], stream: true }, stream: true, credentials: { apiKey: "test" }, @@ -1979,7 +1756,7 @@ test("Stream parser: echoed prior assistant turn is suppressed (non-streaming)", try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2017,7 +1794,7 @@ test("Stream parser: instant single-event reply still surfaces via fallback", as try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2083,7 +1860,7 @@ test("Error: TlsClientUnavailableError returns 502 with TLS_UNAVAILABLE code", a try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2211,7 +1988,7 @@ test("Image gen: file-service:// pointer resolves to download URL and is appende try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "generate an image of a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2245,7 +2022,7 @@ test("Image gen: file-service:// pointer is appended in streaming SSE", async () try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "draw a kitten" }] }, stream: true, credentials: { apiKey: "test" }, @@ -2279,7 +2056,7 @@ test("Image gen: sediment:// pointer prefers /files//download over /attachme try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "make a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2324,7 +2101,7 @@ test("Image gen: failed download URL is dropped silently — no broken markdown" try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2348,7 +2125,7 @@ test("Image gen: image-intent prompt disables Temporary Chat", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "generate an image of a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2369,7 +2146,7 @@ test("Image gen: text-only prompt keeps Temporary Chat ON", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "what is the capital of France?" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2396,7 +2173,7 @@ test("Image gen: Open WebUI follow-up/title/tag tool prompts do NOT trigger imag try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: prompt }] }, stream: false, credentials: { apiKey: "test" }, @@ -2429,7 +2206,7 @@ test("Image gen: Open WebUI image-generation context suppresses duplicate chat i try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "system", content: context }, @@ -2477,7 +2254,7 @@ test("Image gen: heuristic catches common phrasings", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: phrase }] }, stream: false, credentials: { apiKey: "test" }, @@ -2598,7 +2375,7 @@ test("Image gen: signed URL bytes are cached and exposed via /v1/chatgpt-web/ima try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "draw kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2648,7 +2425,7 @@ test("Image gen: prior data: image URIs are stripped from history before upstrea const assistantMsg = `Sure, here you go:\n\n![image](data:image/png;base64,${huge})\n`; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "user", content: "draw a kitten" }, @@ -2686,7 +2463,7 @@ test("Image edit: cached OmniRoute image URL continues the saved ChatGPT convers try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "user", content: "draw a kitten" }, @@ -2726,7 +2503,7 @@ test("Image edit: Open WebUI image context suppresses duplicate edit continuatio try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { @@ -2798,7 +2575,7 @@ test("Image gen: dedupes the same pointer across in-progress + finished events", try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2829,7 +2606,7 @@ test("Image gen: bytes-fetch failure drops markdown (no signed-URL fallback)", a try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "draw a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2903,7 +2680,7 @@ test("Image edit: file_0000XXXX (chatgpt-web edit result) falls back to /convers try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "now make it nighttime" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2965,7 +2742,7 @@ test("Image gen: ChatGPT-internal tool_invoked metadata does NOT spuriously trig try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "limitations of gpt-4o-mini?" }] }, stream: true, credentials: { apiKey: "test" }, @@ -3015,7 +2792,7 @@ test("Image edit handler: bytes-hash match drives executor with cached conversat const { handleImageEdit } = await import("../../open-sse/handlers/imageGeneration.ts"); const result = await handleImageEdit({ provider: "chatgpt-web", - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { prompt: "turn it to day time" }, imageBytes: sourceBytes, credentials: { apiKey: "test" }, @@ -3053,7 +2830,7 @@ test("Image edit handler: no cached match returns 400 (does not silently generat const foreignBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0xde, 0xad, 0xbe, 0xef]); const result = await handleImageEdit({ provider: "chatgpt-web", - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { prompt: "turn it to day time" }, imageBytes: foreignBytes, credentials: { apiKey: "test" }, @@ -3082,7 +2859,7 @@ test("Image gen handler: n>4 is rejected before any upstream call", async () => try { const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); const result = await handleImageGeneration({ - body: { prompt: "draw a kitten", n: 5, model: "cgpt-web/gpt-5.3-instant" }, + body: { prompt: "draw a kitten", n: 5, model: "cgpt-web/gpt-5.5" }, credentials: { apiKey: "test" }, log: null, }); diff --git a/tests/unit/check-env-doc-sync.test.ts b/tests/unit/check-env-doc-sync.test.ts index 2db6a62c87..3975e58361 100644 --- a/tests/unit/check-env-doc-sync.test.ts +++ b/tests/unit/check-env-doc-sync.test.ts @@ -179,6 +179,32 @@ test("runEnvDocSync: ignore set skips a code-referenced var", () => { assert.equal(result.ok, true); }); +test("runEnvDocSync: shipped allowlist ignores ad-hoc BOT_TOKEN and BOT_URL", () => { + const envExampleText = `JWT_SECRET=secret\n`; + const envDocText = "| `JWT_SECRET` | _(none)_ | required |"; + const codeVars = new Set(["JWT_SECRET", "BOT_TOKEN", "BOT_URL"]); + + const unignored = runEnvDocSync({ + envExampleText, + envDocText, + codeVars, + ignore: new Set(), + docOnlyAllowlist: new Set(), + envOnlyAllowlist: new Set(), + }); + assert.equal(unignored.ok, false); + assert.deepEqual(unignored.problems.codeMissingEnv, ["BOT_TOKEN", "BOT_URL"]); + + // Omit `ignore` so the checker uses IGNORE_FROM_CODE from check-env-doc-sync.mjs. + const shipped = runEnvDocSync({ + envExampleText, + envDocText, + codeVars, + }); + assert.equal(shipped.ok, true); + assert.deepEqual(shipped.problems.codeMissingEnv, []); +}); + test("repository contract is in sync (live data)", () => { // Uses the real .env.example, docs/ENVIRONMENT.md, and the bundled // allowlists. This is the same check that runs in pre-commit / CI. diff --git a/tests/unit/check-pack-boot.test.ts b/tests/unit/check-pack-boot.test.ts index ea592db217..56abe03176 100644 --- a/tests/unit/check-pack-boot.test.ts +++ b/tests/unit/check-pack-boot.test.ts @@ -1,14 +1,18 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { REQUIRED_SQLJS_RUNTIME_FILES, + REQUIRED_MACHINE_TOKEN_RUNTIME_FILES, pickTarball, evaluateBoot, pickPort, findMissingSqlJsRuntimeFiles, + findMissingMachineTokenRuntimeFiles, + evaluateMachineTokenAuth, evaluateSqlJsRoundTrip, evaluateRestartPersistence, } from "../../scripts/check/check-pack-boot.mjs"; @@ -74,6 +78,64 @@ test("installed package contract requires sql.js metadata, entrypoint, and WASM" ); }); +test("installed package contract requires a resolvable node-machine-id CommonJS runtime", () => { + const present = new Set( + REQUIRED_MACHINE_TOKEN_RUNTIME_FILES.map((file) => path.join("/pkg", file)) + ); + assert.deepEqual( + findMissingMachineTokenRuntimeFiles("/pkg", (file) => present.has(file)), + [] + ); + + present.delete(path.join("/pkg", "node_modules/node-machine-id/index.js")); + assert.deepEqual( + findMissingMachineTokenRuntimeFiles("/pkg", (file) => present.has(file)), + ["node_modules/node-machine-id/index.js"] + ); +}); + +test("machine-token smoke requires no/invalid credentials to fail and the packaged CLI token to pass", () => { + assert.deepEqual( + evaluateMachineTokenAuth({ + cliToken: "a".repeat(64), + unauthenticatedStatus: 401, + invalidStatus: 401, + authenticatedStatus: 200, + }), + { ok: true, failures: [] } + ); + + for (const candidate of [ + { cliToken: "", unauthenticatedStatus: 401, invalidStatus: 401, authenticatedStatus: 200 }, + { + cliToken: "a".repeat(64), + unauthenticatedStatus: 200, + invalidStatus: 401, + authenticatedStatus: 200, + }, + { + cliToken: "a".repeat(64), + unauthenticatedStatus: 401, + invalidStatus: 200, + authenticatedStatus: 200, + }, + { + cliToken: "a".repeat(64), + unauthenticatedStatus: 401, + invalidStatus: 401, + authenticatedStatus: 401, + }, + { + cliToken: createHmac("sha256", "").update("omniroute-cli-auth-v1").digest("hex"), + unauthenticatedStatus: 401, + invalidStatus: 401, + authenticatedStatus: 200, + }, + ]) { + assert.equal(evaluateMachineTokenAuth(candidate).ok, false); + } +}); + test("sql.js round trip requires the forced-driver marker plus PATCH and GET persistence", () => { const passing = evaluateSqlJsRoundTrip({ startupOutput: "[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)...", @@ -103,10 +165,20 @@ test("source guard: the gate polls the real health endpoint of the INSTALLED bin ); assert.ok(src.includes("/api/monitoring/health"), "must poll the health endpoint"); assert.ok(src.includes("/api/settings"), "must verify a real application write and read"); + assert.ok(src.includes("/api/cli/whoami"), "must exercise the machine-token auth endpoint"); + assert.ok(src.includes("x-omniroute-cli-token"), "must send the official machine-token header"); + const postinstall = readFileSync( + fileURLToPath(new URL("../../scripts/build/postinstall.mjs", import.meta.url)), + "utf8" + ); + assert.ok(postinstall.includes('["sql.js", "node-machine-id"]')); + assert.ok(postinstall.includes('join(ROOT, "dist", "node_modules", packageName)')); assert.ok( src.includes('OMNIROUTE_PACK_BOOT_FORCE_SQLJS: "1"'), "must force the packaged sql.js tier during this smoke" ); + assert.ok(src.includes("MAX_SERVER_OUTPUT_CHARS")); + assert.ok(!src.includes("while (tail.length > 80)"), "must not discard early startup proof"); assert.ok(src.indexOf("npm") < src.indexOf("spawn"), "pack+install must precede the boot spawn"); }); diff --git a/tests/unit/claude-code-parity.test.ts b/tests/unit/claude-code-parity.test.ts index 0e58e9a3f3..07a1e5e2d1 100644 --- a/tests/unit/claude-code-parity.test.ts +++ b/tests/unit/claude-code-parity.test.ts @@ -360,7 +360,7 @@ describe("ensureCacheControlOnLastUserMessage", () => { assert.deepEqual(body.messages[2].content[0].cache_control, { type: "ephemeral" }); }); - it("keeps an existing message breakpoint without adding another", () => { + it("keeps an existing message breakpoint and advances one to the last user message", () => { const body = { messages: [ { @@ -377,9 +377,33 @@ describe("ensureCacheControlOnLastUserMessage", () => { ], }; + ensureCacheControlOnLastUserMessage(body); ensureCacheControlOnLastUserMessage(body); - assert.equal(body.messages[1].content[0].cache_control, undefined); + assert.deepEqual(body.messages[0].content[0].cache_control, { type: "ephemeral" }); + assert.deepEqual(body.messages[1].content[0].cache_control, { type: "ephemeral" }); + assert.equal( + body.messages.flatMap((message) => message.content).filter((block) => block.cache_control) + .length, + 2 + ); + }); + + it("keeps a new tail breakpoint at 5m after an existing 5m breakpoint", () => { + const body = { + system: [ + { type: "text", text: "long", cache_control: { type: "ephemeral", ttl: "1h" } }, + { type: "text", text: "short", cache_control: { type: "ephemeral", ttl: "5m" } }, + ], + messages: [{ role: "user", content: [{ type: "text", text: "Follow up" }] }], + }; + + ensureCacheControlOnLastUserMessage(body); + + assert.deepEqual(body.messages[0].content[0].cache_control, { + type: "ephemeral", + ttl: "5m", + }); }); it("does not exceed four surviving system and message breakpoints", () => { @@ -452,6 +476,25 @@ describe("normalizeCacheControlTtl", () => { }); }); + it("defaults missing ttl to 5m after a 5m breakpoint", () => { + const body = { + system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral", ttl: "5m" } }], + messages: [ + { + role: "user", + content: [{ type: "text", text: "follow up", cache_control: { type: "ephemeral" } }], + }, + ], + }; + + normalizeCacheControlTtl(body); + + assert.deepEqual(body.messages[0].content[0].cache_control, { + type: "ephemeral", + ttl: "5m", + }); + }); + it("leaves blocks without cache_control untouched", () => { const body = { system: [{ type: "text", text: "no cache_control here" }], diff --git a/tests/unit/claude-to-gemini-consecutive-roles.test.ts b/tests/unit/claude-to-gemini-consecutive-roles.test.ts new file mode 100644 index 0000000000..eef412fc13 --- /dev/null +++ b/tests/unit/claude-to-gemini-consecutive-roles.test.ts @@ -0,0 +1,150 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { claudeToGeminiRequest } = + await import("../../open-sse/translator/request/claude-to-gemini.ts"); + +test("Claude -> Gemini merges consecutive user text turns into a single user turn", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [ + { role: "user", content: "hello" }, + { role: "user", content: [{ type: "text", text: "world" }] }, + ], + }, + false + ); + + assert.equal(result.contents.length, 1); + assert.equal(result.contents[0].role, "user"); + assert.deepEqual(result.contents[0].parts, [{ text: "hello" }, { text: "world" }]); +}); + +test("Claude -> Gemini merges tool_result and subsequent user instruction into single user turn", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [ + { role: "user", content: "Calculate 2+2" }, + { + role: "assistant", + content: [{ type: "text", text: "I will calculate that." }], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool_call_1", + content: "4", + }, + ], + }, + { + role: "user", + content: "Now add 10 to that result", + }, + ], + }, + false + ); + + // Contents must alternate properly and not have consecutive same-role turns + for (let i = 1; i < result.contents.length; i++) { + assert.notEqual( + result.contents[i].role, + result.contents[i - 1].role, + `Consecutive same-role detected at index ${i - 1} and ${i}: ${result.contents[i].role}` + ); + } + + // The last turn should be a merged user turn containing both the tool context and the text + const lastTurn = result.contents[result.contents.length - 1]; + assert.equal(lastTurn.role, "user"); + assert.equal(lastTurn.parts.length, 2); + assert.ok( + typeof (lastTurn.parts[0] as { text: string }).text === "string" && + (lastTurn.parts[0] as { text: string }).text.includes("previous_tool_result_context") + ); + assert.deepEqual(lastTurn.parts[1], { text: "Now add 10 to that result" }); +}); + +test("Claude -> Gemini preserves alternating conversation turns without spurious merging", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi! How can I help?" }, + { role: "user", content: "What is the capital of France?" }, + ], + }, + false + ); + + assert.equal(result.contents.length, 3); + assert.equal(result.contents[0].role, "user"); + assert.deepEqual(result.contents[0].parts, [{ text: "Hello" }]); + assert.equal(result.contents[1].role, "model"); + assert.deepEqual(result.contents[1].parts, [{ text: "Hi! How can I help?" }]); + assert.equal(result.contents[2].role, "user"); + assert.deepEqual(result.contents[2].parts, [{ text: "What is the capital of France?" }]); +}); + +test("Claude -> Gemini merges three or more consecutive user turns into a single user turn", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [ + { role: "user", content: "part 1" }, + { role: "user", content: "part 2" }, + { role: "user", content: "part 3" }, + ], + }, + false + ); + + assert.equal(result.contents.length, 1); + assert.equal(result.contents[0].role, "user"); + assert.deepEqual(result.contents[0].parts, [ + { text: "part 1" }, + { text: "part 2" }, + { text: "part 3" }, + ]); +}); + +test("Claude -> Gemini handles empty messages array without error", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [], + }, + false + ); + + assert.deepEqual(result.contents, []); +}); + +test("Claude -> Gemini merges consecutive assistant turns into a single model turn", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "response part 1" }, + { role: "assistant", content: [{ type: "text", text: "response part 2" }] }, + ], + }, + false + ); + + assert.equal(result.contents.length, 2); + assert.equal(result.contents[0].role, "user"); + assert.deepEqual(result.contents[0].parts, [{ text: "hello" }]); + assert.equal(result.contents[1].role, "model"); + assert.deepEqual(result.contents[1].parts, [ + { text: "response part 1" }, + { text: "response part 2" }, + ]); +}); diff --git a/tests/unit/cleanup-column-fix.test.mjs b/tests/unit/cleanup-column-fix.test.mjs index 13e48d5bac..060743021a 100644 --- a/tests/unit/cleanup-column-fix.test.mjs +++ b/tests/unit/cleanup-column-fix.test.mjs @@ -67,16 +67,19 @@ test("cleanup: has background scheduler (startCleanupScheduler)", () => { ); }); -test("cleanup: scheduler is wired into server-init.ts", () => { - const serverInitPath = path.resolve(import.meta.dirname, "../../src/server-init.ts"); - const serverInit = fs.readFileSync(serverInitPath, "utf-8"); +test("cleanup: scheduler is wired into instrumentation-node.ts", () => { + const instrumentationPath = path.resolve( + import.meta.dirname, + "../../src/instrumentation-node.ts" + ); + const instrumentation = fs.readFileSync(instrumentationPath, "utf-8"); assert.ok( - serverInit.includes('import { startCleanupScheduler } from "./lib/db/cleanup"'), - "server-init.ts must import startCleanupScheduler" + instrumentation.includes("startCleanupScheduler"), + "instrumentation-node.ts must import startCleanupScheduler" ); assert.ok( - serverInit.includes("startCleanupScheduler()"), - "server-init.ts must call startCleanupScheduler() at startup" + instrumentation.includes("startCleanupScheduler()"), + "instrumentation-node.ts must call startCleanupScheduler() at startup" ); }); diff --git a/tests/unit/cli-doctor-command.test.ts b/tests/unit/cli-doctor-command.test.ts index c10aa53256..4eedec0fdd 100644 --- a/tests/unit/cli-doctor-command.test.ts +++ b/tests/unit/cli-doctor-command.test.ts @@ -15,6 +15,8 @@ const ORIGINAL_STORAGE_ENCRYPTION_KEY = process.env.STORAGE_ENCRYPTION_KEY; interface DoctorCheck { name: string; status: string; + message?: string; + details?: Record; } interface DoctorResult { @@ -109,3 +111,162 @@ test("doctor fails when encrypted credentials exist without storage key", async assert.equal(getCheck(result, "Storage/encryption")?.status, "fail"); }); }); + +test("doctor probes the real machine-token endpoint without exposing the token", async () => { + await withDoctorEnv(async () => { + const originalFetch = globalThis.fetch; + let observedToken = ""; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + assert.match(url, /\/api\/cli\/whoami$/); + assert.equal(init?.redirect, "error"); + observedToken = new Headers(init?.headers).get("x-omniroute-cli-token") || ""; + return new Response(JSON.stringify({ authenticated: true }), { + status: observedToken ? 200 : 401, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const check = await checkMachineTokenAuth({ + livenessUrl: "http://127.0.0.1:21999/api/health/degradation", + }); + + assert.equal(check.status, "ok"); + assert.match(observedToken, /^[0-9a-f]{64}$/); + assert.ok( + !JSON.stringify(check).includes(observedToken), + "doctor output must never expose token" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +test("doctor only sends the machine token to supported loopback URL shapes", async () => { + const originalFetch = globalThis.fetch; + const observedUrls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + observedUrls.push(String(input)); + assert.equal(init?.redirect, "error"); + assert.match(new Headers(init?.headers).get("x-omniroute-cli-token") || "", /^[0-9a-f]{64}$/); + return new Response(null, { status: 200 }); + }) as typeof fetch; + + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const loopbackUrls = [ + "http://localhost:21999/health", + "http://127.0.0.42:21999/health", + "http://[::1]:21999/health", + "http://[::ffff:127.0.0.1]:21999/health", + ]; + + for (const livenessUrl of loopbackUrls) { + const check = await checkMachineTokenAuth({ livenessUrl }); + assert.equal(check.status, "ok", livenessUrl); + } + assert.equal(observedUrls.length, loopbackUrls.length); + assert.ok(observedUrls.every((url) => url.endsWith("/api/cli/whoami"))); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("doctor refuses remote, deceptive, credential-bearing, and unsupported probe URLs", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response(null, { status: 200 }); + }) as typeof fetch; + + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const rejectedUrls = [ + "https://remote.example.test/health", + "http://localhost.example.test/health", + "http://127.0.0.1.example.test/health", + "http://localhost@remote.example.test/health", + "http://token-user:credential-sentinel@127.0.0.1:21999/health", + "ftp://localhost:21999/health", + "http://0.0.0.0:21999/health", + "http://[::2]:21999/health", + ]; + + for (const livenessUrl of rejectedUrls) { + const check = await checkMachineTokenAuth({ livenessUrl }); + assert.equal(check.status, "warn", livenessUrl); + assert.equal(check.details?.accepted, false); + assert.equal(check.details?.tokenExposed, false); + assert.ok(!JSON.stringify(check).includes("credential-sentinel")); + } + assert.equal(fetchCalls, 0, "rejected targets must never receive a fetch call"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("doctor never follows a machine-token redirect to another origin", async () => { + const originalFetch = globalThis.fetch; + let crossOriginRequests = 0; + let crossOriginTokenObserved = false; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + if (init?.redirect !== "error") { + crossOriginRequests += 1; + crossOriginTokenObserved = new Headers(init?.headers).has("x-omniroute-cli-token"); + return new Response(null, { status: 200 }); + } + throw new TypeError("redirect blocked"); + }) as typeof fetch; + + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const check = await checkMachineTokenAuth({ + livenessUrl: "http://127.0.0.1:21999/redirect-to-other-origin", + }); + + assert.equal(check.status, "warn"); + assert.equal(crossOriginRequests, 0); + assert.equal(crossOriginTokenObserved, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("doctor gives connect guidance when the server rejects a machine token", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(null, { status: 401 })) as typeof fetch; + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const check = await checkMachineTokenAuth({ + livenessUrl: "http://127.0.0.1:21999/api/health/degradation", + }); + assert.equal(check.status, "warn"); + assert.match(check.message || "", /omniroute connect/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("doctor reports explicitly disabled machine-token auth without probing", async () => { + const previous = process.env.OMNIROUTE_DISABLE_CLI_TOKEN; + const originalFetch = globalThis.fetch; + process.env.OMNIROUTE_DISABLE_CLI_TOKEN = "true"; + globalThis.fetch = (async () => { + throw new Error("fetch should not run"); + }) as typeof fetch; + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const check = await checkMachineTokenAuth(); + assert.equal(check.status, "warn"); + assert.equal(check.details?.disabled, true); + assert.match(check.message || "", /disabled/i); + } finally { + globalThis.fetch = originalFetch; + if (previous === undefined) delete process.env.OMNIROUTE_DISABLE_CLI_TOKEN; + else process.env.OMNIROUTE_DISABLE_CLI_TOKEN = previous; + } +}); diff --git a/tests/unit/cli-env-collision.test.ts b/tests/unit/cli-env-collision.test.ts new file mode 100644 index 0000000000..cb11ac4474 --- /dev/null +++ b/tests/unit/cli-env-collision.test.ts @@ -0,0 +1,130 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const BIN = path.join(ROOT, "bin", "omniroute.mjs"); + +function layout() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-env-collision-")); + const home = path.join(tmp, "home"); + const dataDir = path.join(tmp, "data"); + const cwd = path.join(tmp, "cwd"); + const appDataDir = + process.platform === "win32" + ? path.join(tmp, "appdata", "omniroute") + : path.join(home, ".omniroute"); + fs.mkdirSync(dataDir, { recursive: true }); + fs.mkdirSync(appDataDir, { recursive: true }); + fs.mkdirSync(cwd, { recursive: true }); + return { tmp, home, dataDir, cwd }; +} + +function runCli( + { tmp, home, dataDir, cwd }: ReturnType, + extraEnv: Record = {} +) { + const cleanEnv = { ...process.env }; + for (const key of ["OMNIROUTE_BASE_URL", "PORT", "STORAGE_ENCRYPTION_KEY"]) { + delete cleanEnv[key]; + } + return spawnSync("node", [BIN, "env", "show", "--json"], { + cwd, + env: { + ...cleanEnv, + DATA_DIR: dataDir, + HOME: home, + USERPROFILE: home, + APPDATA: path.join(tmp, "appdata"), + CI: "1", + OMNIROUTE_CLI_SKIP_REPO_ENV: "1", + OMNIROUTE_NO_UPDATE_NOTIFIER: "1", + ...extraEnv, + }, + encoding: "utf-8", + timeout: 60_000, + }); +} + +test("a key masked by an earlier .env is named, with both files and without its value", () => { + const dirs = layout(); + try { + fs.writeFileSync( + path.join(dirs.dataDir, ".env"), + "OMNIROUTE_BASE_URL=https://data.example/v1\n" + ); + fs.writeFileSync(path.join(dirs.cwd, ".env"), "OMNIROUTE_BASE_URL=https://cwd.example/v1\n"); + + const stderr = runCli(dirs).stderr ?? ""; + + assert.match(stderr, /OMNIROUTE_BASE_URL/); + assert.ok(stderr.includes(path.join(dirs.cwd, ".env")), `ignored file named: ${stderr}`); + assert.ok(stderr.includes(path.join(dirs.dataDir, ".env")), `winning file named: ${stderr}`); + assert.ok(!stderr.includes("cwd.example"), "the ignored value must never be printed"); + assert.ok(!stderr.includes("data.example"), "the winning value must never be printed"); + } finally { + fs.rmSync(dirs.tmp, { recursive: true, force: true }); + } +}); + +test("a key each file declares once says nothing", () => { + const dirs = layout(); + try { + fs.writeFileSync( + path.join(dirs.dataDir, ".env"), + "OMNIROUTE_BASE_URL=https://data.example/v1\n" + ); + fs.writeFileSync(path.join(dirs.cwd, ".env"), "PORT=34567\n"); + + const stderr = runCli(dirs).stderr ?? ""; + assert.ok(!/OMNIROUTE_BASE_URL|PORT/.test(stderr), `nothing to report: ${stderr}`); + } finally { + fs.rmSync(dirs.tmp, { recursive: true, force: true }); + } +}); + +test("a key the environment already set is reported too — that is #6194", () => { + const dirs = layout(); + try { + fs.writeFileSync( + path.join(dirs.dataDir, ".env"), + "OMNIROUTE_BASE_URL=https://data.example/v1\n" + ); + + const stderr = runCli(dirs, { OMNIROUTE_BASE_URL: "https://shell.example/v1" }).stderr ?? ""; + + assert.match(stderr, /OMNIROUTE_BASE_URL/); + assert.ok(stderr.includes(path.join(dirs.dataDir, ".env")), `inert file named: ${stderr}`); + assert.match(stderr, /environment/); + assert.ok(!stderr.includes("shell.example"), "the winning value must never be printed"); + assert.ok(!stderr.includes("data.example"), "the ignored value must never be printed"); + } finally { + fs.rmSync(dirs.tmp, { recursive: true, force: true }); + } +}); + +test("an unreadable .env is reported instead of being swallowed", () => { + const dirs = layout(); + try { + // A directory named `.env` passes existsSync and makes readFileSync throw + // EISDIR for any user, root included — unlike chmod 000. + fs.mkdirSync(path.join(dirs.cwd, ".env"), { recursive: true }); + fs.writeFileSync( + path.join(dirs.dataDir, ".env"), + "OMNIROUTE_BASE_URL=https://data.example/v1\n" + ); + + const result = runCli(dirs); + assert.equal(result.status, 0, "an unreadable .env must stay non-fatal"); + assert.ok( + (result.stderr ?? "").includes(path.join(dirs.cwd, ".env")), + `the unreadable file should be named: ${result.stderr}` + ); + } finally { + fs.rmSync(dirs.tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/cli-expanded-commands.test.ts b/tests/unit/cli-expanded-commands.test.ts index 1327351555..e631a084c6 100644 --- a/tests/unit/cli-expanded-commands.test.ts +++ b/tests/unit/cli-expanded-commands.test.ts @@ -319,8 +319,8 @@ test("test-provider --all-providers consumes the connections envelope", async () if (url.includes("/api/providers?limit=200")) { return Promise.resolve(new Response(JSON.stringify({ connections }), { status: 200 })); } - if (url.includes("/api/v1/providers/test")) { - return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 201 })); + if (url.includes("/api/providers/") && url.includes("/test")) { + return Promise.resolve(new Response(JSON.stringify({ valid: true }), { status: 200 })); } throw new Error(`unexpected URL: ${url}`); }) as typeof fetch; diff --git a/tests/unit/cli-machine-token.test.ts b/tests/unit/cli-machine-token.test.ts index 833bf67a0c..35170f5a36 100644 --- a/tests/unit/cli-machine-token.test.ts +++ b/tests/unit/cli-machine-token.test.ts @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import crypto from "node:crypto"; +import http from "node:http"; import { execFileSync } from "node:child_process"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -12,6 +13,37 @@ test("cliToken.mjs pode ser importado sem erro", async () => { assert.equal(mod.CLI_TOKEN_HEADER, "x-omniroute-cli-token"); }); +test("packaged CLI derives the same current machine token as the server", async () => { + const salt = `cli-machine-token-${process.pid}`; + const previousSalt = process.env.OMNIROUTE_CLI_SALT; + process.env.OMNIROUTE_CLI_SALT = salt; + try { + const { getCliToken } = await import(`../../bin/cli/utils/cliToken.mjs?current=${Date.now()}`); + const { getMachineTokenSync } = await import("../../src/lib/machineToken.ts"); + const token = await getCliToken(); + + assert.match(token, /^[0-9a-f]{64}$/, "CLI token must be a non-empty HMAC-SHA256 digest"); + assert.equal(token, getMachineTokenSync(salt)); + } finally { + if (previousSalt === undefined) delete process.env.OMNIROUTE_CLI_SALT; + else process.env.OMNIROUTE_CLI_SALT = previousSalt; + } +}); + +test("getCliToken returns an empty string when machine-id derivation is unavailable", async () => { + const { deriveCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); + assert.equal(deriveCliToken({}, "test-salt"), ""); + assert.equal(deriveCliToken({ default: { machineIdSync: () => "" } }, "test-salt"), ""); + const throwingModule = { + default: { + machineIdSync: () => { + throw new Error("unavailable"); + }, + }, + }; + assert.equal(deriveCliToken(throwingModule, "test-salt"), ""); +}); + test("getCliToken retorna string de 64 chars ou string vazia", async () => { const { getCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); const token = await getCliToken(); @@ -97,6 +129,124 @@ test("OMNIROUTE_CLI_TOKEN env sobrescreve token gerado em apiFetch", async () => } }); +test("apiFetch never sends an implicit machine token to remote contexts", async () => { + const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; + const originalOverride = process.env.OMNIROUTE_CLI_TOKEN; + process.env.OMNIROUTE_BASE_URL = "https://remote.example.test"; + delete process.env.OMNIROUTE_CLI_TOKEN; + try { + const { buildHeaders } = await import(`../../bin/cli/api.mjs?remote=${Date.now()}`); + const headers = await buildHeaders({}); + assert.equal(headers.has("x-omniroute-cli-token"), false); + } finally { + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + if (originalOverride === undefined) delete process.env.OMNIROUTE_CLI_TOKEN; + else process.env.OMNIROUTE_CLI_TOKEN = originalOverride; + } +}); + +test("apiFetch sends the implicit machine token only to loopback destinations", async () => { + const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; + const originalOverride = process.env.OMNIROUTE_CLI_TOKEN; + process.env.OMNIROUTE_BASE_URL = "http://127.0.0.1:20128"; + delete process.env.OMNIROUTE_CLI_TOKEN; + try { + const [{ buildHeaders, isLoopbackUrl }, { getCliToken }] = await Promise.all([ + import(`../../bin/cli/api.mjs?loopback=${Date.now()}`), + import("../../bin/cli/utils/cliToken.mjs"), + ]); + assert.equal(isLoopbackUrl("http://localhost:20128"), true); + assert.equal(isLoopbackUrl("http://127.0.0.42:20128"), true); + assert.equal(isLoopbackUrl("http://[::1]:20128"), true); + assert.equal(isLoopbackUrl("https://remote.example.test"), false); + const headers = await buildHeaders({}); + assert.equal(headers.get("x-omniroute-cli-token"), await getCliToken()); + } finally { + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + if (originalOverride === undefined) delete process.env.OMNIROUTE_CLI_TOKEN; + else process.env.OMNIROUTE_CLI_TOKEN = originalOverride; + } +}); + +test("CLI-token overrides are also suppressed for remote contexts", async () => { + const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; + const originalOverride = process.env.OMNIROUTE_CLI_TOKEN; + process.env.OMNIROUTE_BASE_URL = "https://remote.example.test"; + process.env.OMNIROUTE_CLI_TOKEN = "must-not-leave-loopback"; + try { + const { buildHeaders } = await import(`../../bin/cli/api.mjs?override=${Date.now()}`); + const headers = await buildHeaders({ cliToken: "also-local-only" }); + assert.equal(headers.has("x-omniroute-cli-token"), false); + } finally { + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + if (originalOverride === undefined) delete process.env.OMNIROUTE_CLI_TOKEN; + else process.env.OMNIROUTE_CLI_TOKEN = originalOverride; + } +}); + +test("absolute remote URLs cannot inherit a local context machine token", async () => { + const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; + const originalOverride = process.env.OMNIROUTE_CLI_TOKEN; + const originalFetch = globalThis.fetch; + process.env.OMNIROUTE_BASE_URL = "http://127.0.0.1:20128"; + process.env.OMNIROUTE_CLI_TOKEN = "must-stay-local"; + let receivedHeaders: Headers | null = null; + globalThis.fetch = (async (_url, init) => { + receivedHeaders = new Headers(init?.headers); + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const { apiFetch } = await import(`../../bin/cli/api.mjs?absolute=${Date.now()}`); + await apiFetch("https://remote.example.test/probe", { retry: false }); + assert.equal(receivedHeaders?.has("x-omniroute-cli-token"), false); + } finally { + globalThis.fetch = originalFetch; + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + if (originalOverride === undefined) delete process.env.OMNIROUTE_CLI_TOKEN; + else process.env.OMNIROUTE_CLI_TOKEN = originalOverride; + } +}); + +test("apiFetch refuses redirects while carrying a local machine token", async () => { + const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; + const originalOverride = process.env.OMNIROUTE_CLI_TOKEN; + let redirectedRequests = 0; + const destination = http.createServer((_request, response) => { + redirectedRequests += 1; + response.end("unexpected"); + }); + const redirector = http.createServer((_request, response) => { + const destinationAddress = destination.address(); + assert.ok(destinationAddress && typeof destinationAddress === "object"); + response.writeHead(302, { location: `http://127.0.0.1:${destinationAddress.port}/target` }); + response.end(); + }); + await new Promise((resolve) => destination.listen(0, "127.0.0.1", resolve)); + await new Promise((resolve) => redirector.listen(0, "127.0.0.1", resolve)); + const redirectorAddress = redirector.address(); + assert.ok(redirectorAddress && typeof redirectorAddress === "object"); + process.env.OMNIROUTE_BASE_URL = `http://127.0.0.1:${redirectorAddress.port}`; + process.env.OMNIROUTE_CLI_TOKEN = "redirect-secret"; + try { + const { apiFetch } = await import(`../../bin/cli/api.mjs?redirect=${Date.now()}`); + await assert.rejects(() => apiFetch("/redirect", { retry: false }), /fetch failed/i); + assert.equal(redirectedRequests, 0); + } finally { + await Promise.all([ + new Promise((resolve) => redirector.close(() => resolve())), + new Promise((resolve) => destination.close(() => resolve())), + ]); + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + if (originalOverride === undefined) delete process.env.OMNIROUTE_CLI_TOKEN; + else process.env.OMNIROUTE_CLI_TOKEN = originalOverride; + } +}); + // --- testes server-side: isLoopback --- test("isLoopback aceita 127.0.0.1", async () => { diff --git a/tests/unit/cli-provider-test-routes-10570.test.ts b/tests/unit/cli-provider-test-routes-10570.test.ts new file mode 100644 index 0000000000..386a3e448f --- /dev/null +++ b/tests/unit/cli-provider-test-routes-10570.test.ts @@ -0,0 +1,177 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import Database from "better-sqlite3"; + +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_FETCH = globalThis.fetch; +const ORIGINAL_API_KEY = process.env.OMNIROUTE_API_KEY; + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function withCliEnv(fn: (dataDir: string) => Promise) { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-routes-10570-")); + process.env.DATA_DIR = dataDir; + process.env.OMNIROUTE_API_KEY = "test-management-key"; + delete process.env.STORAGE_ENCRYPTION_KEY; + try { + await fn(dataDir); + } finally { + globalThis.fetch = ORIGINAL_FETCH; + fs.rmSync(dataDir, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + if (ORIGINAL_API_KEY === undefined) delete process.env.OMNIROUTE_API_KEY; + else process.env.OMNIROUTE_API_KEY = ORIGINAL_API_KEY; + } +} + +async function createConnection( + dataDir: string, + input: { provider?: string; name?: string; apiKey?: string } = {} +) { + const { ensureProviderSchema, upsertApiKeyProviderConnection } = + await import("../../bin/cli/provider-store.mjs"); + const db = new Database(path.join(dataDir, "storage.sqlite")); + ensureProviderSchema(db); + const connection = upsertApiKeyProviderConnection(db, { + provider: input.provider ?? "custom-openai-compatible", + name: input.name ?? "Custom Connection", + apiKey: input.apiKey ?? "test-key", + }); + db.close(); + return connection; +} + +test("omniroute test resolves a connection and calls its server-owned test route", async () => { + await withCliEnv(async () => { + const requests: Array<{ path: string; method: string }> = []; + globalThis.fetch = (async (input, init) => { + const url = new URL(String(input)); + const method = String(init?.method ?? "GET").toUpperCase(); + requests.push({ path: `${url.pathname}${url.search}`, method }); + if (url.pathname === "/api/health") return jsonResponse({ status: "ok" }); + if (url.pathname === "/api/providers") { + return jsonResponse({ + connections: [ + { + id: "conn/custom 1", + provider: "custom-openai-compatible", + name: "Custom Connection", + authType: "apikey", + isActive: true, + defaultModel: "custom-model", + }, + ], + total: 1, + }); + } + if (url.pathname === "/api/providers/conn%2Fcustom%201/test" && method === "POST") { + return jsonResponse({ valid: true, error: null, latencyMs: 7 }); + } + return jsonResponse({ error: "unexpected route" }, 404); + }) as typeof fetch; + + const { runTestProviderCommand } = await import("../../bin/cli/commands/test-provider.mjs"); + const exitCode = await runTestProviderCommand("custom-openai-compatible", undefined, { + json: true, + }); + + assert.equal(exitCode, 0); + assert.deepEqual(requests, [ + { path: "/api/health", method: "GET" }, + { path: "/api/providers?limit=200", method: "GET" }, + { path: "/api/providers/conn%2Fcustom%201/test", method: "POST" }, + ]); + }); +}); + +test("omniroute test --all-providers consumes the current connections response shape", async () => { + await withCliEnv(async () => { + const requests: Array<{ path: string; method: string }> = []; + globalThis.fetch = (async (input, init) => { + const url = new URL(String(input)); + const method = String(init?.method ?? "GET").toUpperCase(); + requests.push({ path: `${url.pathname}${url.search}`, method }); + if (url.pathname === "/api/health") return jsonResponse({ status: "ok" }); + if (url.pathname === "/api/providers") { + return jsonResponse({ + connections: [ + { + id: "conn-all-1", + provider: "custom-openai-compatible", + name: "Custom Connection", + authType: "apikey", + isActive: true, + defaultModel: "custom-model", + }, + ], + total: 1, + }); + } + if (url.pathname === "/api/providers/conn-all-1/test" && method === "POST") { + return jsonResponse({ valid: true, error: null }); + } + return jsonResponse({ error: "unexpected route" }, 404); + }) as typeof fetch; + + const { runTestProviderCommand } = await import("../../bin/cli/commands/test-provider.mjs"); + const exitCode = await runTestProviderCommand(undefined, undefined, { + allProviders: true, + json: true, + }); + + assert.equal(exitCode, 0); + assert.deepEqual(requests, [ + { path: "/api/health", method: "GET" }, + { path: "/api/providers?limit=200", method: "GET" }, + { path: "/api/providers/conn-all-1/test", method: "POST" }, + ]); + }); +}); + +test("the interactive all-provider view uses the same connection-owned test route", async () => { + const source = await fs.promises.readFile( + new URL("../../bin/cli/tui/ProvidersTestAll.jsx", import.meta.url), + "utf8" + ); + assert.match(source, /import \{ apiFetch \} from "\.\.\/api\.mjs"/); + assert.match(source, /connectionId: p\.connectionId \?\? p\.id/); + assert.match(source, /apiFetch\(/); + assert.match(source, /\/api\/providers\/\$\{encodeURIComponent\(connectionId\)\}\/test/); + assert.match(source, /data\.valid/); + assert.doesNotMatch(source, /api\/v1\/providers\/test/); +}); + +test("providers test-all falls back to the server for unsupported custom API-key providers", async () => { + await withCliEnv(async (dataDir) => { + const connection = await createConnection(dataDir); + const requests: Array<{ path: string; method: string }> = []; + globalThis.fetch = (async (input, init) => { + const url = new URL(String(input)); + const method = String(init?.method ?? "GET").toUpperCase(); + requests.push({ path: url.pathname, method }); + if (url.pathname === "/api/health") return jsonResponse({ status: "ok" }); + if (url.pathname === `/api/providers/${connection.id}/test` && method === "POST") { + return jsonResponse({ valid: true, error: null, latencyMs: 9 }); + } + return jsonResponse({ error: "unexpected route" }, 404); + }) as typeof fetch; + + const { runTestAllCommand } = await import("../../bin/cli/commands/providers.mjs"); + const exitCode = await runTestAllCommand({ json: true }); + + assert.equal(exitCode, 0); + assert.deepEqual(requests, [ + { path: "/api/health", method: "GET" }, + { path: `/api/providers/${connection.id}/test`, method: "POST" }, + ]); + }); +}); diff --git a/tests/unit/cli/autostart-linux.test.ts b/tests/unit/cli/autostart-linux.test.ts index 4e337652f9..26bb63de20 100644 --- a/tests/unit/cli/autostart-linux.test.ts +++ b/tests/unit/cli/autostart-linux.test.ts @@ -101,3 +101,41 @@ test("Linux enable path prefers graphical desktop autostart over systemd", () => assert.ok(systemdBranch > -1, "expected a systemd fallback branch"); assert.ok(graphicalBranch < systemdBranch, "graphical autostart should be preferred"); }); + +test("systemd branch writes Type=notify sd_notify directives (headless, stubs succeed)", async () => { + if (process.platform !== "linux") return; + const stubBin = join(tmpDir, "stub-bin"); + const unitPath = join(tmpDir, ".config", "systemd", "user", "omniroute.service"); + const envKeys = ["DISPLAY", "WAYLAND_DISPLAY", "XDG_CURRENT_DESKTOP"] as const; + const savedEnv: Record = {}; + for (const key of envKeys) savedEnv[key] = process.env[key]; + + try { + for (const key of envKeys) delete process.env[key]; + for (const name of ["systemctl", "loginctl"]) { + writeFileSync(join(stubBin, name), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + } + // autostart.mjs reads process.env / runs the stubs at call time, so a + // cached module is fine. + const { enable } = await import("../../../bin/cli/tray/autostart.mjs"); + + const ok = enable(); + assert.equal(ok, true, "enable() should succeed through the systemd branch"); + assert.ok(existsSync(unitPath), "systemd unit should be written"); + + const unit = readFileSync(unitPath, "utf8"); + assert.match(unit, /Type=notify/); + assert.match(unit, /NotifyAccess=all/); + assert.match(unit, /WatchdogSec=180/); + assert.match(unit, /TimeoutStartSec=300/); + assert.match(unit, /Restart=on-failure/); + } finally { + for (const key of envKeys) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + for (const name of ["systemctl", "loginctl"]) { + writeFileSync(join(stubBin, name), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + } + } +}); diff --git a/tests/unit/cline-workos-auth-token-shape.test.ts b/tests/unit/cline-workos-auth-token-shape.test.ts index 94c73148cd..ab637ac930 100644 --- a/tests/unit/cline-workos-auth-token-shape.test.ts +++ b/tests/unit/cline-workos-auth-token-shape.test.ts @@ -110,3 +110,16 @@ test("DefaultExecutor.buildHeaders uses the cline workos auth token shape", () = assert.equal(headers["X-Title"], "Cline"); assert.equal(headers["X-Task-ID"], "task-from-client"); }); + +test("DefaultExecutor labels internal health checks separately from user traffic", () => { + const executor = new DefaultExecutor("cline"); + const headers = executor.buildHeaders({ apiKey: "tok-abc" }, true, { + "X-Internal-Test": "combo-health-check", + }); + + assert.equal(headers["X-CLIENT-TYPE"], "omniroute-internal-health-check"); + + // BaseExecutor reapplies the required protocol headers immediately before dispatch. + applyClineProtocolHeaders(headers, { taskId: headers["X-Task-ID"] }); + assert.equal(headers["X-CLIENT-TYPE"], "omniroute-internal-health-check"); +}); diff --git a/tests/unit/codex-account-cooldown-write.test.ts b/tests/unit/codex-account-cooldown-write.test.ts new file mode 100644 index 0000000000..c24347afb7 --- /dev/null +++ b/tests/unit/codex-account-cooldown-write.test.ts @@ -0,0 +1,318 @@ +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-codex-cooldown-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "codex-cooldown-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const codexAccount = await import("../../open-sse/services/codexAccount/index.ts"); +const codexFailover = await import("../../open-sse/handlers/chatCore/codexFailover.ts"); + +async function resetStorage(): Promise { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +interface SeededConnection { + id: string; + testStatus?: unknown; + rateLimitedUntil?: unknown; + lastError?: unknown; + errorCode?: unknown; + backoffLevel?: unknown; + providerSpecificData: Record; +} + +interface PersistedConnection extends SeededConnection { + providerSpecificData: { + codexScopeRateLimitedUntil: Record; + codexScopeRateLimitSource?: unknown; + codexQuotaStateByScope?: unknown; + codexQuotaState?: unknown; + codexExhaustedWindowByScope?: unknown; + unrelated?: unknown; + }; +} + +async function seedCodexConnection(): Promise { + return providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-cooldown-writer", + email: "codex-cooldown@example.com", + apiKey: null, + accessToken: "codex-cooldown-access", + refreshToken: "codex-cooldown-refresh", + providerSpecificData: { + unrelated: { retained: true }, + }, + }) as unknown as Promise; +} + +async function readConnection(id: string): Promise { + const connection = await providersDb.getProviderConnectionById(id); + assert.ok(connection); + return connection as unknown as PersistedConnection; +} + +test.beforeEach(resetStorage); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("persisting Codex and Spark child cooldowns retains sibling and unrelated state", async () => { + const connection = await seedCodexConnection(); + const codexUntil = new Date(Date.now() + 60_000).toISOString(); + const sparkUntil = new Date(Date.now() + 120_000).toISOString(); + const parentBefore = await readConnection(connection.id); + + await codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.5", + rateLimitedUntil: codexUntil, + }); + const result = await codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.3-codex-spark", + rateLimitedUntil: sparkUntil, + }); + const persisted = await readConnection(connection.id); + + assert.deepEqual(result.providerSpecificData.codexScopeRateLimitedUntil, { + codex: codexUntil, + spark: sparkUntil, + }); + assert.deepEqual(persisted.providerSpecificData.codexScopeRateLimitedUntil, { + codex: codexUntil, + spark: sparkUntil, + }); + assert.deepEqual(persisted.providerSpecificData.unrelated, { retained: true }); + assert.equal(persisted.testStatus, parentBefore.testStatus); + assert.equal(persisted.rateLimitedUntil, parentBefore.rateLimitedUntil); + assert.equal(persisted.errorCode, parentBefore.errorCode); + assert.equal(persisted.backoffLevel, parentBefore.backoffLevel); +}); + +test("chatCore failover mirrors persisted child state into the failed credential snapshot", async () => { + const connection = await seedCodexConnection(); + const parentBefore = await readConnection(connection.id); + const sparkUntil = new Date(Date.now() + 120_000).toISOString(); + const credentials = { + connectionId: connection.id, + providerSpecificData: connection.providerSpecificData, + }; + + await codexFailover.markCodexScopeRateLimited({ + failedConnectionId: connection.id, + model: "gpt-5.3-codex-spark", + rateLimitedUntil: sparkUntil, + credentials, + }); + const persisted = await readConnection(connection.id); + + assert.equal(persisted.testStatus, parentBefore.testStatus); + assert.equal(persisted.rateLimitedUntil, parentBefore.rateLimitedUntil); + assert.equal(persisted.lastError, parentBefore.lastError); + assert.equal(persisted.errorCode, parentBefore.errorCode); + assert.equal(persisted.backoffLevel, parentBefore.backoffLevel); + assert.equal(persisted.providerSpecificData.codexScopeRateLimitedUntil.spark, sparkUntil); + assert.deepEqual(credentials.providerSpecificData, persisted.providerSpecificData); +}); + +function quotaHeaders(resetAt5h: string, resetAt7d: string, weeklyUsage = "10") { + return { + "x-codex-5h-usage": "95", + "x-codex-5h-limit": "100", + "x-codex-5h-reset-at": resetAt5h, + "x-codex-7d-usage": weeklyUsage, + "x-codex-7d-limit": "100", + "x-codex-7d-reset-at": resetAt7d, + }; +} + +test("Codex and Spark quota responses retain independent scoped snapshots across restart", async () => { + const connection = await seedCodexConnection(); + const codexReset5h = new Date(Date.now() + 60_000).toISOString(); + const codexReset7d = new Date(Date.now() + 600_000).toISOString(); + const sparkReset5h = new Date(Date.now() + 120_000).toISOString(); + const sparkReset7d = new Date(Date.now() + 1_200_000).toISOString(); + + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(codexReset5h, codexReset7d), + status: 200, + }); + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.3-codex-spark", + headers: quotaHeaders(sparkReset5h, sparkReset7d), + status: 200, + }); + + core.resetDbInstance(); + const persisted = await readConnection(connection.id); + const byScope = persisted.providerSpecificData.codexQuotaStateByScope as Record< + string, + Record + >; + + assert.equal(byScope.codex.resetAt5h, codexReset5h); + assert.equal(byScope.spark.resetAt5h, sparkReset5h); + assert.equal( + (persisted.providerSpecificData.codexQuotaState as Record).scope, + "spark" + ); + assert.deepEqual(persisted.providerSpecificData.unrelated, { retained: true }); +}); + +test("concurrent Codex and Spark quota responses retain both scoped snapshots", async () => { + const connection = await seedCodexConnection(); + const codexReset5h = new Date(Date.now() + 60_000).toISOString(); + const sparkReset5h = new Date(Date.now() + 120_000).toISOString(); + const reset7d = new Date(Date.now() + 600_000).toISOString(); + + await Promise.all([ + codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(codexReset5h, reset7d), + status: 200, + }), + codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.3-codex-spark", + headers: quotaHeaders(sparkReset5h, reset7d), + status: 200, + }), + ]); + const persisted = await readConnection(connection.id); + const byScope = persisted.providerSpecificData.codexQuotaStateByScope as Record< + string, + Record + >; + + assert.equal(byScope.codex.resetAt5h, codexReset5h); + assert.equal(byScope.spark.resetAt5h, sparkReset5h); +}); + +test("header-derived exhausted reset survives fallback cooldown persistence", async () => { + const connection = await seedCodexConnection(); + const exactReset5h = new Date(Date.now() + 30_000).toISOString(); + const reset7d = new Date(Date.now() + 600_000).toISOString(); + const fallbackUntil = new Date(Date.now() + 60_000).toISOString(); + + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(exactReset5h, reset7d), + status: 429, + }); + await codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.5", + rateLimitedUntil: fallbackUntil, + }); + const persisted = await readConnection(connection.id); + + assert.equal(persisted.providerSpecificData.codexScopeRateLimitedUntil.codex, exactReset5h); + assert.equal( + (persisted.providerSpecificData.codexExhaustedWindowByScope as Record).codex, + "5h" + ); + assert.equal( + (persisted.providerSpecificData.codexScopeRateLimitSource as Record).codex, + "quota_reset" + ); +}); + +test("a successful quota observation clears earlier exhaustion for only that child", async () => { + const connection = await seedCodexConnection(); + const futureReset5h = new Date(Date.now() + 60_000).toISOString(); + const futureReset7d = new Date(Date.now() + 600_000).toISOString(); + + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(futureReset5h, futureReset7d), + status: 429, + }); + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.3-codex-spark", + headers: quotaHeaders(futureReset5h, futureReset7d), + status: 429, + }); + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(futureReset5h, futureReset7d), + status: 200, + }); + + const persisted = await readConnection(connection.id); + const exhaustedByScope = persisted.providerSpecificData.codexExhaustedWindowByScope as Record< + string, + unknown + >; + + assert.equal(exhaustedByScope.codex, undefined); + assert.equal(exhaustedByScope.spark, "5h"); +}); + +test("a newer fallback supersedes an expired authoritative reset", async () => { + const connection = await seedCodexConnection(); + const expiredReset = new Date(Date.now() - 60_000).toISOString(); + const fallbackUntil = new Date(Date.now() + 60_000).toISOString(); + await providersDb.updateCodexScopedQuotaState(connection.id, "codex", { + rateLimitedUntil: expiredReset, + rateLimitSource: "quota_reset", + }); + + await codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.5", + rateLimitedUntil: fallbackUntil, + }); + const persisted = await readConnection(connection.id); + + assert.equal(persisted.providerSpecificData.codexScopeRateLimitedUntil.codex, fallbackUntil); + assert.equal( + (persisted.providerSpecificData.codexScopeRateLimitSource as Record).codex, + "fallback" + ); +}); + +test("concurrent Codex and Spark child cooldown writes retain both scopes", async () => { + const connection = await seedCodexConnection(); + const codexUntil = new Date(Date.now() + 60_000).toISOString(); + const sparkUntil = new Date(Date.now() + 120_000).toISOString(); + + await Promise.all([ + codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.5", + rateLimitedUntil: codexUntil, + }), + codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.3-codex-spark", + rateLimitedUntil: sparkUntil, + }), + ]); + const persisted = await readConnection(connection.id); + + assert.deepEqual(persisted.providerSpecificData.codexScopeRateLimitedUntil, { + codex: codexUntil, + spark: sparkUntil, + }); + assert.deepEqual(persisted.providerSpecificData.unrelated, { retained: true }); +}); diff --git a/tests/unit/codex-account-pool.test.ts b/tests/unit/codex-account-pool.test.ts new file mode 100644 index 0000000000..c11cc8db2e --- /dev/null +++ b/tests/unit/codex-account-pool.test.ts @@ -0,0 +1,279 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const codexAccount = await import("../../open-sse/services/codexAccount/index.ts"); + +const SPARK_MODEL = "gpt-5.3-codex-spark"; +const SOL_MODEL = "gpt-5.5"; + +function futureTimestamp(offsetMs = 60_000): string { + return new Date(Date.now() + offsetMs).toISOString(); +} + +test("one persisted parent creates same-interface parent, Codex, and Spark accounts", () => { + const connection = { + id: "codex-parent-1", + provider: "codex", + providerSpecificData: { + accessToken: "must remain on the parent connection", + }, + }; + + const pool = codexAccount.createCodexAccountPool(connection); + + assert.equal(pool.accounts.length, 3); + assert.equal(pool.parent.scope, null); + assert.equal(pool.parent.kind, "parent"); + assert.deepEqual( + pool.children.map((account) => account.scope), + ["codex", "spark"] + ); + for (const account of pool.accounts) { + assert.strictEqual(account.connection, connection); + assert.equal(account.connectionId, connection.id); + assert.deepEqual(account.key.parentConnectionId, connection.id); + assert.equal("accessToken" in account, false); + assert.equal("id" in account, false); + } + assert.deepEqual( + pool.children.map((account) => account.key.scope), + ["codex", "spark"] + ); +}); + +test("model resolution selects a scoped child and blank models resolve to the parent", () => { + const pool = codexAccount.createCodexAccountPool({ + id: "codex-parent-2", + provider: "codex", + providerSpecificData: {}, + }); + + assert.equal(codexAccount.resolveCodexAccount(pool, SPARK_MODEL).scope, "spark"); + assert.equal(codexAccount.resolveCodexAccount(pool, SOL_MODEL).scope, "codex"); + assert.equal(codexAccount.resolveCodexAccount(pool, null).kind, "parent"); + assert.equal(codexAccount.resolveCodexAccount(pool, undefined).kind, "parent"); + assert.equal(codexAccount.resolveCodexAccount(pool, " ").kind, "parent"); +}); + +test("quota hydration reads scoped facts without leaking legacy singleton state", () => { + const sparkResetAt = futureTimestamp(90_000); + const pool = codexAccount.createCodexAccountPool({ + id: "connection-quota-hydration", + provider: "codex", + providerSpecificData: { + codexQuotaStateByScope: { + codex: { usage5h: 25, limit5h: 100, resetAt5h: futureTimestamp(30_000) }, + }, + codexExhaustedWindowByScope: { codex: "5h" }, + codexQuotaState: { + scope: "spark", + usage5h: 100, + limit5h: 100, + resetAt5h: sparkResetAt, + }, + codexExhaustedWindow: "7d", + }, + }); + + const codex = codexAccount.getCodexChildQuotaHydration(pool.children[0]); + const spark = codexAccount.getCodexChildQuotaHydration(pool.children[1]); + + assert.equal(codex.quotaState?.usage5h, 25); + assert.equal(codex.exhaustedWindow, "5h"); + assert.equal(spark.quotaState?.usage5h, 100); + assert.equal(spark.exhaustedWindow, "7d"); +}); + +test("parent inspection is an aggregate of child cooldowns", () => { + const pool = codexAccount.createCodexAccountPool({ + id: "codex-parent-3", + provider: "codex", + providerSpecificData: { + codexScopeRateLimitedUntil: { spark: futureTimestamp() }, + }, + }); + + const parentState = codexAccount.inspectCodexAccount(pool, pool.parent); + assert.equal(parentState.kind, "parent"); + if (parentState.kind === "parent") { + assert.equal(parentState.status, "partially_limited"); + assert.deepEqual(parentState.limitedScopes, ["spark"]); + } + + const sparkState = codexAccount.inspectCodexAccount(pool, pool.children[1]); + assert.equal(sparkState.kind, "child"); + if (sparkState.kind === "child") { + assert.equal(sparkState.scope, "spark"); + assert.equal(sparkState.unavailable, true); + } +}); + +test("earliest scoped cooldown identifies the child and parent connection", () => { + const earlier = futureTimestamp(30_000); + const later = futureTimestamp(60_000); + const pools = [ + codexAccount.createCodexAccountPool({ + id: "codex-parent-4", + provider: "codex", + providerSpecificData: { codexScopeRateLimitedUntil: { spark: later } }, + }), + codexAccount.createCodexAccountPool({ + id: "codex-parent-5", + provider: "codex", + providerSpecificData: { codexScopeRateLimitedUntil: { spark: earlier } }, + }), + ]; + + const earliest = codexAccount.getEarliestCodexChildCooldown(pools, SPARK_MODEL); + assert.equal(earliest?.account.connectionId, "codex-parent-5"); + assert.equal(earliest?.account.scope, "spark"); + assert.equal(earliest?.until, earlier); + assert.equal(codexAccount.getEarliestCodexChildCooldown(pools, " "), null); +}); + +test("account inspection rejects an account from a different parent pool", () => { + const first = codexAccount.createCodexAccountPool({ + id: "codex-parent-5a", + provider: "codex", + providerSpecificData: {}, + }); + const second = codexAccount.createCodexAccountPool({ + id: "codex-parent-5b", + provider: "codex", + providerSpecificData: {}, + }); + + assert.throws( + () => codexAccount.inspectCodexAccount(first, second.children[0]), + /does not belong to this pool/ + ); +}); + +test("expired and invalid legacy timestamps are not active cooldowns", () => { + const pool = codexAccount.createCodexAccountPool({ + id: "codex-parent-6", + provider: "codex", + providerSpecificData: { + codexScopeRateLimitedUntil: { + codex: new Date(Date.now() - 60_000).toISOString(), + spark: "not-a-timestamp", + }, + }, + }); + + const codexState = codexAccount.inspectCodexAccount(pool, pool.children[0]); + const sparkState = codexAccount.inspectCodexAccount(pool, pool.children[1]); + assert.equal(codexState.kind, "child"); + assert.equal(sparkState.kind, "child"); + if (codexState.kind === "child") assert.equal(codexState.unavailable, false); + if (sparkState.kind === "child") assert.equal(sparkState.unavailable, false); + assert.equal(codexAccount.inspectCodexAccount(pool, pool.parent).status, "available"); +}); + +test("projects quota exhaustion and active cooldown as distinct child facts", () => { + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const sparkCooldown = "2026-01-01T01:00:00.000Z"; + const projected = codexAccount.projectCodexAccountPool( + { + id: "codex-projection", + provider: "codex", + providerSpecificData: { + codexScopeRateLimitedUntil: { spark: sparkCooldown }, + codexQuotaStateByScope: { + codex: { + usage5h: 100, + limit5h: 100, + resetAt5h: "2026-01-01T02:00:00.000Z", + observedAt: "2025-12-31T23:59:00.000Z", + }, + spark: { usage7d: 80, limit7d: 100, resetAt7d: "2026-01-02T00:00:00.000Z" }, + }, + codexExhaustedWindowByScope: { codex: "5h" }, + }, + }, + now + ); + + assert.equal(projected.parentConnectionId, "codex-projection"); + assert.equal(projected.aggregate.status, "fully_limited"); + assert.equal(projected.aggregate.limitedChildCount, 2); + assert.deepEqual( + projected.children.map((child) => child.key), + [ + { parentConnectionId: "codex-projection", scope: "codex" }, + { parentConnectionId: "codex-projection", scope: "spark" }, + ] + ); + assert.equal("connectionId" in projected.children[0], false); + assert.deepEqual( + projected.children.map((child) => ({ + unavailable: child.unavailable, + cooldown: child.cooldown, + exhaustedWindow: child.quota.exhaustedWindow, + })), + [ + { + unavailable: true, + cooldown: { active: false, rateLimitedUntil: null }, + exhaustedWindow: "5h", + }, + { + unavailable: true, + cooldown: { active: true, rateLimitedUntil: sparkCooldown }, + exhaustedWindow: null, + }, + ] + ); + assert.equal(projected.children[0].quota.windows["5h"]?.usedPercentage, 100); +}); + +test("projects neither exhaustion nor an expired cooldown as unavailable", () => { + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const projected = codexAccount.projectCodexAccountPool( + { + id: "codex-available-projection", + provider: "codex", + providerSpecificData: { + codexScopeRateLimitedUntil: { spark: "2025-12-31T23:59:00.000Z" }, + }, + }, + now + ); + + assert.equal(projected.aggregate.status, "available"); + assert.equal(projected.aggregate.limitedChildCount, 0); + assert.equal(projected.children[1].unavailable, false); + assert.deepEqual(projected.children[1].cooldown, { + active: false, + rateLimitedUntil: null, + }); +}); + +test("projects an exhausted window as available after its reset passes", () => { + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const projected = codexAccount.projectCodexAccountPool( + { + id: "codex-expired-quota-projection", + provider: "codex", + providerSpecificData: { + codexScopeRateLimitedUntil: { codex: "2025-12-31T23:59:59.000Z" }, + codexQuotaStateByScope: { + codex: { + usage5h: 100, + limit5h: 100, + resetAt5h: "2025-12-31T23:59:59.000Z", + observedAt: "2025-12-31T18:59:00.000Z", + }, + }, + codexExhaustedWindowByScope: { codex: "5h" }, + }, + }, + now + ); + + assert.equal(projected.aggregate.status, "available"); + assert.equal(projected.aggregate.limitedChildCount, 0); + assert.equal(projected.children[0].unavailable, false); + assert.equal(projected.children[0].quota.exhaustedWindow, null); + assert.equal(projected.children[0].quota.windows["5h"]?.resetAt, "2025-12-31T23:59:59.000Z"); +}); diff --git a/tests/unit/codex-executor-split.test.ts b/tests/unit/codex-executor-split.test.ts index eac6b2eea0..5e542da13f 100644 --- a/tests/unit/codex-executor-split.test.ts +++ b/tests/unit/codex-executor-split.test.ts @@ -6,7 +6,7 @@ import { dirname, join } from "node:path"; // Split-guard for the codex executor quota extraction. // The pure quota-snapshot parsing + reset/cooldown scheduling lives in codex/quota.ts. -// Host re-exports the 4 public symbols (chatCore/codexQuota.ts + tests import them). +// Host re-exports the 4 public symbols for the Codex account module and tests. const HERE = dirname(fileURLToPath(import.meta.url)); const EXE = join(HERE, "../../open-sse/executors"); const HOST = join(EXE, "codex.ts"); diff --git a/tests/unit/codex-fingerprint-convergence.test.ts b/tests/unit/codex-fingerprint-convergence.test.ts index 11c186776a..5e44d350b9 100644 --- a/tests/unit/codex-fingerprint-convergence.test.ts +++ b/tests/unit/codex-fingerprint-convergence.test.ts @@ -6,6 +6,7 @@ import { applyCodexClientMetadata, applyCodexOriginalIdentityHeaders, createCodexClientIdentity, + ensureCodexFingerprintSeed, getCodexClientSessionId, getCodexConvergedSessionId, getCodexConvergedThreadId, @@ -371,3 +372,84 @@ test("Codex websocket headers and payload share one fingerprint identity", async assert.equal(wsHeaders["x-codex-window-id"], metadata["x-codex-window-id"]); assert.equal(payload.type, "response.create"); }); + +test("Codex fingerprint seed: persisted seed drives v2 derivation deterministically", () => { + const seed = "11111111-2222-4233-8444-555555555555"; + const seeded = { workspaceId: "workspace-42", codexFingerprintSeed: seed }; + + const installationId = getCodexInstallationId(seeded, "connection-42"); + const sessionId = getCodexConvergedSessionId(seeded, "connection-42"); + const threadId = getCodexConvergedThreadId("client-session", seeded, "connection-42"); + + // Deterministic: same seed → same ids, regardless of the connection key. + assert.equal(getCodexInstallationId(seeded, "another-connection"), installationId); + assert.equal(getCodexConvergedSessionId(seeded, "another-connection"), sessionId); + assert.equal(getCodexConvergedThreadId("client-session", seeded, null), threadId); + + // v2 derivation deliberately rotates away from the legacy workspace-derived ids. + const legacy = { workspaceId: "workspace-42" }; + assert.notEqual(installationId, getCodexInstallationId(legacy, "connection-42")); + assert.notEqual(sessionId, getCodexConvergedSessionId(legacy, "connection-42")); + + // Two connections never share an identity once seeded (sub2api #5696). + const otherSeed = { codexFingerprintSeed: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" }; + assert.notEqual(getCodexInstallationId(otherSeed, "connection-42"), installationId); + assert.notEqual(getCodexConvergedSessionId(otherSeed, "connection-42"), sessionId); + + // Admin-configured explicit installation id still wins over the seed. + assert.equal( + getCodexInstallationId( + { ...seeded, codexInstallationId: "99999999-8888-4777-8666-555555555555" }, + "connection-42" + ), + "99999999-8888-4777-8666-555555555555" + ); +}); + +test("ensureCodexFingerprintSeed creates once, preserves, and skips non-converged", () => { + const oauth = { accessToken: "oauth-token" }; + + // Default mode (session) on OAuth → seed created. + const created = ensureCodexFingerprintSeed(undefined, oauth); + assert.match(created?.codexFingerprintSeed as string, /^[0-9a-f-]{36}$/); + + // The stored seed ALWAYS wins over an incoming payload that omits it + // (partial update) or carries a client-forged one (system-managed key). + const stored = { codexFingerprintSeed: "11111111-2222-4233-8444-555555555555" }; + assert.deepEqual(ensureCodexFingerprintSeed(undefined, oauth, stored), stored); + assert.deepEqual( + ensureCodexFingerprintSeed( + { codexFingerprintSeed: "99999999-8888-4777-8666-555555555555" }, + oauth, + stored + ), + stored + ); + // The stored seed stays dormant when the mode is switched off. + assert.deepEqual(ensureCodexFingerprintSeed({ codexFingerprintMode: "off" }, oauth, stored), { + codexFingerprintMode: "off", + codexFingerprintSeed: "11111111-2222-4233-8444-555555555555", + }); + + // Explicit off without a stored seed stays seedless; a client-supplied seed + // on create is stripped and replaced by a system-generated one. + assert.deepEqual(ensureCodexFingerprintSeed({ codexFingerprintMode: "off" }, oauth), { + codexFingerprintMode: "off", + }); + const forgedOnCreate = ensureCodexFingerprintSeed( + { codexFingerprintSeed: "99999999-8888-4777-8666-555555555555" }, + oauth + ); + assert.match(forgedOnCreate?.codexFingerprintSeed as string, /^[0-9a-f-]{36}$/); + assert.notEqual(forgedOnCreate?.codexFingerprintSeed, "99999999-8888-4777-8666-555555555555"); + + // Non-OAuth (API key) connections are never seeded. + assert.equal(ensureCodexFingerprintSeed(undefined, { apiKey: "sk-x" }), undefined); + assert.equal(ensureCodexFingerprintSeed(undefined, undefined), undefined); + + // device/full modes require the seed as well. + for (const mode of ["device", "full"]) { + const result = ensureCodexFingerprintSeed({ codexFingerprintMode: mode }, oauth); + assert.match(result?.codexFingerprintSeed as string, /^[0-9a-f-]{36}$/); + } +}); diff --git a/tests/unit/codex-fingerprint-seed-persistence.test.ts b/tests/unit/codex-fingerprint-seed-persistence.test.ts new file mode 100644 index 0000000000..d65b7197b4 --- /dev/null +++ b/tests/unit/codex-fingerprint-seed-persistence.test.ts @@ -0,0 +1,111 @@ +import { after, beforeEach, 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-codex-seed-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); + +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +beforeEach(resetStorage); +after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function createCodexOAuthConnection(providerSpecificData?: Record) { + const suffix = Math.random().toString(16).slice(2, 10); + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: `codex-${suffix}`, + accessToken: `access-${suffix}`, + refreshToken: `refresh-${suffix}`, + providerSpecificData, + }); + assert.ok(connection && typeof connection.id === "string"); + return connection; +} + +test("codex OAuth connections persist a fingerprint seed at creation (default session mode)", async () => { + const connection = await createCodexOAuthConnection(); + const psd = connection.providerSpecificData as Record; + assert.match(String(psd.codexFingerprintSeed), UUID_V4_PATTERN); +}); + +test("the seed survives unrelated edits and explicit re-saves (identity stability)", async () => { + const connection = await createCodexOAuthConnection({ workspaceId: "ws-1" }); + const seed = (connection.providerSpecificData as Record).codexFingerprintSeed; + + const renamed = await providersDb.updateProviderConnection(connection.id, { name: "renamed" }); + assert.equal( + (renamed?.providerSpecificData as Record).codexFingerprintSeed, + seed + ); + + const resaved = await providersDb.updateProviderConnection(connection.id, { + providerSpecificData: { workspaceId: "ws-1", codexFingerprintMode: "full" }, + }); + assert.equal( + (resaved?.providerSpecificData as Record).codexFingerprintSeed, + seed + ); + assert.equal( + (resaved?.providerSpecificData as Record).codexFingerprintMode, + "full" + ); +}); + +test("explicit off is not seeded; switching to full seeds once and keeps it", async () => { + const connection = await createCodexOAuthConnection({ codexFingerprintMode: "off" }); + assert.equal( + (connection.providerSpecificData as Record).codexFingerprintSeed, + undefined + ); + + const switched = await providersDb.updateProviderConnection(connection.id, { + providerSpecificData: { codexFingerprintMode: "full" }, + }); + const psd = switched?.providerSpecificData as Record; + assert.match(String(psd.codexFingerprintSeed), UUID_V4_PATTERN); + + const again = await providersDb.updateProviderConnection(connection.id, { name: "again" }); + assert.equal( + (again?.providerSpecificData as Record).codexFingerprintSeed, + psd.codexFingerprintSeed + ); +}); + +test("a client-supplied seed is replaced by a system-managed one", async () => { + const connection = await createCodexOAuthConnection({ + codexFingerprintSeed: "client-supplied-not-a-uuid", + }); + const seed = String( + (connection.providerSpecificData as Record).codexFingerprintSeed + ); + assert.match(seed, UUID_V4_PATTERN); + assert.notEqual(seed, "client-supplied-not-a-uuid"); +}); + +test("non-OAuth codex connections are never seeded", async () => { + const suffix = Math.random().toString(16).slice(2, 10); + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "apikey", + name: `codex-key-${suffix}`, + apiKey: `sk-codex-${suffix}`, + }); + assert.ok(connection); + const psd = (connection.providerSpecificData ?? {}) as Record; + assert.equal(psd.codexFingerprintSeed, undefined); +}); diff --git a/tests/unit/codex-import-token-route.test.ts b/tests/unit/codex-import-token-route.test.ts index bff8c49b83..f9bbbc4ec2 100644 --- a/tests/unit/codex-import-token-route.test.ts +++ b/tests/unit/codex-import-token-route.test.ts @@ -75,7 +75,14 @@ test("import-token: decodes email + workspace claims from the access token and c assert.deepEqual(created?.providerSpecificData, { chatgptAccountId: "acct-bare", chatgptPlanType: "plus", + // Convergence is on by default (session mode), so the connection persists + // its system-managed fingerprint seed at creation time (v178 parity). + codexFingerprintSeed: created?.providerSpecificData?.codexFingerprintSeed, }); + assert.match( + String(created?.providerSpecificData?.codexFingerprintSeed), + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); }); test("import-token: falls back to the explicit `name` when the JWT carries no email", async () => { diff --git a/tests/unit/codex-quota-selection-hydration.test.ts b/tests/unit/codex-quota-selection-hydration.test.ts index 2a63ce2aea..124c863fcd 100644 --- a/tests/unit/codex-quota-selection-hydration.test.ts +++ b/tests/unit/codex-quota-selection-hydration.test.ts @@ -82,3 +82,106 @@ test("Codex selection ignores hydrated Spark-only exhaustion for normal Codex mo assert.equal(normalSelected.connectionId, connectionId); assert.equal(sparkSelected.allRateLimited, true); }); + +test("Codex selection hydrates authoritative scoped quota metadata after restart", async () => { + const sparkResetAt = futureIso(180_000); + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-authoritative-scoped-restart", + apiKey: null, + accessToken: "codex-authoritative-scoped-access", + refreshToken: "codex-authoritative-scoped-refresh", + isActive: true, + testStatus: "active", + providerSpecificData: { + codexQuotaStateByScope: { + codex: { + usage5h: 20, + limit5h: 100, + resetAt5h: futureIso(60_000), + usage7d: 30, + limit7d: 100, + resetAt7d: futureIso(120_000), + observedAt: new Date().toISOString(), + }, + spark: { + usage5h: 80, + limit5h: 100, + resetAt5h: sparkResetAt, + usage7d: 20, + limit7d: 100, + resetAt7d: futureIso(240_000), + observedAt: new Date().toISOString(), + }, + }, + codexExhaustedWindowByScope: { spark: "5h" }, + codexScopeRateLimitSource: { spark: "quota_reset" }, + }, + }); + const connectionId = (connection as { id: string }).id; + + quotaCache.__clearForTests(); + + const normalSelected = await auth.getProviderCredentials("codex", null, null, "codex/gpt-5.5"); + const sparkSelected = await auth.getProviderCredentials( + "codex", + null, + null, + "gpt-5.3-codex-spark" + ); + + assert.equal(normalSelected.connectionId, connectionId); + assert.equal(sparkSelected.allRateLimited, true); + assert.equal(sparkSelected.retryAfter, sparkResetAt); + assert.equal( + quotaCache.getQuotaWindowStatus(connectionId, "session", 100)?.reachedThreshold, + false + ); + assert.equal( + quotaCache.getQuotaWindowStatus(connectionId, "gpt_5_3_codex_spark_session", 100) + ?.reachedThreshold, + true + ); +}); + +test("legacy Codex quota metadata hydrates only its embedded child scope", async () => { + const sparkResetAt = futureIso(180_000); + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-legacy-scoped-restart", + apiKey: null, + accessToken: "codex-legacy-scoped-access", + refreshToken: "codex-legacy-scoped-refresh", + isActive: true, + testStatus: "active", + providerSpecificData: { + codexQuotaState: { + scope: "spark", + usage5h: 100, + limit5h: 100, + resetAt5h: sparkResetAt, + usage7d: 10, + limit7d: 100, + resetAt7d: futureIso(240_000), + observedAt: new Date().toISOString(), + }, + codexExhaustedWindow: "5h", + }, + }); + const connectionId = (connection as { id: string }).id; + + quotaCache.__clearForTests(); + + const normalSelected = await auth.getProviderCredentials("codex", null, null, "codex/gpt-5.5"); + const sparkSelected = await auth.getProviderCredentials( + "codex", + null, + null, + "gpt-5.3-codex-spark" + ); + + assert.equal(normalSelected.connectionId, connectionId); + assert.equal(sparkSelected.allRateLimited, true); +}); diff --git a/tests/unit/codex-same-account-transport-retry-9708.test.ts b/tests/unit/codex-same-account-transport-retry-9708.test.ts new file mode 100644 index 0000000000..e436df0080 --- /dev/null +++ b/tests/unit/codex-same-account-transport-retry-9708.test.ts @@ -0,0 +1,243 @@ +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 { + isRetryablePreOutputTransportError, + shouldRetrySameAccountTransport, + sameAccountTransportRetryDelayMs, + isTransportCooldownErrorCode, + buildMixedAvailabilityError, + SAME_ACCOUNT_TRANSPORT_RETRY_MAX, +} = await import("../../src/sse/services/sameAccountTransportRetry.ts"); + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9708-codex-retry-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET ||= "codex-9708-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const quotaCache = await import("../../src/domain/quotaCache.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function futureIso(ms = 60_000) { + return new Date(Date.now() + ms).toISOString(); +} + +async function seedConnection(provider: string, overrides: Record = {}) { + return providersDb.createProviderConnection({ + provider, + authType: overrides.authType || "oauth", + name: overrides.name || `${provider}-${Math.random().toString(16).slice(2, 8)}`, + accessToken: overrides.accessToken || `tok-${Math.random().toString(16).slice(2, 10)}`, + isActive: overrides.isActive ?? true, + testStatus: overrides.testStatus || "active", + priority: overrides.priority, + rateLimitedUntil: overrides.rateLimitedUntil, + lastError: overrides.lastError, + lastErrorType: overrides.lastErrorType, + errorCode: overrides.errorCode, + providerSpecificData: overrides.providerSpecificData || {}, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#9708: 503 connection-reset and 507 buffer errors are retryable pre-output transport", () => { + assert.equal( + isRetryablePreOutputTransportError( + 503, + "upstream connect error or disconnect/reset before headers reset reason: remote connection failure" + ), + true + ); + assert.equal( + isRetryablePreOutputTransportError( + 507, + "exceeded request buffer limit while retrying upstream" + ), + true + ); + assert.equal(isRetryablePreOutputTransportError(504, "gateway timeout"), true); + assert.equal(isRetryablePreOutputTransportError(502, "Bad Gateway"), true); +}); + +test("#9708: quota, auth, and deterministic 400s never enter the same-account retry path", () => { + assert.equal( + isRetryablePreOutputTransportError( + 429, + "All codex accounts reached configured quota threshold" + ), + false + ); + assert.equal(isRetryablePreOutputTransportError(401, "unauthorized"), false); + assert.equal(isRetryablePreOutputTransportError(400, "prompt is too long"), false); + assert.equal( + shouldRetrySameAccountTransport({ + status: 503, + errorText: "remote connection failure", + attempt: 0, + hasForcedConnection: true, + }), + false + ); + assert.equal( + shouldRetrySameAccountTransport({ + status: 503, + errorText: "remote connection failure", + attempt: 0, + hasEmittedOutput: true, + }), + false + ); +}); + +test("#9708: same-account retry is bounded to exactly one attempt", () => { + assert.equal( + shouldRetrySameAccountTransport({ + status: 503, + errorText: "remote connection failure", + attempt: 0, + }), + true + ); + assert.equal( + shouldRetrySameAccountTransport({ + status: 503, + errorText: "remote connection failure", + attempt: SAME_ACCOUNT_TRANSPORT_RETRY_MAX, + }), + false + ); +}); + +test("#9708: retry delay stays in the 2-3s jitter window", () => { + assert.equal( + sameAccountTransportRetryDelayMs(() => 0), + 2000 + ); + assert.equal( + sameAccountTransportRetryDelayMs(() => 1), + 3000 + ); + assert.equal( + sameAccountTransportRetryDelayMs(() => 0.5), + 2500 + ); +}); + +test("#9708: mixed-cause pool error is 503, not all-accounts-quota 429", () => { + const mixed = buildMixedAvailabilityError({ + provider: "codex", + quotaFilteredCount: 2, + transportUnavailableCount: 1, + transportStatus: 507, + }); + assert.equal(mixed.status, 503); + assert.equal(mixed.lastErrorCode, 503); + assert.match(mixed.lastError, /2 quota-filtered/); + assert.match(mixed.lastError, /1 temporarily unavailable after upstream 507/); + assert.equal(mixed.lastError.includes("quota threshold"), false); +}); + +test("#9708: simulate first 503 then success on the same account; second failure rotates", () => { + function simulate(results: Array<{ status: number; error?: string; success?: boolean }>) { + let attempt = 0; + let markUnavailable = 0; + let i = 0; + let connectionId = "acct-a"; + while (true) { + const result = results[Math.min(i, results.length - 1)]; + if (result.success) { + return { outcome: "success", attempt, markUnavailable, connectionId }; + } + if ( + shouldRetrySameAccountTransport({ + status: result.status, + errorText: result.error, + attempt, + }) + ) { + attempt += 1; + i += 1; + continue; + } + markUnavailable += 1; + connectionId = "acct-b"; + return { outcome: "fallback", attempt, markUnavailable, connectionId }; + } + } + + const recovered = simulate([ + { status: 503, error: "remote connection failure" }, + { success: true, status: 200 }, + ]); + assert.equal(recovered.outcome, "success"); + assert.equal(recovered.attempt, 1); + assert.equal(recovered.markUnavailable, 0); + assert.equal(recovered.connectionId, "acct-a"); + + const rotated = simulate([ + { status: 507, error: "exceeded request buffer limit while retrying upstream" }, + { status: 507, error: "exceeded request buffer limit while retrying upstream" }, + ]); + assert.equal(rotated.outcome, "fallback"); + assert.equal(rotated.attempt, 1); + assert.equal(rotated.markUnavailable, 1); + assert.equal(rotated.connectionId, "acct-b"); +}); + +test("#9708: getProviderCredentials does not report all-quota 429 when a sibling is only transport-cooled", async () => { + const resetAt = futureIso(120_000); + const quotaA = await seedConnection("codex", { + name: "codex-quota-a", + priority: 1, + providerSpecificData: { + limitPolicy: { enabled: true, thresholdPercent: 75, windows: ["session"] }, + }, + }); + const quotaB = await seedConnection("codex", { + name: "codex-quota-b", + priority: 2, + providerSpecificData: { + limitPolicy: { enabled: true, thresholdPercent: 75, windows: ["session"] }, + }, + }); + await seedConnection("codex", { + name: "codex-transport-blip", + priority: 3, + rateLimitedUntil: futureIso(8_000), + errorCode: 507, + lastError: "exceeded request buffer limit while retrying upstream", + lastErrorType: "server_error", + }); + + quotaCache.setQuotaCache(quotaA.id, "codex", { + session: { remainingPercentage: 0, resetAt }, + }); + quotaCache.setQuotaCache(quotaB.id, "codex", { + session: { remainingPercentage: 0, resetAt }, + }); + + const result = await auth.getProviderCredentials("codex"); + assert.equal(result.allRateLimited, true); + assert.notEqual(result.lastErrorCode, 429); + assert.equal(result.lastErrorCode, 503); + assert.match(String(result.lastError), /temporarily unavailable after upstream 507/i); + assert.equal(isTransportCooldownErrorCode(507), true); +}); diff --git a/tests/unit/codex-turn-state.test.ts b/tests/unit/codex-turn-state.test.ts new file mode 100644 index 0000000000..bbe9b67fd2 --- /dev/null +++ b/tests/unit/codex-turn-state.test.ts @@ -0,0 +1,147 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + __resetCodexTurnStateOriginsForTesting, + isCrossAccountCodexTurnState, + noteCodexTurnStateProvenance, + readCodexTurnStateHeader, +} from "../../open-sse/config/codexTurnState.ts"; +import { + resolveCodexTurnStateEcho, + withCodexFingerprintCredentials, +} from "../../open-sse/config/codexIdentity.ts"; +import { buildStreamingResponseHeaders } from "../../open-sse/handlers/chatCore/responseHeaders.ts"; + +const TURN_STATE = "ts-blob-0123456789"; + +function reset() { + __resetCodexTurnStateOriginsForTesting(); +} + +test("readCodexTurnStateHeader reads Headers and plain records case-insensitively", () => { + reset(); + assert.equal(readCodexTurnStateHeader(null), null); + assert.equal(readCodexTurnStateHeader({}), null); + assert.equal(readCodexTurnStateHeader({ "x-codex-turn-state": " " }), null); + assert.equal( + readCodexTurnStateHeader(new Headers({ "x-codex-turn-state": TURN_STATE })), + TURN_STATE + ); + assert.equal(readCodexTurnStateHeader({ "X-Codex-Turn-State": TURN_STATE }), TURN_STATE); +}); + +test("provenance: same account passes, cross account is flagged, unknown session passes", () => { + reset(); + noteCodexTurnStateProvenance("session-1", "conn-a"); + assert.equal(isCrossAccountCodexTurnState("session-1", "conn-a"), false); + assert.equal(isCrossAccountCodexTurnState("session-1", "conn-b"), true); + assert.equal(isCrossAccountCodexTurnState("session-unknown", "conn-b"), false); +}); + +test("provenance: missing session or account does not track", () => { + reset(); + noteCodexTurnStateProvenance("", "conn-a"); + noteCodexTurnStateProvenance("session-1", ""); + noteCodexTurnStateProvenance(null, "conn-a"); + assert.equal(isCrossAccountCodexTurnState("session-1", "conn-b"), false); +}); + +test("provenance: expired records stop guarding", () => { + reset(); + const t0 = 1_000_000; + noteCodexTurnStateProvenance("session-1", "conn-a", t0); + assert.equal(isCrossAccountCodexTurnState("session-1", "conn-b", t0 + 1000), true); + // 2h TTL — after it lapses the record is lazily dropped and the echo passes. + assert.equal( + isCrossAccountCodexTurnState("session-1", "conn-b", t0 + 2 * 60 * 60 * 1000 + 1), + false + ); +}); + +test("provenance: newest commit wins for a re-minted session blob", () => { + reset(); + noteCodexTurnStateProvenance("session-1", "conn-a"); + // Failover committed a response from conn-b — the client now holds b's blob. + noteCodexTurnStateProvenance("session-1", "conn-b"); + assert.equal(isCrossAccountCodexTurnState("session-1", "conn-b"), false); + assert.equal(isCrossAccountCodexTurnState("session-1", "conn-a"), true); +}); + +test("resolveCodexTurnStateEcho strips only known cross-account echoes", () => { + reset(); + const clientHeaders = { + "session-id": "session-1", + "x-codex-turn-state": TURN_STATE, + }; + // No provenance yet → pass through unchanged. + assert.equal(resolveCodexTurnStateEcho(clientHeaders, "conn-a"), TURN_STATE); + + // Blob minted by conn-a and client now served by conn-a again → pass. + noteCodexTurnStateProvenance("session-1", "conn-a"); + assert.equal(resolveCodexTurnStateEcho(clientHeaders, "conn-a"), TURN_STATE); + + // Failover to conn-b while the client echoes conn-a's blob → strip. + assert.equal(resolveCodexTurnStateEcho(clientHeaders, "conn-b"), null); + + // No echo header → nothing to forward. + assert.equal(resolveCodexTurnStateEcho({ "session-id": "session-1" }, "conn-a"), null); + + // Echo without a session id cannot be provenance-checked → pass through + // (same as sub2api: no tracking key, keep passthrough behavior). + assert.equal( + resolveCodexTurnStateEcho({ "x-codex-turn-state": TURN_STATE }, "conn-b"), + TURN_STATE + ); +}); + +test("withCodexFingerprintCredentials stashes the allowed echo independent of mode", () => { + reset(); + noteCodexTurnStateProvenance("session-1", "conn-a"); + + const baseCredentials = { + accessToken: "oauth-token", + connectionId: "conn-a", + providerSpecificData: { codexFingerprintMode: "off" as const }, + }; + const clientHeaders = { "session-id": "session-1", "x-codex-turn-state": TURN_STATE }; + + // Same account, explicit off: echo survives alongside original identity passthrough. + const sameAccount = withCodexFingerprintCredentials(baseCredentials, clientHeaders, {}); + assert.equal(sameAccount.providerSpecificData?.codexTurnStateEcho, TURN_STATE); + assert.ok(sameAccount.providerSpecificData?.codexOriginalIdentityHeaders); + assert.equal(sameAccount.providerSpecificData?.codexClientIdentity, undefined); + + // Cross account: echo stripped, original client identity still preserved. + const crossAccount = withCodexFingerprintCredentials( + { ...baseCredentials, connectionId: "conn-b" }, + clientHeaders, + {} + ); + assert.equal(crossAccount.providerSpecificData?.codexTurnStateEcho, undefined); + + // Compact endpoint: convergence identity is skipped but the echo guard still runs. + const compact = withCodexFingerprintCredentials( + { ...baseCredentials, requestEndpointPath: "/responses/compact" }, + clientHeaders, + {} + ); + assert.equal(compact.providerSpecificData?.codexClientIdentity, undefined); + assert.equal(compact.providerSpecificData?.codexTurnStateEcho, TURN_STATE); +}); + +test("streaming response headers forward x-codex-turn-state outside the byte budget", () => { + reset(); + const upstream = new Headers(); + upstream.set("x-codex-turn-state", "s".repeat(300)); + // Fill the budget with low-priority noise the blob would otherwise evict into. + for (let index = 0; index < 12; index += 1) { + upstream.set(`x-noise-${index}`, "n".repeat(60)); + } + upstream.set("x-codex-primary-used-percent", "41"); + + const out = buildStreamingResponseHeaders(upstream, {}, null); + const record = out as Record; + assert.equal(record["x-codex-turn-state"], "s".repeat(300)); + assert.equal(record["x-codex-primary-used-percent"], "41"); +}); diff --git a/tests/unit/colocate-optionals.test.ts b/tests/unit/colocate-optionals.test.ts index 711c405f99..8250c46317 100644 --- a/tests/unit/colocate-optionals.test.ts +++ b/tests/unit/colocate-optionals.test.ts @@ -29,10 +29,9 @@ function mkPkg( /** * Build a root tree mirroring the real SLM optional shape: - * @atjsh/llmlingua-2 → dep es-toolkit, PEER @huggingface/transformers (+ tfjs, js-tiktoken) - * @tensorflow/tfjs → dep @tensorflow/tfjs-core → dep long + * @atjsh/llmlingua-2 → dep es-toolkit, PEER @huggingface/transformers (+ js-tiktoken) * js-tiktoken → dep base64-js - * @huggingface/transformers present at root as a (stale) 4.2.0 + * @huggingface/transformers present at root as a (hypothetical future) 5.0.0 * * Each mock package gets a resolvable entrypoint so that isPackageIntact (which * checks entrypoint integrity via require.resolve) can validate the co-located @@ -49,26 +48,12 @@ function buildRoot(rootDir: string): void { dependencies: { "es-toolkit": "^1.38.0" }, peerDependencies: { "@huggingface/transformers": "*", - "@tensorflow/tfjs": "*", "js-tiktoken": "*", }, }, { "dist/index.js": "export const llmlingua = true;\n" } ); mkPkg(rootNm, "es-toolkit", { main: "index.js" }, { "index.js": "export const esToolkit = true;\n" }); - mkPkg( - rootNm, - "@tensorflow/tfjs", - { main: "index.js", dependencies: { "@tensorflow/tfjs-core": "4.22.0" } }, - { "index.js": "export const tfjs = true;\n" } - ); - mkPkg( - rootNm, - "@tensorflow/tfjs-core", - { main: "index.js", dependencies: { long: "^5.0.0" } }, - { "index.js": "export const tfjsCore = true;\n" } - ); - mkPkg(rootNm, "long", { main: "index.js" }, { "index.js": "export const long = true;\n" }); mkPkg( rootNm, "js-tiktoken", @@ -76,8 +61,8 @@ function buildRoot(rootDir: string): void { { "index.js": "export const tiktoken = true;\n" } ); mkPkg(rootNm, "base64-js", { main: "index.js" }, { "index.js": "export const base64 = true;\n" }); - // Root transformers is the STALE 4.x line — the bug we must not propagate into dist. - mkPkg(rootNm, "@huggingface/transformers", { version: "4.2.0" }); + // Root transformers is a hypothetical FUTURE line — the version we must not propagate into dist. + mkPkg(rootNm, "@huggingface/transformers", { version: "5.0.0" }); } test("computeDependencyClosure walks deps transitively and skips peers (transformers)", () => { @@ -88,11 +73,8 @@ test("computeDependencyClosure walks deps transitively and skips peers (transfor for (const expected of [ "@atjsh/llmlingua-2", - "@tensorflow/tfjs", "js-tiktoken", "es-toolkit", - "@tensorflow/tfjs-core", - "long", "base64-js", ]) { assert.ok(closure.includes(expected), `closure should include ${expected}`); @@ -111,23 +93,20 @@ test("colocateLlmlinguaOptionals copies the closure into dist and never clobbers const root = mkdtempSync(join(tmpdir(), "omniroute-colocate-copy-")); try { buildRoot(root); - // dist already ships the PINNED transformers (3.5.2) — must survive untouched. + // dist already ships the PINNED transformers (4.2.0) — must survive untouched. const distNm = join(root, "dist", "node_modules"); - mkPkg(distNm, "@huggingface/transformers", { version: "3.5.2" }); + mkPkg(distNm, "@huggingface/transformers", { version: "4.2.0" }); const result = colocateLlmlinguaOptionals({ rootDir: root }); assert.equal(result.skipped, false); if (result.skipped === false) { - assert.ok(result.copied >= 6, `expected >=6 packages copied, got ${result.copied}`); + assert.ok(result.copied >= 4, `expected >=4 packages copied, got ${result.copied}`); } // Full closure landed in dist/node_modules. for (const name of [ "@atjsh/llmlingua-2", "es-toolkit", - "@tensorflow/tfjs", - "@tensorflow/tfjs-core", - "long", "js-tiktoken", "base64-js", ]) { @@ -136,11 +115,11 @@ test("colocateLlmlinguaOptionals copies the closure into dist and never clobbers // The package payload came along (not just the manifest). assert.ok(existsSync(join(distNm, "@atjsh", "llmlingua-2", "dist", "index.js"))); - // CRITICAL: dist's pinned transformers is preserved — root's 4.2.0 must NOT win. + // CRITICAL: dist's pinned transformers is preserved — root's 5.0.0 must NOT win. const distTransformers = JSON.parse( readFileSync(join(distNm, "@huggingface", "transformers", "package.json"), "utf8") ); - assert.equal(distTransformers.version, "3.5.2", "dist transformers must remain 3.5.2"); + assert.equal(distTransformers.version, "4.2.0", "dist transformers must remain 4.2.0"); } finally { rmSync(root, { recursive: true, force: true }); } @@ -150,7 +129,7 @@ test("colocateLlmlinguaOptionals is idempotent (second run is a no-op)", () => { const root = mkdtempSync(join(tmpdir(), "omniroute-colocate-idem-")); try { buildRoot(root); - mkPkg(join(root, "dist", "node_modules"), "@huggingface/transformers", { version: "3.5.2" }); + mkPkg(join(root, "dist", "node_modules"), "@huggingface/transformers", { version: "4.2.0" }); const first = colocateLlmlinguaOptionals({ rootDir: root }); assert.equal(first.skipped, false); @@ -169,7 +148,7 @@ test("colocateLlmlinguaOptionals skips when SLM optionals are not installed", () const root = mkdtempSync(join(tmpdir(), "omniroute-colocate-noopt-")); try { // dist bundle exists, but the optional seeds were never installed at root. - mkPkg(join(root, "dist", "node_modules"), "@huggingface/transformers", { version: "3.5.2" }); + mkPkg(join(root, "dist", "node_modules"), "@huggingface/transformers", { version: "4.2.0" }); mkdirSync(join(root, "node_modules"), { recursive: true }); const result = colocateLlmlinguaOptionals({ rootDir: root }); @@ -208,7 +187,7 @@ test("colocateLlmlinguaOptionals fills a Next-traced stub (package.json only, no try { buildRoot(root); const distNm = join(root, "dist", "node_modules"); - mkPkg(distNm, "@huggingface/transformers", { version: "3.5.2" }); + mkPkg(distNm, "@huggingface/transformers", { version: "4.2.0" }); // Simulate the Next-traced stub: directory exists, package.json only. const stubDir = join(distNm, "@atjsh", "llmlingua-2"); @@ -233,5 +212,5 @@ test("colocateLlmlinguaOptionals fills a Next-traced stub (package.json only, no test("SEED_PACKAGES excludes transformers (it is a dist-pinned peer, not a seed)", () => { assert.ok(!SEED_PACKAGES.includes("@huggingface/transformers")); - assert.deepEqual(SEED_PACKAGES, ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"]); + assert.deepEqual(SEED_PACKAGES, ["@atjsh/llmlingua-2", "js-tiktoken"]); }); diff --git a/tests/unit/combo-10597-error-body-logging.test.ts b/tests/unit/combo-10597-error-body-logging.test.ts new file mode 100644 index 0000000000..6df6a2cf49 --- /dev/null +++ b/tests/unit/combo-10597-error-body-logging.test.ts @@ -0,0 +1,91 @@ +/** + * #10597 — When a combo target fails with a non-2xx status, the per-target + * "Model X failed, trying next" COMBO log line only carries `{ status }` — + * the upstream error BODY (e.g. Anthropic's "prompt is too long" or a + * tool_use/tool_result pairing 400) is captured in `errorText` but never + * logged, so operators cannot distinguish failure causes from server logs + * without reproducing the request. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-10597-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-10597-test-secret"; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + +const DISTINCTIVE_ERROR_TEXT = + "messages.450: `tool_use` ids were found without `tool_result` blocks immediately after"; + +type WarnCall = { tag: string; msg: string; meta: unknown }; +const warnCalls: WarnCall[] = []; +const log = { + info: () => {}, + debug: () => {}, + error: () => {}, + warn: (tag: string, msg: string, meta?: unknown) => { + warnCalls.push({ tag, msg, meta }); + }, +}; + +function failing400() { + return new Response( + JSON.stringify({ + type: "error", + error: { type: "invalid_request_error", message: DISTINCTIVE_ERROR_TEXT }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); +} + +function healthy200(model: string) { + return new Response( + JSON.stringify({ + id: "ok", + object: "chat.completion", + model, + choices: [{ index: 0, message: { role: "assistant", content: "hello from " + model }, finish_reason: "stop" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} + +function makeCombo(models: string[]) { + return { name: "test-combo-10597", strategy: "priority", models: models.map((m) => ({ model: m })) }; +} + +test("#10597 COMBO failure log must surface the upstream error body, not just the status code", async () => { + const modelsCalled: string[] = []; + const handleSingleModel = async (_body: unknown, modelStr: string) => { + modelsCalled.push(modelStr); + if (modelsCalled.length === 1) return failing400(); + return healthy200(modelStr); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }] }, + combo: makeCombo(["claude/claude-opus-4-8", "openai/gpt-4o-mini"]), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + assert.equal(result.status, 200); + assert.equal(modelsCalled.length, 2); + + const failureLog = warnCalls.find( + (c) => typeof c.msg === "string" && c.msg.includes("claude/claude-opus-4-8") && c.msg.includes("failed") + ); + assert.ok(failureLog, "expected a COMBO warn log for the failing leg"); + + const serialized = JSON.stringify(failureLog); + assert.ok( + serialized.includes("tool_use") || serialized.includes(DISTINCTIVE_ERROR_TEXT), + `expected the upstream error body to appear in the COMBO failure log, but got: ${serialized}` + ); +}); diff --git a/tests/unit/combo-attempt-body-isolation-7847.test.ts b/tests/unit/combo-attempt-body-isolation-7847.test.ts index 6238baf8ff..9373c51e7c 100644 --- a/tests/unit/combo-attempt-body-isolation-7847.test.ts +++ b/tests/unit/combo-attempt-body-isolation-7847.test.ts @@ -22,6 +22,8 @@ const ORIGINAL_DATA_DIR = process.env.DATA_DIR; process.env.DATA_DIR = TEST_DATA_DIR; const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { applyReasoningInputPolicy } = + await import("../../open-sse/services/reasoningInputPolicy.ts"); const core = await import("../../src/lib/db/core.ts"); const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); @@ -164,6 +166,85 @@ test("the caller's body object is never mutated by the combo loop", async () => assert.equal(JSON.stringify(body), before, "handleComboChat must treat `body` as read-only"); }); +test("incompatible reasoning skips a target without mutating the fallback attempt", async () => { + const body = { + model: "deepseek/deepseek-v4-pro", + input: [ + { + id: "rs_plaintext", + type: "reasoning", + content: [{ type: "reasoning_text", text: "inspect first" }], + }, + { + id: "fc_shared", + type: "function_call", + call_id: "call_1", + name: "search", + arguments: "{}", + }, + ], + }; + let attempts = 0; + + const response = await handleComboChat({ + body, + combo: comboOf("priority", "reasoning-policy-isolation"), + handleSingleModel: async (received: Record) => { + attempts++; + const input = received.input as Array>; + if (attempts === 1) { + const policy = applyReasoningInputPolicy(received, "responses", { + provider: "openai", + onIncompatibleReasoning: "drop", + }); + assert.equal(policy.incompatibleReasoning, false); + assert.equal( + (received.input as Array>).some( + (item) => item.type === "reasoning" + ), + false + ); + return new Response( + JSON.stringify({ + error: { + message: "Reasoning continuation is not compatible with the selected target", + }, + }), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + assert.equal( + input[1].id, + "fc_shared", + "the first target's nested input rewrite leaked into the fallback" + ); + const policy = applyReasoningInputPolicy(received, "responses", { + provider: "deepseek", + }); + assert.equal(policy.incompatibleReasoning, false); + assert.equal( + (received.input as Array>)[0].type, + "reasoning", + "the compatible fallback lost the plaintext reasoning item" + ); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(response.status, 200); + assert.ok(attempts >= 2); + assert.equal( + (body.input[1] as Record).id, + "fc_shared", + "the caller's nested input was mutated" + ); +}); + // ── The copy must stay shallow — that is the whole point ───────────────────── test("the per-target copy shares the nested payload instead of deep-cloning it", async () => { const body = agentBody(); diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index 61fe705d0a..d09ecf6999 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -647,6 +647,24 @@ test("createComboSchema accepts nestedComboMode and rejects invalid values", () assert.equal(invalid.success, false); }); +test("createComboSchema validates reasoning transport fallback modes", () => { + for (const mode of ["skip", "drop"] as const) { + const parsed = createComboSchema.parse({ + name: `reasoning-transport-${mode}`, + models: ["openai/gpt-5.4"], + config: { reasoningTransportFallback: mode }, + }); + assert.equal(parsed.config.reasoningTransportFallback, mode); + } + + const invalid = createComboSchema.safeParse({ + name: "reasoning-transport-invalid", + models: ["openai/gpt-5.4"], + config: { reasoningTransportFallback: "retry" }, + }); + assert.equal(invalid.success, false); +}); + test("createComboSchema accepts per-combo stickyRoundRobinLimit and rejects out-of-range", () => { const parsed = createComboSchema.parse({ name: "sticky-override", diff --git a/tests/unit/combo-context-generic-default-10734.test.ts b/tests/unit/combo-context-generic-default-10734.test.ts new file mode 100644 index 0000000000..27749b9f9a --- /dev/null +++ b/tests/unit/combo-context-generic-default-10734.test.ts @@ -0,0 +1,106 @@ +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-10734-combo-ctx-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET ||= "combo-context-10734-secret"; + +const { getSourcedTokenLimit, resolveTokenLimit, getTokenLimit } = + await import("../../open-sse/services/contextManager.ts"); +const { computeComboContextLength } = await import("../../src/lib/combos/comboContext.ts"); +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const contextOverrides = await import("../../src/lib/db/modelContextOverrides.ts"); +const catalog = await import("../../src/app/api/v1/models/catalog.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#10734: resolveTokenLimit marks the generic 128k catch-all as specific:false", () => { + const resolved = resolveTokenLimit("not-a-real-provider", "no-such-model"); + assert.equal(resolved.limit, 128000); + assert.equal(resolved.specific, false); +}); + +test("#10734: getSourcedTokenLimit omits the generic 128k catch-all", () => { + assert.equal(getSourcedTokenLimit("not-a-real-provider", "no-such-model"), undefined); + assert.equal(getTokenLimit("not-a-real-provider", "no-such-model"), 128000); +}); + +test("#10734: getSourcedTokenLimit keeps an explicit canonical window", () => { + assert.equal(getSourcedTokenLimit("not-a-real-provider", "no-such-model", 500000), 500000); +}); + +test("#10734: getSourcedTokenLimit keeps provider-specific defaults (claude)", () => { + const sourced = getSourcedTokenLimit("claude", "claude-sonnet-4"); + assert.equal(typeof sourced, "number"); + assert.ok(sourced && sourced > 128000); + assert.equal(resolveTokenLimit("claude", "claude-sonnet-4").specific, true); +}); + +test("#10734: combo min() ignores unsourced members instead of advertising 128k", () => { + const combo = { + name: "large-plus-unknown", + models: ["glm/glm-5.2", "not-a-real-provider/no-such-model"], + }; + const result = computeComboContextLength(combo, []); + assert.equal( + result, + 1000000, + "glm-5.2 is a sourced 1M window; the generic-default member must not pull min() to 128k" + ); +}); + +test("#10734: nested combo-ref inherits sourced min(), not 128k", () => { + const inner = { + name: "inner-large", + models: ["glm/glm-5.2", "not-a-real-provider/no-such-model"], + }; + const wrapper = { + name: "wrapper-large", + models: [{ kind: "combo-ref", comboName: "inner-large" }], + }; + const result = computeComboContextLength(wrapper, [inner, wrapper]); + assert.equal(result, 1000000); +}); + +test("#10734: GET /v1/models does not advertise 128k for a 500k combo plus an unsourced member", async () => { + const modelId = "gpt-5.6-terra"; + assert.equal(contextOverrides.setModelContextOverride("codex", modelId, 500000), true); + + await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-10734-large-window", + accessToken: "codex-test-token", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + await combosDb.createCombo({ + name: "large-context-combo-10734", + strategy: "priority", + models: [`codex/${modelId}`, "not-a-real-provider/no-such-model"], + }); + + const response = await catalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array> }; + const combo = body.data.find((item) => item.id === "large-context-combo-10734"); + + assert.equal(response.status, 200); + assert.ok(combo, "combo should be published in /v1/models"); + assert.notEqual( + combo.context_length, + 128000, + "generic 128k must not win min() over the 500k sourced member" + ); + assert.equal(combo.context_length, 500000); +}); diff --git a/tests/unit/combo-context-window-filter.test.ts b/tests/unit/combo-context-window-filter.test.ts index 7759b20fb7..442caf85e0 100644 --- a/tests/unit/combo-context-window-filter.test.ts +++ b/tests/unit/combo-context-window-filter.test.ts @@ -15,7 +15,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const { saveModelsDevCapabilities, clearModelsDevCapabilities } = await import("../../src/lib/modelsDevSync.ts"); -const { filterTargetsByRequestCompatibility, getKnownContextOverflow, handleComboChat } = +const { filterTargetsByRequestCompatibility, handleComboChat } = await import("../../open-sse/services/combo.ts"); const { setModelContextOverride, removeModelContextOverride } = await import("../../src/lib/db/modelContextOverrides.ts"); @@ -212,62 +212,7 @@ test("output-token limits remain a hard compatibility requirement", () => { ); }); -test("known context overflow reports the largest target limit", () => { - saveModelsDevCapabilities({ - "unit-known-context": { - tiny: capabilityEntry(8_000), - small: capabilityEntry(16_000), - }, - }); - - const overflow = getKnownContextOverflow( - [target("unit-known-context/tiny"), target("unit-known-context/small")], - largeContextBody() - ); - - assert.ok(overflow); - assert.ok(overflow.requiredContextTokens > overflow.maxKnownContextTokens); - assert.equal(overflow.maxKnownContextTokens, 16_000); - assert.equal(overflow.targetCount, 2); -}); - -test("#7177 an empty messages array is not counted as real content at an exact-boundary limit", () => { - // Regression: some combo entrypoints default a caller-omitted `messages` to `[]`. The - // estimator used to JSON.stringify whatever keys were merely *present* on the body, - // so an empty array still contributed a few phantom "structural" tokens (JSON braces/ - // brackets), which was enough to trip a false-positive overflow when max_tokens exactly - // equals the target's context window (a common config where limit_input === limit_output - // === limit_context) even though there is no real input to account for. - saveModelsDevCapabilities({ - "unit-known-context": { - exact: capabilityEntry(4_096), - }, - }); - - const overflow = getKnownContextOverflow([target("unit-known-context/exact")], { - messages: [], - max_tokens: 4_096, - }); - - assert.equal(overflow, null); -}); - -test("unknown context metadata keeps overflow detection fail-open", () => { - saveModelsDevCapabilities({ - "unit-known-context": { - tiny: capabilityEntry(8_000), - }, - }); - - const overflow = getKnownContextOverflow( - [target("unit-known-context/tiny"), target("unit-unknown-context/mystery")], - largeContextBody() - ); - - assert.equal(overflow, null); -}); - -test("combo rejects a known oversized request before upstream dispatch", async () => { +test("combo dispatches requests that only an approximate estimate marks oversized", async () => { saveModelsDevCapabilities({ "unit-known-context": { tiny: capabilityEntry(8_000), @@ -285,48 +230,46 @@ test("combo rejects a known oversized request before upstream dispatch", async ( }, handleSingleModel: async () => { dispatches += 1; - return new Response("unexpected", { status: 200 }); + return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); }, log: noopLog, }); - assert.equal(response.status, 400); - assert.equal(dispatches, 0); - const body = await response.json(); - assert.equal(body.error.code, "context_length_exceeded"); - assert.equal(body.diagnostics.terminalReason, "context_length_exceeded"); - assert.equal(body.diagnostics.attempted, 0); + assert.equal(response.status, 200); + assert.equal(dispatches, 1); }); -test("native Responses context bypasses catalog overflow only for all-Codex pools (#8932)", () => { +test("round-robin dispatches requests that only an approximate estimate marks oversized", async () => { saveModelsDevCapabilities({ - codex: { - large: capabilityEntry(272_000), - }, "unit-known-context": { - large: capabilityEntry(272_000), + tiny: capabilityEntry(8_000), + small: capabilityEntry(16_000), }, }); - const body = bigContextBody(275_000); + let dispatches = 0; - assert.equal( - getKnownContextOverflow([target("codex/large")], body, { - clientManagedResponsesContext: true, - }), - null - ); - assert.equal( - getKnownContextOverflow([target("codex/large"), target("chatgpt-web-codex/large")], body, { - clientManagedResponsesContext: true, - }), - null - ); - assert.ok( - getKnownContextOverflow([target("unit-known-context/large")], body, { - clientManagedResponsesContext: true, - }), - "non-Codex pools must retain the catalog overflow guard" - ); + const response = await handleComboChat({ + body: largeContextBody(), + combo: { + name: "known-context-overflow-round-robin", + strategy: "round-robin", + models: ["unit-known-context/tiny", "unit-known-context/small"], + }, + handleSingleModel: async () => { + dispatches += 1; + return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + log: noopLog, + }); + + assert.equal(response.status, 200); + assert.equal(dispatches, 1); }); test("native Responses context reaches an all-Codex target beyond its catalog hint (#8932)", async () => { diff --git a/tests/unit/combo-empty-models.test.ts b/tests/unit/combo-empty-models.test.ts new file mode 100644 index 0000000000..1486628ec5 --- /dev/null +++ b/tests/unit/combo-empty-models.test.ts @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import assert from "node:assert/strict"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-empty-models-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { createComboSchema, updateComboSchema } = + await import("../../src/shared/validation/schemas/combo.ts"); +const { getCopilotTool } = await import("../../src/lib/copilot/tools.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const core = await import("../../src/lib/db/core.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("an update cannot remove every model from a combo", () => { + assert.equal(updateComboSchema.safeParse({ models: [] }).success, false); + assert.equal(updateComboSchema.safeParse({ models: ["openai/gpt-4o-mini"] }).success, true); + assert.equal(updateComboSchema.safeParse({ name: "renamed" }).success, true); +}); + +test("creating a combo with no model stays allowed — the CLI does it on purpose", () => { + assert.equal(createComboSchema.safeParse({ name: "drafted", models: [] }).success, true); + assert.equal(createComboSchema.safeParse({ name: "drafted" }).success, true); +}); + +test("the copilot createCombo tool stores targets where the router looks for them", async () => { + const tool = getCopilotTool("createCombo"); + assert.ok(tool); + + await tool.handler({ + name: "copilot-stored", + strategy: "priority", + targets: JSON.stringify([{ provider: "openai", model: "gpt-4o-mini", weight: 100 }]), + }); + + const stored = (await combosDb.getComboByName("copilot-stored")) as { models?: unknown[] } | null; + assert.ok(stored, "the combo should exist"); + assert.equal(stored.models?.length, 1); +}); + +test("the copilot combo list counts the targets the router will use", async () => { + const list = getCopilotTool("listCombos"); + assert.ok(list); + + await combosDb.createCombo({ + name: "dashboard-made", + strategy: "priority", + models: ["openai/gpt-4o-mini", "openai/gpt-4o"], + }); + + const output = await list.handler({}); + assert.match(output, /\*\*dashboard-made\*\* — strategy: `priority` — 2 target\(s\)/); +}); diff --git a/tests/unit/combo-guide-invocation-keys.test.ts b/tests/unit/combo-guide-invocation-keys.test.ts new file mode 100644 index 0000000000..95bd0f144f --- /dev/null +++ b/tests/unit/combo-guide-invocation-keys.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const en = require("../../src/i18n/messages/en.json"); + +const invokeKeys = [ + "usageGuideInvokeTitle", + "usageGuideInvokeDesc", + "usageGuideInvokeAutoNote", + "usageGuideInvokeOpenrouterNote", +]; + +test("combos: usage guide invocation keys exist in en.json with expected copy", () => { + for (const key of invokeKeys) { + assert.ok( + typeof en.combos[key] === "string" && en.combos[key].length > 0, + `combos.${key} missing` + ); + } + assert.match(en.combos.usageGuideInvokeDesc, /exact name/); + assert.match(en.combos.usageGuideInvokeAutoNote, /does not use your combos/); + assert.match(en.combos.usageGuideInvokeOpenrouterNote, /paid OpenRouter product/); +}); + +test("combos: ComboUsageGuide renders the invocation keys via getI18nOrFallback", () => { + const page = readFileSync( + path.join(import.meta.dirname, "../../src/app/(dashboard)/dashboard/combos/page.tsx"), + "utf8" + ); + for (const key of invokeKeys) { + assert.ok(page.includes(`"${key}"`), `page.tsx references combos.${key}`); + } +}); diff --git a/tests/unit/combo-patch-verb.test.ts b/tests/unit/combo-patch-verb.test.ts new file mode 100644 index 0000000000..c72d0e23d4 --- /dev/null +++ b/tests/unit/combo-patch-verb.test.ts @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-patch-verb-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const comboRoute = await import("../../src/app/api/combos/[id]/route.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function patch(id: string, body: Record) { + return new Request(`http://localhost/api/combos/${id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test("PATCH changes one field and leaves the rest of the combo alone", async () => { + const combo = await combosDb.createCombo({ + name: "patchable", + strategy: "priority", + models: [{ provider: "openai", model: "gpt-4o" }], + system_message: "keep me", + }); + + const response = await comboRoute.PATCH(patch(combo.id, { strategy: "round-robin" }), { + params: Promise.resolve({ id: combo.id }), + }); + assert.equal(response.status, 200); + + const stored = (await combosDb.getComboById(combo.id)) as { + strategy?: string; + system_message?: string; + models?: unknown[]; + }; + assert.equal(stored.strategy, "round-robin"); + assert.equal(stored.system_message, "keep me"); + assert.equal(stored.models?.length, 1); +}); + +test("PATCH on an unknown combo answers 404, like PUT", async () => { + const response = await comboRoute.PATCH(patch("does-not-exist", { strategy: "priority" }), { + params: Promise.resolve({ id: "does-not-exist" }), + }); + assert.equal(response.status, 404); + + const body = (await response.json()) as { error?: { code?: string } }; + assert.equal(body.error?.code, "COMBO_007"); +}); diff --git a/tests/unit/combo-quota-token-limit.test.ts b/tests/unit/combo-quota-token-limit.test.ts new file mode 100644 index 0000000000..53ecdb91c1 --- /dev/null +++ b/tests/unit/combo-quota-token-limit.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-quota-token-limit-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_QUOTA_ROUTING = process.env.OMNIROUTE_QUOTA_AWARE_ROUTING; +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.OMNIROUTE_QUOTA_AWARE_ROUTING = "1"; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { getProviderQuota } = await import("../../src/lib/quota/providerQuotaState.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const dbCore = await import("../../src/lib/db/core.ts"); + +const log = { info() {}, warn() {}, debug() {}, error() {} }; + +test.after(() => { + dbCore.resetDbInstance(); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + if (ORIGINAL_QUOTA_ROUTING === undefined) delete process.env.OMNIROUTE_QUOTA_AWARE_ROUTING; + else process.env.OMNIROUTE_QUOTA_AWARE_ROUTING = ORIGINAL_QUOTA_ROUTING; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("round-robin quota reservation keeps the connection token limit", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "quota token limit test", + apiKey: "sk-quota-token-limit-test", + rateLimitOverrides: { tpm: 5000 }, + }); + assert.ok(connection?.id); + + const model = "openai/gpt-4o"; + const combo = { + name: "quota-token-limit-test", + strategy: "round-robin", + config: { maxRetries: 0, disableSessionStickiness: true }, + models: [ + { + kind: "model", + provider: "openai", + providerId: "openai", + model: "gpt-4o", + connectionId: connection.id, + id: "quota-token-limit-test-target", + }, + ], + }; + + const result = await handleComboChat({ + body: { + model, + messages: [{ role: "user", content: "reserve this request" }], + max_tokens: 8, + stream: false, + }, + combo, + allCombos: [combo], + isModelAvailable: async () => true, + settings: {}, + log, + handleSingleModel: async () => Response.json({ choices: [{ message: { content: "ok" } }] }), + }); + + assert.equal(result.ok, true); + const snapshot = getProviderQuota(connection.id, model); + assert.equal(snapshot?.known, true); + assert.equal(snapshot?.tokenLimit, 5000); + assert.ok((snapshot?.tokensUsed ?? 0) > 0); +}); diff --git a/tests/unit/combo-target-resolution-split.test.ts b/tests/unit/combo-target-resolution-split.test.ts index be2b89c7ee..92d95f570a 100644 --- a/tests/unit/combo-target-resolution-split.test.ts +++ b/tests/unit/combo-target-resolution-split.test.ts @@ -5,14 +5,13 @@ import os from "node:os"; import path from "node:path"; // Split guard for the #3501 god-file decomposition (PR 2): the target-resolution -// stage of handleComboChat (wildcard expansion → weighted step groups → known -// context overflow → strategy ordering → stickiness/eval/compat/context filters → -// task-aware reorder → prompt-cache affinity → pre-screen) was extracted verbatim -// into resolveComboTargetPipeline. These tests pin the leaf's own contract: the -// shape it hands back to the attempt loop, the pass-through ordering for the plain -// `priority` path, and the `earlyResponse` exit for a request that exceeds every -// target's known context window. The strategy-specific branches stay covered -// end-to-end by the combo-* consumer suites through combo.ts. +// stage of handleComboChat (wildcard expansion → weighted step groups → strategy +// ordering → stickiness/eval/compat/context filters → task-aware reorder → +// prompt-cache affinity → pre-screen) was extracted verbatim into +// resolveComboTargetPipeline. These tests pin the leaf's own contract: the shape it +// hands back to the attempt loop and pass-through ordering for the plain `priority` +// path. The strategy-specific branches stay covered end-to-end by the combo-* +// consumer suites through combo.ts. const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-target-resolution-")); const ORIGINAL_DATA_DIR = process.env.DATA_DIR; @@ -115,7 +114,7 @@ test("an empty combo yields an empty target pool (combo.ts turns it into a 404)" assert.deepEqual(result.orderedTargets, []); }); -test("request exceeding every known context window returns a 400 earlyResponse", async () => { +test("request exceeding every approximate context hint keeps the target pool", async () => { saveModelsDevCapabilities({ "unit-target-resolution": { tiny: capabilityEntry(8_000), @@ -135,16 +134,12 @@ test("request exceeding every known context window returns a 400 earlyResponse", }) ); - assert.ok("earlyResponse" in result, "expected a context-overflow early response"); - if (!("earlyResponse" in result)) return; - assert.equal(result.earlyResponse.status, 400); - const body = (await result.earlyResponse.json()) as { - error?: { code?: string }; - diagnostics?: { terminalReason?: string; attempted?: number }; - }; - assert.equal(body.error?.code, "context_length_exceeded"); - assert.equal(body.diagnostics?.terminalReason, "context_length_exceeded"); - assert.equal(body.diagnostics?.attempted, 0); + assert.ok(!("earlyResponse" in result), "approximate context hints must not reject the pool"); + if ("earlyResponse" in result) return; + assert.deepEqual( + result.orderedTargets.map((target) => target.modelStr), + ["unit-target-resolution/tiny", "unit-target-resolution/small"] + ); }); // #8790: maxContextWindow rejects every target whose known context window diff --git a/tests/unit/combo-target-timeout-runner.test.ts b/tests/unit/combo-target-timeout-runner.test.ts index e75fee746b..a058a94552 100644 --- a/tests/unit/combo-target-timeout-runner.test.ts +++ b/tests/unit/combo-target-timeout-runner.test.ts @@ -1,6 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildTargetTimeoutRunner } from "../../open-sse/services/combo/targetTimeoutRunner.ts"; +import { + buildTargetTimeoutRunner, + drainLastTimeoutContexts, +} from "../../open-sse/services/combo/targetTimeoutRunner.ts"; import type { ComboLogger, SingleModelTarget } from "../../open-sse/services/combo/types.ts"; const noopLog: ComboLogger = { warn() {}, info() {}, error() {}, debug() {} }; @@ -85,3 +88,119 @@ test("hedge do parent já abortado propaga o abort ao filho", async () => { await runner({}, "m", parentTarget); assert.equal(sawAbort, true); }); + +test("rejection from handleSingleModel after timeout does not leak as unhandledRejection", async () => { + // Simulate: timeout fires, handleSingleModel later rejects with the abort error. + // Before the fix, this rejection could escape as an unhandledRejection if the + // .catch() handler itself threw or if the promise chain had a gap. + let unhandledRejectionFired = false; + const handler = (reason: unknown) => { + if (reason instanceof Error && reason.message === "combo-per-model-timeout") { + unhandledRejectionFired = true; + } + }; + process.on("unhandledRejection", handler); + + const runner = buildTargetTimeoutRunner({ + handleSingleModel: (_b, _m, target) => + new Promise((_resolve, reject) => { + const sig = target?.modelAbortSignal; + sig?.addEventListener("abort", () => { + // Simulate an upstream that rejects on abort (common pattern). + reject(new Error(sig.reason?.message ?? "aborted")); + }); + }), + comboTargetTimeoutMs: 10, + log: noopLog, + }); + + const res = await runner({}, "test-model"); + assert.equal(res.status, 504, "timeout must win the race"); + + // Drain microtasks — the rejected promise from handleSingleModel should be + // caught by the .catch() handler, not surface as unhandledRejection. + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + process.removeListener("unhandledRejection", handler); + assert.equal( + unhandledRejectionFired, + false, + "handleSingleModel rejection must be caught, not leak as unhandledRejection" + ); +}); + +test("defensive outer .catch() handles unexpected throws in inner .catch()", async () => { + // Edge case: if the inner .catch() handler itself throws (e.g. a broken + // Error.prototype.message getter), the outer defensive .catch() must + // prevent an unhandledRejection. + let unhandledRejectionFired = false; + const handler = (reason: unknown) => { + if (reason instanceof Error && reason.message === "message getter exploded") { + unhandledRejectionFired = true; + } + }; + process.on("unhandledRejection", handler); + + const runner = buildTargetTimeoutRunner({ + handleSingleModel: async () => { + const err = new Error("upstream-fail"); + // Sabotage the message getter to throw in the .catch() handler. + Object.defineProperty(err, "message", { + get() { + throw new Error("message getter exploded"); + }, + }); + throw err; + }, + comboTargetTimeoutMs: 10000, // long enough that timeout doesn't fire + log: noopLog, + }); + + const res = await runner({}, "broken-model"); + // The defensive outer .catch() should return a 502 instead of letting + // the throw escape. + assert.equal(res.status, 502, "defensive catch must return 502"); + assert.match( + await res.text(), + /message getter exploded/, + "error detail must be included in response" + ); + + // Drain microtasks. + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + process.removeListener("unhandledRejection", handler); + assert.equal( + unhandledRejectionFired, + false, + "defensive catch must prevent unhandledRejection from inner .catch() throw" + ); +}); + +test("drainLastTimeoutContexts returns and clears recorded contexts", async () => { + // Drain any leftover contexts from previous tests. + drainLastTimeoutContexts(); + + const runner = buildTargetTimeoutRunner({ + handleSingleModel: () => new Promise(() => {}), // never resolves + comboTargetTimeoutMs: 10, + log: noopLog, + }); + + // Fire two timeouts to verify the ring buffer. + await runner({}, "model-a"); + await runner({}, "model-b"); + + const contexts = drainLastTimeoutContexts(); + assert.ok(contexts.length >= 1, "at least one context must be recorded"); + assert.equal(contexts[contexts.length - 1].modelStr, "model-b"); + assert.equal(contexts[contexts.length - 1].timeoutMs, 10); + assert.ok(contexts[contexts.length - 1].abortError instanceof Error); + assert.ok(contexts[contexts.length - 1].timestamp > 0); + + // drain clears the buffer. + const second = drainLastTimeoutContexts(); + assert.equal(second.length, 0, "second drain must return empty"); +}); diff --git a/tests/unit/combo/combo-decision-trace.test.ts b/tests/unit/combo/combo-decision-trace.test.ts new file mode 100644 index 0000000000..0da363ad34 --- /dev/null +++ b/tests/unit/combo/combo-decision-trace.test.ts @@ -0,0 +1,311 @@ +// #10681: combo decision trace — unit + integration (public handleComboChat). +import { test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-trace-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-decision-trace-secret"; + +const { + COMBO_SKIP_REASONS, + createInvocationId, + finalizeComboTrace, + getComboTrace, + recordComboDecision, + resetComboTraceStore, + startComboTrace, +} = await import("../../../open-sse/services/combo/decisionTrace.ts"); +const { handleComboChat } = await import("../../../open-sse/services/combo.ts"); +const { recordComboRequest, resetComboMetrics } = + await import("../../../open-sse/services/comboMetrics.ts"); + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +function okResponse(content: string) { + return new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content } }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} +function rateLimitedResponse() { + return new Response( + JSON.stringify({ + error: { message: "rate limited", type: "rate_limit_error", code: "rate_limit" }, + }), + { status: 429, headers: { "Content-Type": "application/json" } } + ); +} + +beforeEach(() => resetComboTraceStore()); + +test("createInvocationId yields unique opaque ids", () => { + const a = createInvocationId(); + const b = createInvocationId(); + assert.ok(a.startsWith("combo-")); + assert.notEqual(a, b); +}); + +test("skip reasons are allowlisted (unknown reason is rejected)", () => { + startComboTrace("combo-t", { strategy: "priority", comboName: "x" }); + for (const reason of COMBO_SKIP_REASONS) { + recordComboDecision("combo-t", { + step: "s", + target: "p/m", + decision: "skipped_before_dispatch", + reason, + }); + } + assert.throws(() => + recordComboDecision("combo-t", { + step: "s", + target: "p/m", + decision: "skipped_before_dispatch", + reason: "freeform upstream error", + }) + ); + assert.equal(getComboTrace("combo-t")!.decisions.length, COMBO_SKIP_REASONS.length); +}); + +test("finalize marks never-iterated targets as not_reached", () => { + startComboTrace("combo-t", { strategy: "priority", comboName: "x" }); + recordComboDecision("combo-t", { step: "s1", target: "p/a", decision: "dispatched" }); + recordComboDecision("combo-t", { + step: "s2", + target: "p/b", + decision: "skipped_before_dispatch", + reason: "provider_cooldown", + }); + const trace = finalizeComboTrace("combo-t", [ + { executionKey: "s1", modelStr: "p/a" }, + { executionKey: "s2", modelStr: "p/b" }, + { executionKey: "s3", modelStr: "p/c" }, + ])!; + assert.deepEqual( + trace.decisions.map((d) => d.decision), + ["dispatched", "skipped_before_dispatch", "not_reached"] + ); +}); + +test("handleComboChat: mixed fallback produces an ordered decision trace", async () => { + const invocationId = createInvocationId(); + const calls: string[] = []; + const res = await handleComboChat({ + invocationId, + body: { messages: [{ role: "user", content: "ping" }] }, + combo: { + name: "trace-std", + strategy: "priority", + models: ["openai/a", "openai/b", "openai/c"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async (_b: Record, modelStr: string) => { + calls.push(modelStr); + if (modelStr === "openai/a") return rateLimitedResponse(); + return okResponse("recovered"); + }, + isModelAvailable: async () => true, + log, + settings: null, + allCombos: null, + }); + assert.equal(res.status, 200); + assert.deepEqual(calls, ["openai/a", "openai/b"]); + + const trace = getComboTrace(invocationId)!; + assert.equal(trace.comboName, "trace-std"); + assert.equal(trace.strategy, "priority"); + assert.deepEqual( + trace.decisions.map((d) => ({ target: d.target, decision: d.decision })), + [ + { target: "openai/a", decision: "dispatched" }, + { target: "openai/b", decision: "dispatched" }, + { target: "openai/c", decision: "not_reached" }, + ] + ); + assert.equal(trace.terminal?.status, 200); +}); + +test("handleComboChat: predictive-TTFT skip records skipped_before_dispatch/predictive_ttft", async () => { + const comboName = "trace-predictive-ttft"; + resetComboMetrics(comboName); + // Seed enough samples (>= PREDICTIVE_TTFT_MIN_SAMPLES) with a high average + // latency for openai/a so the predictive-TTFT breaker trusts and trips on it. + for (let i = 0; i < 5; i++) { + recordComboRequest(comboName, "openai/a", { + success: true, + latencyMs: 5000, + strategy: "priority", + }); + } + + const invocationId = createInvocationId(); + const calls: string[] = []; + const res = await handleComboChat({ + invocationId, + body: { messages: [{ role: "user", content: "ping" }] }, + combo: { + name: comboName, + strategy: "priority", + models: ["openai/a", "openai/b"], + config: { + maxRetries: 0, + retryDelayMs: 0, + fallbackDelayMs: 0, + zeroLatencyOptimizationsEnabled: true, + predictiveTtftMs: 100, + }, + }, + handleSingleModel: async (_b: Record, modelStr: string) => { + calls.push(modelStr); + return okResponse(`ok-${modelStr}`); + }, + isModelAvailable: async () => true, + log, + settings: null, + allCombos: null, + }); + assert.equal(res.status, 200); + // openai/a must never be dispatched — it is skipped pre-flight by the + // predictive-TTFT breaker; only openai/b is actually called. + assert.deepEqual(calls, ["openai/b"]); + + const trace = getComboTrace(invocationId)!; + assert.deepEqual( + trace.decisions.map((d) => ({ + target: d.target, + decision: d.decision, + reason: d.reason ?? null, + })), + [ + { target: "openai/a", decision: "skipped_before_dispatch", reason: "predictive_ttft" }, + { target: "openai/b", decision: "dispatched", reason: null }, + ] + ); +}); + +test("handleComboChat: pre-dispatch skip records allowlisted reason", async () => { + const invocationId = createInvocationId(); + const res = await handleComboChat({ + invocationId, + body: { messages: [{ role: "user", content: "ping" }] }, + combo: { + name: "trace-skip", + strategy: "priority", + models: ["openai/a", "openai/b", "openai/c"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async (_b, modelStr) => + modelStr === "openai/a" ? rateLimitedResponse() : okResponse(`ok-${modelStr}`), + isModelAvailable: async (_m: string, target?: { modelStr?: string }) => + target?.modelStr !== "openai/b", + log, + settings: null, + allCombos: null, + }); + assert.equal(res.status, 200); + const trace = getComboTrace(invocationId)!; + assert.deepEqual( + trace.decisions.map((d) => ({ + target: d.target, + decision: d.decision, + reason: d.reason ?? null, + })), + [ + { target: "openai/a", decision: "dispatched", reason: null }, + { target: "openai/b", decision: "skipped_before_dispatch", reason: "availability" }, + { target: "openai/c", decision: "dispatched", reason: null }, + ] + ); +}); + +test("egress: every response carries X-OmniRoute-Combo-Trace (success path)", async () => { + const invocationId = createInvocationId(); + const res = await handleComboChat({ + invocationId, + body: { messages: [{ role: "user", content: "ping" }] }, + combo: { + name: "egress-ok", + strategy: "priority", + models: ["openai/a", "openai/b"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async () => okResponse("recovered"), + isModelAvailable: async () => true, + log, + settings: null, + allCombos: null, + }); + assert.equal(res.status, 200); + assert.equal(res.headers.get("X-OmniRoute-Combo-Trace"), invocationId); +}); + +test("egress: header present even when every target fails", async () => { + const invocationId = createInvocationId(); + const res = await handleComboChat({ + invocationId, + body: { messages: [{ role: "user", content: "ping" }] }, + combo: { + name: "egress-fail", + strategy: "priority", + models: ["openai/a", "openai/b"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async () => rateLimitedResponse(), + isModelAvailable: async () => true, + log, + settings: null, + allCombos: null, + }); + assert.notEqual(res.status, 200); + assert.equal(res.headers.get("X-OmniRoute-Combo-Trace"), invocationId); +}); + +test("egress: finalized trace is emitted as one metadata-only log line", async () => { + const invocationId = createInvocationId(); + const infoCalls: string[] = []; + const capturingLog = { + info: (_cat: string, msg: string) => infoCalls.push(msg), + warn: noop, + debug: noop, + error: noop, + }; + const res = await handleComboChat({ + invocationId, + body: { messages: [{ role: "user", content: "ping" }] }, + combo: { + name: "egress-log", + strategy: "priority", + models: ["openai/a", "openai/b"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async () => okResponse("recovered"), + isModelAvailable: async () => true, + log: capturingLog, + settings: null, + allCombos: null, + }); + assert.equal(res.status, 200); + const line = infoCalls.find((m) => m.includes("combo trace") && m.includes(invocationId)); + assert.ok(line, "finalized trace log line expected"); + assert.ok(line!.includes('"status":200'), "log line must carry the terminal status"); + assert.ok(!line!.includes("messages"), "log line must not carry request content"); +}); + +test("retention: in-flight traces are pinned against eviction (finalized evicted first)", () => { + resetComboTraceStore(); + for (let i = 0; i < 2000; i++) { + startComboTrace(`combo-t-${i}`, { strategy: "priority", comboName: "x" }); + } + // Only the FIRST trace is finalized; the other 1999 are still in flight. + finalizeComboTrace("combo-t-0", [{ executionKey: "s", modelStr: "p/m" }]); + // Burst beyond the cap: eviction must prefer the finalized trace. + startComboTrace("combo-t-2000", { strategy: "priority", comboName: "x" }); + assert.equal(getComboTrace("combo-t-0"), null, "finalized trace is the eviction victim"); + assert.ok(getComboTrace("combo-t-1"), "in-flight trace survives the burst"); + assert.ok(getComboTrace("combo-t-1999"), "in-flight trace survives the burst"); + assert.ok(getComboTrace("combo-t-2000"), "new trace is stored"); +}); diff --git a/tests/unit/compression/llmlingua-worker.test.ts b/tests/unit/compression/llmlingua-worker.test.ts index aea8931da6..7bb74f2288 100644 --- a/tests/unit/compression/llmlingua-worker.test.ts +++ b/tests/unit/compression/llmlingua-worker.test.ts @@ -1,8 +1,8 @@ /** * Tests for the real LLMLingua worker-thread backend (`worker.ts` + `onnxWorker.ts`). * - * The four optional deps (`@atjsh/llmlingua-2`, `@huggingface/transformers`, - * `@tensorflow/tfjs`, `js-tiktoken`) are NOT installed in this worktree, so the + * The three optional deps (`@atjsh/llmlingua-2`, `@huggingface/transformers`, + * `js-tiktoken`) are NOT installed in this worktree, so the * default path MUST fail-open WITHOUT spawning a worker: * * 1. Deps absent → fail-open, no spawn (ALWAYS runs here): the backend returns the @@ -23,12 +23,11 @@ import { const require = createRequire(import.meta.url); -/** Whether all four optional deps resolve in this environment. */ +/** Whether all three optional deps resolve in this environment. */ function depsResolve(): boolean { try { require.resolve("@atjsh/llmlingua-2"); require.resolve("@huggingface/transformers"); - require.resolve("@tensorflow/tfjs"); require.resolve("js-tiktoken"); return true; } catch { diff --git a/tests/unit/compression/rtk-engine.test.ts b/tests/unit/compression/rtk-engine.test.ts index 7406f53634..4741ddb6e6 100644 --- a/tests/unit/compression/rtk-engine.test.ts +++ b/tests/unit/compression/rtk-engine.test.ts @@ -70,7 +70,8 @@ describe("RTK compression engine", () => { assert.equal(rtkEngine.validateConfig({ intensity: "invalid" }).valid, false); assert.equal(rtkEngine.validateConfig({ rawOutputRetention: "always" }).valid, true); - const body = { messages: [{ role: "tool", content: "same\nsame\nsame\nsame" }] }; + const repeated = Array.from({ length: 20 }, () => "same").join("\n"); + const body = { messages: [{ role: "tool", content: repeated }] }; assert.equal( rtkEngine.apply(body, { config: { rtkConfig: { enabled: true } } }).stats?.engine, "rtk" diff --git a/tests/unit/compression/rtk-raw-output-retention.test.ts b/tests/unit/compression/rtk-raw-output-retention.test.ts new file mode 100644 index 0000000000..32349bcafb --- /dev/null +++ b/tests/unit/compression/rtk-raw-output-retention.test.ts @@ -0,0 +1,111 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + maybePersistRtkRawOutput, + purgeRtkRawOutput, + readRtkRawOutput, + resetRtkRawOutputPurgeThrottle, +} from "../../../open-sse/services/compression/engines/rtk/rawOutput.ts"; + +const originalDataDir = process.env.DATA_DIR; + +afterEach(() => { + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + resetRtkRawOutputPurgeThrottle(); +}); + +function freshDataDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-rtk-store-")); + process.env.DATA_DIR = dir; + return dir; +} + +function storeRoot(dataDir: string): string { + return path.join(dataDir, "rtk", "raw-output"); +} + +/** Write a raw-output file in the bucketized layout and return its pointer id. */ +function writeBucketFile( + dataDir: string, + ts: number, + command: string, + idHex: string, + content: string +): string { + const bucket = path.join(storeRoot(dataDir), idHex.slice(0, 2)); + fs.mkdirSync(bucket, { recursive: true }); + const slug = command.replace(/[^A-Za-z0-9_-]+/g, "_").slice(0, 48); + fs.writeFileSync(path.join(bucket, `${ts}-${slug}-${idHex}.log`), content); + return idHex; +} + +describe("RTK raw-output bounded retention (#10659)", () => { + it("writes new captures into id-prefix buckets and reads them back", () => { + const dataDir = freshDataDir(); + const text = "error: boom\nnoise\n".repeat(8); + const pointer = maybePersistRtkRawOutput(text, { retention: "always" }); + assert.ok(pointer, "pointer should be produced with retention=always"); + // Bucketed layout: pointer.path sits one level below the store root. + assert.equal(path.dirname(pointer!.path), path.join(storeRoot(dataDir), pointer!.id.slice(0, 2))); + assert.ok(fs.existsSync(pointer!.path), "bucket file exists on disk"); + assert.equal(readRtkRawOutput(pointer!.id), text, "read resolves via bucket lookup"); + }); + + it("still reads legacy flat-store files (backward compatibility)", () => { + const dataDir = freshDataDir(); + const store = storeRoot(dataDir); + fs.mkdirSync(store, { recursive: true }); + const id = "ab".padEnd(24, "0"); + fs.writeFileSync(path.join(store, `1710000000000-tool-output-${id}.log`), "legacy content"); + assert.equal(readRtkRawOutput(id), "legacy content"); + }); + + it("returns null for unknown pointer ids", () => { + freshDataDir(); + assert.equal(readRtkRawOutput("ffffffffffffffffffffffff"), null); + }); + + it("purge deletes files older than maxAgeDays and keeps recent ones", async () => { + const dataDir = freshDataDir(); + const now = Date.now(); + const oldId = writeBucketFile(dataDir, now - 40 * 86_400_000, "old", "aa".padEnd(24, "0"), "old"); + writeBucketFile(dataDir, now - 40 * 86_400_000, "old2", "ab".padEnd(24, "0"), "old2"); + const recentId = writeBucketFile(dataDir, now - 1000, "recent", "ac".padEnd(24, "0"), "recent"); + + const result = await purgeRtkRawOutput({ maxAgeDays: 30, maxFiles: 100_000 }); + assert.equal(result.skipped, false); + assert.equal(result.deleted, 2); + assert.equal(readRtkRawOutput(oldId), null, "aged-out file purged"); + assert.equal(readRtkRawOutput(recentId), "recent", "recent file kept"); + }); + + it("purge caps the store at maxFiles, keeping the newest", async () => { + const dataDir = freshDataDir(); + const now = Date.now(); + const ids: string[] = []; + for (let i = 0; i < 8; i++) { + const id = `b${i}`.padEnd(24, "b").slice(0, 24); + writeBucketFile(dataDir, now - i * 1000, `cmd${i}`, id, `content${i}`); + ids.push(id); + } + const result = await purgeRtkRawOutput({ maxAgeDays: 30, maxFiles: 5 }); + assert.equal(result.deleted, 3); + // Newest 5 (i=0..4) survive; oldest 3 (i=5..7) are purged. + assert.equal(readRtkRawOutput(ids[0]), "content0"); + assert.equal(readRtkRawOutput(ids[4]), "content4"); + assert.equal(readRtkRawOutput(ids[5]), null); + assert.equal(readRtkRawOutput(ids[7]), null); + }); + + it("retention=never writes nothing to disk", () => { + const dataDir = freshDataDir(); + const pointer = maybePersistRtkRawOutput("some output", { retention: "never" }); + assert.equal(pointer, null); + assert.equal(fs.existsSync(storeRoot(dataDir)), false); + }); +}); diff --git a/tests/unit/connection-test-timed-out-network-error.test.ts b/tests/unit/connection-test-timed-out-network-error.test.ts new file mode 100644 index 0000000000..fbba8ba0b4 --- /dev/null +++ b/tests/unit/connection-test-timed-out-network-error.test.ts @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { classifyFailure } = await import("../../src/app/api/providers/[id]/test/route.ts"); + +// The OAuth probe's own abort message is "Test timed out after Xs" (route.ts's +// AbortSignal.timeout handling), which contains "timed out" but not "timeout" — +// classifyFailure must classify it as network_error, not the generic upstream_error +// fallback, so a transient probe timeout doesn't paint the connection permanently red. +test("classifyFailure maps an OAuth-probe 'timed out' message to network_error", () => { + const diagnosis = classifyFailure({ + error: "Test timed out after 30s", + statusCode: null, + provider: "some-oauth-provider", + }); + + assert.equal(diagnosis.type, "network_error"); + assert.equal(diagnosis.code, "network_error"); +}); + +test("classifyFailure still maps the existing 'timeout' substring to network_error", () => { + const diagnosis = classifyFailure({ + error: "connect ETIMEDOUT — timeout while probing upstream", + statusCode: null, + provider: "some-oauth-provider", + }); + + assert.equal(diagnosis.type, "network_error"); +}); diff --git a/tests/unit/console-interceptor-message-fidelity.test.ts b/tests/unit/console-interceptor-message-fidelity.test.ts new file mode 100644 index 0000000000..ab1cd6f90e --- /dev/null +++ b/tests/unit/console-interceptor-message-fidelity.test.ts @@ -0,0 +1,95 @@ +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"; + +// Two defects in the same formatting path, both visible in a real app log: +// +// - the component was read as the first bracket, so entries from the tagged logger +// ("[INFO] [TAG] message") were filed under the level and the tag was lost; +// - printf format strings were not applied, so "%s"/"%d" stayed literal and the values +// trailed behind them without labels. +// +// Both assertions below fail against the previous implementation. +// +// consoleInterceptor freezes `logToFile` and `logFilePath` at import time, so the env has +// to be set before the module is loaded — hence the dynamic import. + +const LOG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-interceptor-fidelity-")); +const LOG_PATH = path.join(LOG_DIR, "app.log"); + +process.env.APP_LOG_FILE_PATH = LOG_PATH; +process.env.APP_LOG_TO_FILE = "true"; + +const { initConsoleInterceptor, __consoleInterceptorInternals } = + await import("../../src/lib/consoleInterceptor.ts"); + +function readEntries(): Array> { + if (!fs.existsSync(LOG_PATH)) return []; + return fs + .readFileSync(LOG_PATH, "utf8") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as Record); +} + +test("the interceptor keeps the component and substitutes printf formats", () => { + try { + initConsoleInterceptor(); + + console.log("[INFO] [SKILLS_INJECTION] injected 3 skills"); + console.log("[LiveWS] Client connected: %s (%s) [%d total]", "37cb8f70", "127.0.0.1", 1); + console.log("plain message", { a: 1 }); + + __consoleInterceptorInternals.reset(); + + const entries = readEntries(); + assert.ok(entries.length > 0, "interceptor wrote nothing"); + + const tagged = entries.find((e) => String(e.message ?? "").includes("SKILLS_INJECTION")); + assert.ok(tagged, "tagged entry not written"); + assert.equal(tagged.component, "SKILLS_INJECTION"); + + const formatted = entries + .map((e) => String(e.message ?? "")) + .find((m) => m.includes("Client connected")); + assert.ok(formatted, "LiveWS entry not written"); + assert.equal(formatted, "[LiveWS] Client connected: 37cb8f70 (127.0.0.1) [1 total]"); + + // No format string: the previous join behaviour is preserved verbatim. + const plain = entries + .map((e) => String(e.message ?? "")) + .find((m) => m.startsWith("plain message")); + assert.equal(plain, 'plain message {"a":1}'); + } finally { + __consoleInterceptorInternals.reset(); + fs.rmSync(LOG_DIR, { recursive: true, force: true }); + } +}); + +test("a first argument that coincidentally contains a printf token does not swallow a trailing Error", () => { + try { + initConsoleInterceptor(); + + // Dynamic, non-format-string content (e.g. a hook/tag name) that happens to contain "%s" — + // the real defect this guards: util.format() would consume `err` as the %s substitution and + // drop its message/stack instead of appending them. + const err = new Error("boom"); + console.error('[Middleware] Failed to compile hook "handler%sname":', err); + + __consoleInterceptorInternals.reset(); + + const entries = readEntries(); + const entry = entries + .map((e) => String(e.message ?? "")) + .find((m) => m.includes("Failed to compile hook")); + + assert.ok(entry, "entry not written"); + assert.ok(entry.includes("boom"), "Error message was dropped"); + assert.ok(entry.includes(err.stack || ""), "Error stack was dropped"); + } finally { + __consoleInterceptorInternals.reset(); + fs.rmSync(LOG_DIR, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/conversationTracker-reconnect-7847.test.ts b/tests/unit/conversationTracker-reconnect-7847.test.ts new file mode 100644 index 0000000000..00686f37ac --- /dev/null +++ b/tests/unit/conversationTracker-reconnect-7847.test.ts @@ -0,0 +1,183 @@ +/** + * Regression tests for the #7847-class pre-routing stall caused by the + * conversation-tracker reconnect walk + * (open-sse/services/conversationTracker.ts). + * + * findReconnectMatch evaluates every (start turn × duplicate anchor) pair and + * walks the chain forward, computing an HMAC per step. On long coding-agent + * histories (1000+ turns, heavily duplicated tool outputs) that walk is + * O(starts × anchors × walkLength) with the turn's FULL text re-hashed at + * every step — measured on production traffic as a 10-130 s synchronous + * block on the request path (chat.ts resolves the conversation id in the + * validate phase, before routing). These tests pin the two properties that + * keep it bounded: + * + * 1. The walk charges a step budget and never exceeds it (pure, no DB). + * 2. A duplicate-heavy long-history resolve completes in bounded wall time + * (DB-backed end-to-end through resolveConversationId). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-conv-7847-")); +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "conversation-7847-test-secret"; + +// Dynamic imports: modules reading DATA_DIR at top level must evaluate after +// the override above (see conversationTracker.test.ts for the full rationale). +const tracker = await import("../../open-sse/services/conversationTracker.ts"); +const { findReconnectMatch, resolveConversationId, hashTurnContent, DEFAULT_RECONNECT_MAX_STEPS } = + tracker as { + findReconnectMatch: typeof tracker.findReconnectMatch; + resolveConversationId: typeof tracker.resolveConversationId; + hashTurnContent: typeof tracker.hashTurnContent; + DEFAULT_RECONNECT_MAX_STEPS: number; + }; +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + +test.after(() => { + try { + resetDbInstance(); + } catch { + /* DB may already be closed */ + } +}); + +function turn(role: "user" | "assistant" | "tool", text: string) { + return { role, text, blockKind: "text" as const, toolName: null }; +} + +test("findReconnectMatch is exported and enforces a step budget", () => { + assert.equal(typeof findReconnectMatch, "function", "findReconnectMatch must be exported"); + assert.ok( + DEFAULT_RECONNECT_MAX_STEPS > 0 && DEFAULT_RECONNECT_MAX_STEPS <= 500_000, + "default budget must be a sane bounded constant" + ); + + // Adversarial shape: many turns, all with the same text ("ok" tool outputs), + // and an index whose content-hash bucket holds many duplicate anchors — + // each (start, anchor) pair invites a forward walk. + const N = 400; + const chainTurns = Array.from({ length: N }, (_, i) => turn(i % 2 === 0 ? "user" : "tool", "ok")); + const okHash = hashTurnContent(turn("user", "ok")); + const anchors = Array.from({ length: 40 }, (_, i) => `anchor-${i}`); + const nodeIds = new Set(anchors); + const parentsWithChildren = new Set(); + const index = { + nodeIds, + byContentHash: new Map([[okHash, anchors]]), + parentsWithChildren, + }; + + const { stepsUsed } = findReconnectMatch(chainTurns, index, { maxSteps: 50 }); + assert.ok(stepsUsed <= 50, `budget must cap work (used ${stepsUsed})`); +}); + +test("findReconnectMatch: budget exhaustion degrades to no-match, never a wrong attach", () => { + // A genuine 3-turn continuation that WOULD match with enough budget — + // with maxSteps too small to verify even one step, the walker must return + // no match (resolveConversationId then mints a new conversation) rather + // than attaching to an unverified anchor. + const t0 = turn("user", "hello"); + const t1 = turn("assistant", "hi"); + const t2 = turn("user", "do the thing"); + const h0 = hashTurnContent(t0); + const h1 = hashTurnContent(t1); + const h2 = hashTurnContent(t2); + + // Build a real 3-node chain: n1 -> n2 -> n3. + const ids = (["", "n1", "n2", "n3"] as const).slice(0); + const chain = (parent: string, hash: string) => `node:${parent}:${hash.slice(0, 8)}`; + const n1 = chain("root", h0); + const n2 = chain(n1, h1); + const n3 = chain(n2, h2); + + const index = { + nodeIds: new Set([n1, n2, n3]), + byContentHash: new Map([ + [h0, [n1]], + [h1, [n2]], + [h2, [n3]], + ]), + parentsWithChildren: new Set([n1, n2]), + }; + void ids; + + const full = findReconnectMatch([t0, t1, t2], index); + assert.ok(full.match, "with the default budget the 3-turn continuation matches"); + assert.equal(full.match?.matchEndIndex, 3); + + const starved = findReconnectMatch([t0, t1, t2], index, { maxSteps: 0 }); + assert.equal(starved.match, null, "a zero budget must yield no match, not an unverified one"); +}); + +test("resolveConversationId: duplicate-heavy long history resolves in bounded time (#7847)", async () => { + // Shape mirrors production coding-agent traffic: ~800 turns where every + // other turn is a byte-identical short tool output (the duplicate-anchor + // amplifier the tracker's own docs describe) and the rest are large + // file-content turns. The second request edits every large turn (a + // cache-warm rewrite clients really do), so every duplicate start turn has + // hundreds of stale anchors to walk past before giving up. + const N = 800; + const pad = "x".repeat(40 * 1024); + const messages: Array> = [{ role: "system", content: "sys" }]; + for (let i = 0; i < N; i++) { + if (i % 2 === 0) { + messages.push({ role: "tool", tool_call_id: `c${i}`, content: "ok" }); + } else { + messages.push({ role: "user", content: `file ${i}\n${pad}` }); + } + } + const body1 = { model: "big-pickle-7847", messages }; + const body2 = { + model: "big-pickle-7847", + messages: [ + messages[0], + ...messages + .slice(1) + .map((m, idx) => + idx % 2 === 1 + ? { ...(m as object), content: `${(m as { content: string }).content} v2` } + : m + ), + ], + }; + + const apiKeyId = "key-7847"; + const first = await resolveConversationId({ + body: body1, + model: "big-pickle-7847", + apiKeyId, + clientSessionIdHeader: null, + correlationId: "corr-7847-1", + }); + assert.equal(first.isNewConversation, true); + + const startedAt = Date.now(); + const second = await resolveConversationId({ + body: body2, + model: "big-pickle-7847", + apiKeyId, + clientSessionIdHeader: null, + correlationId: "corr-7847-2", + }); + const elapsedMs = Date.now() - startedAt; + + // Before the bound: ~10 s+ of synchronous HMAC work on this exact shape. + // After: the walk is budget-capped and turn hashes are memoized, so the + // whole resolve stays in the tens-of-milliseconds range. 2 s leaves ample + // headroom for slow CI while still failing hard on a regression. + assert.ok( + elapsedMs < 2_000, + `duplicate-heavy resolve took ${elapsedMs}ms (budget/memoization regression)` + ); + + // Editing every large turn diverges from the recorded chain — the tracker + // must mint a new conversation for it, never attach to the stale one. + assert.equal(second.isNewConversation, true); + assert.notEqual(second.conversationId, first.conversationId); +}); diff --git a/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts b/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts index f95cd12df3..98a3548dfe 100644 --- a/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts +++ b/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts @@ -29,12 +29,24 @@ class MockM365WebSocket { send(data: string): void { this.sent.push(String(data)); - const parsed = JSON.parse(String(data).replace(/\x1e$/, "")); - if (parsed.protocol === "json") { + // #10718 — a single socket write may carry multiple \x1e-terminated frames + // (the chat invocation and its Metrics follow-up ride together). + const parsedFrames = String(data) + .split("\x1e") + .filter((f) => f.length > 0) + .map((f) => { + try { + return JSON.parse(f); + } catch { + return null; + } + }); + const parsed = parsedFrames.find((f) => f && f.protocol === "json") ?? parsedFrames[0]; + if (parsed?.protocol === "json") { queueMicrotask(() => this.emit("message", Buffer.from(encodeFrame({})))); return; } - if (parsed.type === 4 && parsed.target === "chat") { + if (parsedFrames.some((f) => f?.type === 4 && f?.target === "chat")) { queueMicrotask(() => { this.emit( "message", @@ -83,7 +95,7 @@ async function sendChatInvocation(tier: string | undefined) { assert.equal(MockM365WebSocket.instances.length, 1); const sentFrames = MockM365WebSocket.instances[0].sent; const chatFrameRaw = sentFrames - .map((f) => f.replace(/\x1e$/, "")) + .flatMap((f) => f.split("\x1e").filter((frame) => frame.length > 0)) .map((f) => { try { return JSON.parse(f); @@ -132,31 +144,30 @@ test("#7870: enterprise-tier chat invocation defaults tone to Magic", async () = assert.equal(invocationArgs.tone, "Magic"); }); -test("#7870: individual (no tier) chat invocation payload stays byte-identical to today", async () => { +test("#10718: individual (no tier) chat invocation carries the recaptured 2026-08 shape", async () => { const invocationArgs = await sendChatInvocation(undefined); const optionsSets = invocationArgs.optionsSets as string[]; - assert.ok(optionsSets.includes("enable_msa_user")); - assert.equal(invocationArgs.tone, ""); + // The 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is gone from + // the wire — the stale set was part of the silently-dropped shape. + assert.ok(!optionsSets.includes("enable_msa_user")); + assert.ok(optionsSets.includes("enable_gg_gpt")); + // The browser sends tone:"magic" (lowercase) on the individual/EDU surface. + assert.equal(invocationArgs.tone, "magic"); assert.deepEqual(invocationArgs.allowedMessageTypes, [ "Chat", "Suggestion", - "InternalSearchQuery", "Disengaged", - "InternalLoaderMessage", "Progress", - "GeneratedCode", - "RenderCardRequest", - "AdsQuery", - "SemanticSerp", - "GenerateContentQuery", + "EndOfRequest", + "InternalLoaderMessage", ]); }); -test("#7870: EDU-tier chat invocation payload stays byte-identical to today (unaffected by enterprise change)", async () => { +test("#10718: EDU-tier chat invocation carries the same recaptured shape", async () => { const invocationArgs = await sendChatInvocation("edu"); const optionsSets = invocationArgs.optionsSets as string[]; - assert.ok(optionsSets.includes("enable_msa_user")); - assert.equal(invocationArgs.tone, ""); + assert.ok(!optionsSets.includes("enable_msa_user")); + assert.equal(invocationArgs.tone, "magic"); }); test("#8971: enterprise-tier chat invocation must send disconnectBehavior=continue", async () => { @@ -168,11 +179,11 @@ test("#8971: enterprise-tier chat invocation must send disconnectBehavior=contin ); }); -test("#8971: individual (no tier) chat invocation disconnectBehavior remains empty (byte-identical to #4042)", async () => { +test("#8971/#10718: individual (no tier) chat invocation omits disconnectBehavior (not on the 2026-08 wire)", async () => { const invocationArgs = await sendChatInvocation(undefined); assert.equal( invocationArgs.disconnectBehavior, - "", - `individual-tier invocation must carry disconnectBehavior=""; got ${JSON.stringify(invocationArgs.disconnectBehavior)}` + undefined, + `individual-tier invocation must omit disconnectBehavior; got ${JSON.stringify(invocationArgs.disconnectBehavior)}` ); }); diff --git a/tests/unit/copilot-m365-invocation-refresh-10718.test.ts b/tests/unit/copilot-m365-invocation-refresh-10718.test.ts new file mode 100644 index 0000000000..f5e602ce3d --- /dev/null +++ b/tests/unit/copilot-m365-invocation-refresh-10718.test.ts @@ -0,0 +1,205 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #10718 — the substrate started dropping the old type:4 chat invocation shape +// (immediate bare type:3 close, "(empty response)" on every request). A fresh +// TLS-MITM capture of a working m365.cloud.microsoft/chat round-trip (2026-08) +// showed a materially different argument shape AND a type:1 target:"Metrics" +// frame written in the SAME socket write right after the invocation — an +// invocation without its Metrics pair is silently ignored. +// +// These tests pin the recaptured wire shape and the refresh_token pre-flight +// (the browser-issued access_token lives ~75 min with no refresh path before +// this). Live round-trip on a real EDU (A3/Starter) tenant is the separate +// Rule #18 validation gate. + +import { + RECORD_SEPARATOR, + metricsFrame, + buildChatInvocation, + resolveChatInvocationOverrides, + M365_DEFAULT_OPTION_SETS, + ALLOWED_MESSAGE_TYPES, +} from "../../open-sse/executors/copilot-m365-frames.ts"; +import { + decodeJwtClaims, + tokenNeedsRefresh, + refreshM365AccessToken, + M365_OAUTH_CLIENT_ID, + M365_REFRESH_LEAD_MS, +} from "../../open-sse/executors/copilot-m365-connection.ts"; + +// ── Metrics follow-up frame ──────────────────────────────────────────────── + +test("#10718: metricsFrame emits the exact bytes observed in the browser capture", () => { + assert.equal( + metricsFrame(), + '{"arguments":[{"Timestamps":{"ConnectionEstablished":"","ConnectionStart":"","UserInputStart":"","UserInputSubmit":""}}],"target":"Metrics","type":1}' + + RECORD_SEPARATOR + ); +}); + +// ── Recaptured invocation shape ──────────────────────────────────────────── + +test("#10718: buildChatInvocation matches the recaptured arguments[0] key set", () => { + const arg = buildChatInvocation({ + text: "Say OK in one word.", + traceId: "11111111-1111-1111-1111-111111111111", + sessionId: "22222222-2222-2222-2222-222222222222", + requestId: "33333333-3333-3333-3333-333333333333", + conversationId: "44444444-4444-4444-4444-444444444444", + }).arguments[0] as Record; + + // Exact key set from the capture — additions AND omissions are both pinned, + // because the stale keys are exactly what got the shape dropped. + assert.deepEqual(Object.keys(arg).sort(), [ + "allowedMessageTypes", + "clientCorrelationId", + "clientInfo", + "conversationId", + "isStartOfSession", + "message", + "options", + "optionsSets", + "plugins", + "productThreadType", + "sessionId", + "sliceIds", + "source", + "streamingMode", + "threadLevelGptId", + "tone", + "toolChoice", + "traceId", + ]); + assert.equal(arg.productThreadType, "Office"); + assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" }); + assert.equal(arg.conversationId, "44444444-4444-4444-4444-444444444444"); + assert.equal(arg.toolChoice, null); + assert.equal(arg.tone, "magic"); +}); + +test("#10718: the message object carries the recaptured rich shape", () => { + const arg = buildChatInvocation({ + text: "Say OK in one word.", + traceId: "t", + sessionId: "s", + requestId: "r", + conversationId: "c", + }).arguments[0] as Record; + const message = arg.message as Record; + + assert.deepEqual(Object.keys(message).sort(), [ + "adaptiveCards", + "attachments", + "author", + "clientPreferences", + "entityAnnotationTypes", + "experienceType", + "inputMethod", + "locale", + "locationInfo", + "messageType", + "requestId", + "text", + ]); + assert.equal(message.author, "user"); + assert.equal(message.messageType, "Chat"); + assert.equal(message.requestId, "r"); + assert.equal(message.experienceType, "Default"); + assert.deepEqual(message.entityAnnotationTypes, ["People", "File", "Event", "Email", "TeamsMessage"]); + assert.equal(message.attachments, null); + assert.deepEqual(message.locationInfo, { timeZone: "UTC", timeZoneOffset: 0 }); +}); + +test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-entry allowedMessageTypes", () => { + const overrides = resolveChatInvocationOverrides(undefined); + assert.equal(overrides.optionsSets.length, 14); + assert.equal(overrides.allowedMessageTypes.length, 6); + assert.equal(overrides.tone, "magic"); + // The pre-#10718 consumer/MSA flags are gone from the wire. + const optionSets = M365_DEFAULT_OPTION_SETS as readonly string[]; + const messageTypes = ALLOWED_MESSAGE_TYPES as readonly string[]; + for (const stale of ["enable_msa_user", "pdnascan", "cwc_code_interpreter", "rich_responses"]) { + assert.ok(!optionSets.includes(stale), `${stale} must not be in the default option sets`); + } + for (const stale of ["InternalSearchQuery", "GeneratedCode", "RenderCardRequest", "AdsQuery", "SemanticSerp", "GenerateContentQuery"]) { + assert.ok(!messageTypes.includes(stale), `${stale} must not be in allowedMessageTypes`); + } + // Entries the capture showed and the old lists lacked. + assert.ok(optionSets.includes("cwcfluxgptv")); + assert.ok(messageTypes.includes("EndOfRequest")); +}); + +// ── refresh_token helpers ────────────────────────────────────────────────── + +function fakeJwt(claims: Record): string { + const b64 = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${b64({ alg: "none" })}.${b64(claims)}.sig`; +} + +test("#10718: decodeJwtClaims reads exp/tid without verification; non-JWT returns null", () => { + const claims = decodeJwtClaims(fakeJwt({ exp: 123, tid: "tenant-id", oid: "oid" })); + assert.equal(claims?.exp, 123); + assert.equal(claims?.tid, "tenant-id"); + assert.equal(decodeJwtClaims("not.a-jwt"), null); + assert.equal(decodeJwtClaims("opaque-jwe-token.with.five.parts.here.and-more"), null); +}); + +test("#10718: tokenNeedsRefresh — unreadable/expired/inside-lead needs refresh, fresh does not", () => { + const now = Math.floor(Date.now() / 1000); + assert.equal(tokenNeedsRefresh("opaque"), true); + assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now - 60 })), true); + // Inside the 5-minute lead window. + assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now + M365_REFRESH_LEAD_MS / 1000 - 30 })), true); + assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now + 3600 })), false); +}); + +test("#10718: refreshM365AccessToken redeems the public client grant and returns rotated tokens", async () => { + const originalFetch = globalThis.fetch; + let capturedUrl = ""; + let capturedBody = ""; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + capturedUrl = String(url); + capturedBody = String(init?.body); + return new Response( + JSON.stringify({ + access_token: "NEW-ACCESS", + refresh_token: "ROTATED-REFRESH", + expires_in: 4777, + }), + { status: 200 } + ); + }) as typeof fetch; + try { + const result = await refreshM365AccessToken("OLD-REFRESH", "tenant-id"); + assert.ok("accessToken" in result); + assert.equal(result.accessToken, "NEW-ACCESS"); + assert.equal(result.refreshToken, "ROTATED-REFRESH"); + assert.equal(result.expiresIn, 4777); + assert.match(capturedUrl, /login\.microsoftonline\.com\/tenant-id\/oauth2\/v2\.0\/token/); + assert.match(capturedBody, /grant_type=refresh_token/); + assert.match(capturedBody, new RegExp(`client_id=${M365_OAUTH_CLIENT_ID}`)); + assert.match(capturedBody, /refresh_token=OLD-REFRESH/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("#10718: refreshM365AccessToken surfaces AAD errors and network failures as {error}", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "invalid_grant", error_description: "AADSTS700082" }), { + status: 400, + })) as typeof fetch; + const aadError = await refreshM365AccessToken("STALE"); + assert.deepEqual(aadError, { error: "invalid_grant" }); + + globalThis.fetch = (async () => { + throw new Error("ENOTFOUND"); + }) as typeof fetch; + const netError = await refreshM365AccessToken("ANY"); + assert.deepEqual(netError, { error: "ENOTFOUND" }); + globalThis.fetch = originalFetch; +}); diff --git a/tests/unit/copilot-m365-web-executor.test.ts b/tests/unit/copilot-m365-web-executor.test.ts index 3150082cb1..f6e48e488e 100644 --- a/tests/unit/copilot-m365-web-executor.test.ts +++ b/tests/unit/copilot-m365-web-executor.test.ts @@ -40,12 +40,24 @@ class MockM365WebSocket { send(data: string): void { this.sent.push(String(data)); - const parsed = JSON.parse(String(data).replace(/\x1e$/, "")); - if (parsed.protocol === "json") { + // #10718 — a single socket write may carry multiple \x1e-terminated frames + // (the chat invocation and its Metrics follow-up ride together). + const parsedFrames = String(data) + .split("\x1e") + .filter((f) => f.length > 0) + .map((f) => { + try { + return JSON.parse(f); + } catch { + return null; + } + }); + const parsed = parsedFrames.find((f) => f && f.protocol === "json") ?? parsedFrames[0]; + if (parsed?.protocol === "json") { queueMicrotask(() => this.emit("message", Buffer.from(encodeFrame({})))); return; } - if (parsed.type === 4 && parsed.target === "chat") { + if (parsedFrames.some((f) => f?.type === 4 && f?.target === "chat")) { queueMicrotask(() => { this.emit( "message", @@ -116,10 +128,18 @@ test("CopilotM365WebExecutor streams OpenAI SSE chunks from accumulated M365 upd assert.doesNotMatch(result.url, /redacted-token/); assert.equal(MockM365WebSocket.instances.length, 1); - const sent = MockM365WebSocket.instances[0].sent.join("\n"); - assert.match(sent, /"protocol":"json"/); - assert.match(sent, /"type":6/); - assert.match(sent, /"target":"chat"/); + const sent = MockM365WebSocket.instances[0].sent; + const sentFrames = sent.flatMap((f) => f.split("\x1e").filter((frame) => frame.length > 0)); + assert.ok(sentFrames.some((f) => f.includes('"protocol":"json"'))); + // #10718 — the chat invocation and its type:1 Metrics follow-up ride in ONE + // socket write, and no type:6 keepalive is sent before them. + const invocationWrite = sent.find((f) => f.includes('"target":"chat"')); + assert.ok(invocationWrite, "expected a chat invocation write"); + assert.match(invocationWrite, /"target":"Metrics"/); + assert.ok( + !sentFrames.some((f) => f === '{"type":6}'), + "the leading keepalive ping was removed (#10718): it must not precede the invocation" + ); const dataLines = body .split("\n") diff --git a/tests/unit/credential-health-backoff-retry.test.ts b/tests/unit/credential-health-backoff-retry.test.ts index fb461df91c..f05b5774ac 100644 --- a/tests/unit/credential-health-backoff-retry.test.ts +++ b/tests/unit/credential-health-backoff-retry.test.ts @@ -6,10 +6,12 @@ * a time-based per-connection backoff check (`nextAttemptAt`). This test * validates that: * 1. Connections with failures are retried after the backoff period elapses - * 2. Healthy connections (no timing entry) are always due + * 2. Healthy connections (no timing entry) are always due; after a success the + * connection is due again once its per-connection interval has elapsed * 3. OAuth connections respect the same time-based backoff * 4. Multiple failure levels have correct backoff durations - * 5. The `scheduleSweep()` no longer couples to `maxFailuresAcrossConnections` + * 5. The `scheduleSweep()` runs on a stable interval independent of + * per-connection failures */ import test from "node:test"; @@ -108,19 +110,28 @@ test("never-tested connection is always due (no perConnTiming entry)", () => { ); }); -test("connection after success (timing cleared) is due immediately", () => { +test("connection after success (timing set to interval) is due after the interval elapses", () => { const perConnTiming = new Map(); const connId = "conn-bug-9289"; const now = 1_000_000_000_000; - // Simulate failure then success (timing deleted) - perConnTiming.set(connId, { lastAttemptAt: now, nextAttemptAt: now + 600_000 }); - perConnTiming.delete(connId); // On success, timing is cleared + // Simulate failure then success: timing now holds lastAttemptAt + interval + perConnTiming.set(connId, { lastAttemptAt: now, nextAttemptAt: now + DEFAULT_INTERVAL }); assert.equal( - isConnectionDue(perConnTiming, connId, now), + isConnectionDue(perConnTiming, connId, now + DEFAULT_INTERVAL - 1), + false, + "Healthy connection should NOT be due before its interval elapses" + ); + assert.equal( + isConnectionDue(perConnTiming, connId, now + DEFAULT_INTERVAL), true, - "Connection should be due immediately after success (timing cleared)" + "Healthy connection should be due at the interval boundary" + ); + assert.equal( + isConnectionDue(perConnTiming, connId, now + DEFAULT_INTERVAL + 1), + true, + "Healthy connection should be due after its interval elapses" ); }); @@ -152,7 +163,7 @@ test("multiple failure levels have correct backoff durations", () => { }); test("scheduleSweep uses stable interval (decoupled from maxFailures)", () => { - // The fix decouples scheduleSweep from getMaxFailuresAcrossConnections. + // The fix decouples scheduleSweep from per-connection failures. // Previously, one failed connection would delay the global sweep for all // connections. Now the global sweep runs on a stable interval regardless // of individual connection failures. This test validates the new behavior @@ -179,4 +190,4 @@ test("scheduleSweep uses stable interval (decoupled from maxFailures)", () => { true, "Failed connection should be due when its own backoff elapses" ); -}); \ No newline at end of file +}); diff --git a/tests/unit/credential-health-interval.test.ts b/tests/unit/credential-health-interval.test.ts new file mode 100644 index 0000000000..8ef3af138c --- /dev/null +++ b/tests/unit/credential-health-interval.test.ts @@ -0,0 +1,100 @@ +/** + * Unit tests for the per-connection credential health sweep interval (#8443). + * + * Mirrors the house pattern of credential-health-backoff-retry.test.ts: the + * scheduler predicates are replicated here rather than imported, because the + * scheduler module auto-initializes on import. + * + * Validates: + * 1. getConnIntervalMs: null → global env interval; >0 → minutes × 60 000; + * <=0 → null (per-connection opt-out, never tested) + * 2. isConnectionDue: success timing (lastAttemptAt + interval) is respected — + * not due before the interval, due at/after it + * 3. Opt-out connections (intervalMs null) are excluded from the due filter + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +// ── Constants (mirrored from scheduler.ts) ──────────────────────────────── + +const DEFAULT_INTERVAL = 300_000; // 5 min + +// ── Helper: replicated scheduler predicates ─────────────────────────────── + +function getConnIntervalMs(conn: { healthCheckInterval?: number | null }): number | null { + const minutes = conn.healthCheckInterval; + if (minutes === null || minutes === undefined) return DEFAULT_INTERVAL; + if (minutes <= 0) return null; + return minutes * 60_000; +} + +function isConnectionDue( + perConnTiming: Map, + connId: string, + now: number, + intervalMs: number | null +): boolean { + // Per-connection opt-out: never tested. + if (intervalMs === null) return false; + const timing = perConnTiming.get(connId); + // No timing entry = never tested since boot → due now + if (!timing) return true; + // Time-based: due when the current time has passed the next attempt time + return now >= timing.nextAttemptAt; +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +test("getConnIntervalMs: absent healthCheckInterval falls back to the global interval", () => { + assert.equal(getConnIntervalMs({}), DEFAULT_INTERVAL); + assert.equal(getConnIntervalMs({ healthCheckInterval: null }), DEFAULT_INTERVAL); +}); + +test("getConnIntervalMs: positive minutes override becomes milliseconds", () => { + assert.equal(getConnIntervalMs({ healthCheckInterval: 1 }), 60_000); + assert.equal(getConnIntervalMs({ healthCheckInterval: 5 }), 300_000); + assert.equal(getConnIntervalMs({ healthCheckInterval: 60 }), 3_600_000); +}); + +test("getConnIntervalMs: zero or negative means opt-out (null)", () => { + assert.equal(getConnIntervalMs({ healthCheckInterval: 0 }), null); + assert.equal(getConnIntervalMs({ healthCheckInterval: -5 }), null); +}); + +test("opt-out connection (intervalMs null) is never due", () => { + const perConnTiming = new Map(); + const now = 1_000_000_000_000; + assert.equal(isConnectionDue(perConnTiming, "conn-optout", now, null), false); +}); + +test("connection with success timing is due at the interval boundary, not before", () => { + const perConnTiming = new Map(); + const connId = "conn-healthy"; + const now = 1_000_000_000_000; + const intervalMs = DEFAULT_INTERVAL; + + // Simulate a success: timing holds lastAttemptAt + interval + perConnTiming.set(connId, { lastAttemptAt: now, nextAttemptAt: now + intervalMs }); + + assert.equal( + isConnectionDue(perConnTiming, connId, now + intervalMs - 1, intervalMs), + false, + "Not due before the per-connection interval elapses" + ); + assert.equal( + isConnectionDue(perConnTiming, connId, now + intervalMs, intervalMs), + true, + "Due at the interval boundary" + ); + assert.equal( + isConnectionDue(perConnTiming, connId, now + intervalMs + 1, intervalMs), + true, + "Due after the interval elapses" + ); +}); + +test("never-tested connection is always due (no perConnTiming entry)", () => { + const perConnTiming = new Map(); + assert.equal(isConnectionDue(perConnTiming, "conn-fresh", Date.now(), DEFAULT_INTERVAL), true); +}); diff --git a/tests/unit/cursor-agent-image.test.ts b/tests/unit/cursor-agent-image.test.ts new file mode 100644 index 0000000000..e4c2cfea2d --- /dev/null +++ b/tests/unit/cursor-agent-image.test.ts @@ -0,0 +1,297 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { IMAGE_PROVIDERS, parseImageModel, getImageProvider } from "../../open-sse/config/imageRegistry.ts"; +import { + buildCursorAgentAuthEnv, + buildCursorAgentImagePrompt, + CURSOR_AGENT_IMAGE_FORMAT, + handleCursorAgentImageGeneration, + isRasterImageBuffer, + normalizeCursorSeatToken, + resolveCursorImageModel, + resolveCursorImageTimeoutMs, + __resetCursorAgentImageConcurrencyForTests, +} from "../../open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts"; + +test("cursor is registered in IMAGE_PROVIDERS with cursor-agent-image format", () => { + const entry = IMAGE_PROVIDERS.cursor; + assert.ok(entry, "expected IMAGE_PROVIDERS.cursor"); + assert.equal(entry.id, "cursor"); + assert.equal(entry.alias, "cu"); + assert.equal(entry.format, CURSOR_AGENT_IMAGE_FORMAT); + assert.equal(entry.authType, "oauth"); + assert.equal(entry.authHeader, "bearer"); + assert.ok(entry.models.some((m) => m.id === "auto")); + assert.deepEqual(getImageProvider("cursor"), entry); +}); + +test("parseImageModel resolves cursor/auto and cu/auto to the cursor image provider", () => { + assert.deepEqual(parseImageModel("cursor/auto"), { provider: "cursor", model: "auto" }); + assert.deepEqual(parseImageModel("cu/auto"), { provider: "cursor", model: "auto" }); +}); + +test("normalizeCursorSeatToken strips account:: prefix like CursorExecutor", () => { + assert.equal(normalizeCursorSeatToken("acct::tok_abc"), "tok_abc"); + assert.equal(normalizeCursorSeatToken(" crsr_live "), "crsr_live"); + assert.equal(normalizeCursorSeatToken("a::b::c"), "b::c"); +}); + +test("buildCursorAgentAuthEnv maps crsr_ to CURSOR_API_KEY and JWTs to CURSOR_AUTH_TOKEN", () => { + assert.deepEqual(buildCursorAgentAuthEnv("crsr_abc"), { CURSOR_API_KEY: "crsr_abc" }); + assert.deepEqual(buildCursorAgentAuthEnv("user::crsr_abc"), { CURSOR_API_KEY: "crsr_abc" }); + assert.deepEqual(buildCursorAgentAuthEnv("eyJhbGciOi.jwt"), { + CURSOR_AUTH_TOKEN: "eyJhbGciOi.jwt", + }); +}); + +test("buildCursorAgentImagePrompt locks the agent to native generateImage + exact out path", () => { + const prompt = buildCursorAgentImagePrompt("a red cube", "/tmp/out.png", "1024x1024"); + assert.match(prompt, /native image-generation tool/i); + assert.match(prompt, /Do NOT write code/); + assert.match(prompt, /a red cube/); + assert.match(prompt, /1024x1024/); + assert.match(prompt, /\/tmp\/out\.png/); + assert.match(prompt, /\bDONE\b/); +}); + +test("isRasterImageBuffer accepts PNG and JPEG magics", () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]); + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + assert.equal(isRasterImageBuffer(png), true); + assert.equal(isRasterImageBuffer(jpeg), true); + assert.equal(isRasterImageBuffer(Buffer.from("not-an-image")), false); +}); + +test("handleCursorAgentImageGeneration rejects empty prompt and missing credentials", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const noPrompt = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: " " }, + credentials: { accessToken: "crsr_x" }, + peerLocality: "loopback", + }); + assert.equal(noPrompt.success, false); + assert.equal(noPrompt.status, 400); + + const noCreds = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "hi" }, + credentials: {}, + peerLocality: "loopback", + }); + assert.equal(noCreds.success, false); + assert.equal(noCreds.status, 401); +}); + +test("handleCursorAgentImageGeneration returns 501 when agentBin path is missing", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const result = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern" }, + credentials: { + accessToken: "crsr_test", + providerSpecificData: { agentBin: "/nonexistent/cursor-agent-bin" }, + }, + peerLocality: "loopback", + }); + assert.equal(result.success, false); + assert.equal(result.status, 501); + assert.match(String(result.error), /CURSOR_AGENT_BIN|agentBin/i); +}); + +// ─── Hard Rules #15 + #17: spawn-capable providers must loopback/LAN-gate ─── + +test("handleCursorAgentImageGeneration rejects a non-loopback/non-LAN caller BEFORE spawning", async () => { + __resetCursorAgentImageConcurrencyForTests(); + let spawnCalled = false; + const spyingSpawn = (() => { + spawnCalled = true; + throw new Error("spawn must never be invoked for a remote caller"); + }) as unknown as typeof import("node:child_process").spawn; + + const result = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern in fog" }, + credentials: { + accessToken: "crsr_test", + providerSpecificData: { agentBin: process.execPath }, + }, + spawnImpl: spyingSpawn, + peerLocality: "remote", + }); + + assert.equal(spawnCalled, false, "spawn must not run for a rejected non-local caller"); + assert.equal(result.success, false); + assert.equal(result.status, 403); + assert.match(String(result.error), /localhost|LAN/i); +}); + +test("handleCursorAgentImageGeneration rejects when peerLocality is missing (fail closed)", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const result = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern in fog" }, + credentials: { accessToken: "crsr_test" }, + }); + assert.equal(result.success, false); + assert.equal(result.status, 403); +}); + +/** + * Minimal fake `spawn` that writes a tiny PNG to the out path embedded in the + * prompt and exits 0 — exercises the success path without a real Cursor Agent. + */ +test("handleCursorAgentImageGeneration returns b64_json via injectable spawn", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const { writeFile, mkdir } = await import("node:fs/promises"); + const path = await import("node:path"); + + const tinyPng = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + ]); + + const fakeSpawn = ((bin: string, args: string[]) => { + assert.ok(bin, "agent bin required"); + const prompt = args[args.length - 1] || ""; + const marker = "Save the resulting image to exactly this path: "; + const idx = prompt.indexOf(marker); + assert.ok(idx >= 0, "prompt must contain out path"); + const after = prompt.slice(idx + marker.length); + const end = after.indexOf(". When the file exists"); + assert.ok(end > 0, "prompt must end out path before DONE clause"); + const outPath = after.slice(0, end); + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: () => void; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => undefined; + queueMicrotask(async () => { + await mkdir(path.dirname(outPath), { recursive: true }); + await writeFile(outPath, tinyPng); + child.emit("close", 0); + }); + return child; + }) as unknown as typeof import("node:child_process").spawn; + + // Use an existing path so the preflight existsSync check passes; spawn is faked. + const result = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern in fog", size: "1024x1024", n: 1 }, + credentials: { + accessToken: "crsr_test", + providerSpecificData: { agentBin: process.execPath }, + }, + spawnImpl: fakeSpawn, + peerLocality: "loopback", + }); + + assert.equal(result.success, true); + assert.ok(result.data?.data?.[0]?.b64_json); + assert.equal(result.data.data[0].b64_json, tinyPng.toString("base64")); +}); + +test("resolveCursorImageModel allows only registry models, clamping everything else to auto", () => { + // The three ids declared in IMAGE_PROVIDERS.cursor.models pass through verbatim. + for (const m of IMAGE_PROVIDERS.cursor.models) { + assert.equal(resolveCursorImageModel(m.id), m.id); + } + // Unknown models and (crucially) flag-shaped / injection-y strings fall back to auto. + assert.equal(resolveCursorImageModel("--dangerously-allow-shell"), "auto"); + assert.equal(resolveCursorImageModel("-p"), "auto"); + assert.equal(resolveCursorImageModel("composer-9"), "auto"); + assert.equal(resolveCursorImageModel(" composer-2 "), "composer-2"); // trimmed, still valid + assert.equal(resolveCursorImageModel(""), "auto"); + assert.equal(resolveCursorImageModel(undefined), "auto"); + assert.equal(resolveCursorImageModel(42), "auto"); +}); + +/** + * End-to-end guard: an odd/flag-shaped `model` from the request must never reach + * the spawned Agent CLI argv — the handler resolves it to "auto" first. + */ +test("handleCursorAgentImageGeneration never forwards a flag-shaped model into CLI argv", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const { writeFile, mkdir } = await import("node:fs/promises"); + const path = await import("node:path"); + + const tinyPng = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + ]); + + let capturedArgs: string[] = []; + const fakeSpawn = ((_bin: string, args: string[]) => { + capturedArgs = args; + const prompt = args[args.length - 1] || ""; + const marker = "Save the resulting image to exactly this path: "; + const idx = prompt.indexOf(marker); + const after = prompt.slice(idx + marker.length); + const outPath = after.slice(0, after.indexOf(". When the file exists")); + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: () => void; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => undefined; + queueMicrotask(async () => { + await mkdir(path.dirname(outPath), { recursive: true }); + await writeFile(outPath, tinyPng); + child.emit("close", 0); + }); + return child; + }) as unknown as typeof import("node:child_process").spawn; + + const result = await handleCursorAgentImageGeneration({ + model: "--dangerously-allow-shell", // untrusted, flag-shaped + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern in fog", n: 1 }, + credentials: { + accessToken: "crsr_test", + providerSpecificData: { agentBin: process.execPath }, + }, + spawnImpl: fakeSpawn, + peerLocality: "loopback", + }); + + assert.equal(result.success, true); + const modelIdx = capturedArgs.indexOf("--model"); + assert.ok(modelIdx >= 0, "expected --model in the CLI argv"); + assert.equal(capturedArgs[modelIdx + 1], "auto", "flag-shaped model must resolve to auto"); + assert.ok( + !capturedArgs.includes("--dangerously-allow-shell"), + "the raw flag-shaped model must not appear anywhere in argv" + ); +}); + +test("resolveCursorImageTimeoutMs clamps caller timeout_ms to the 300s ceiling", () => { + const prev = process.env.CURSOR_IMG_TIMEOUT_MS; + delete process.env.CURSOR_IMG_TIMEOUT_MS; // isolate from any operator default + try { + assert.equal(resolveCursorImageTimeoutMs(5_000), 5_000); // under the cap: unchanged + assert.equal(resolveCursorImageTimeoutMs(300_000), 300_000); // exactly at the cap + assert.equal(resolveCursorImageTimeoutMs(999_999_999), 300_000); // over the cap: clamped + assert.equal(resolveCursorImageTimeoutMs(-1), 210_000); // invalid → default fallback + assert.equal(resolveCursorImageTimeoutMs(undefined), 210_000); // absent → default fallback + } finally { + if (prev === undefined) delete process.env.CURSOR_IMG_TIMEOUT_MS; + else process.env.CURSOR_IMG_TIMEOUT_MS = prev; + } +}); diff --git a/tests/unit/cursor-api-key-auth.test.ts b/tests/unit/cursor-api-key-auth.test.ts new file mode 100644 index 0000000000..d5517c1e5b --- /dev/null +++ b/tests/unit/cursor-api-key-auth.test.ts @@ -0,0 +1,227 @@ +/** + * Cursor API-key exchange (open-sse/services/cursorApiKeyAuth.ts). + * + * api2.cursor.sh rejects a raw `crsr_…` key as Bearer; cursor-agent exchanges + * it at /auth/exchange_user_api_key for a 1h session JWT. These tests pin the + * exchange request shape, the 401/5xx/malformed-body mapping, the per-key + * cache (refresh 5 min before exp, in-flight dedupe, invalidation) and the + * shared bearer resolver used by the executor and the CLI passthrough. + */ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + CURSOR_API_KEY_EXCHANGE_URL, + CursorApiKeyExchangeError, + __resetCursorApiKeyAuthForTest, + exchangeCursorApiKey, + invalidateCursorSessionToken, + isCursorApiKey, + readJwtExpiryMs, + resolveCursorBearerToken, + resolveCursorSessionToken, + stripCursorOAuthTokenPrefix, +} from "@omniroute/open-sse/services/cursorApiKeyAuth.ts"; + +const API_KEY = "crsr_test_key_0123456789"; + +function jwtWithExp(expSeconds: number): string { + const b64 = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${b64({ alg: "HS256", typ: "JWT" })}.${b64({ exp: expSeconds, type: "api_key_token" })}.sig`; +} + +type RecordedCall = { url: string; init: RequestInit | undefined }; + +function fakeFetch( + responder: (call: RecordedCall) => Response, + calls: RecordedCall[] = [] +): { fetchImpl: (url: string, init?: RequestInit) => Promise; calls: RecordedCall[] } { + return { + calls, + fetchImpl: async (url, init) => { + const call = { url, init }; + calls.push(call); + return responder(call); + }, + }; +} + +function okExchange(expSeconds: number): Response { + return new Response( + JSON.stringify({ accessToken: jwtWithExp(expSeconds), refreshToken: jwtWithExp(expSeconds) }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +describe("cursorApiKeyAuth", () => { + beforeEach(() => { + __resetCursorApiKeyAuthForTest(); + }); + + it("recognises crsr_ keys only", () => { + assert.equal(isCursorApiKey(API_KEY), true); + assert.equal(isCursorApiKey("sk-other"), false); + assert.equal(isCursorApiKey(undefined), false); + }); + + it("reads exp from a JWT and returns null for opaque tokens", () => { + assert.equal(readJwtExpiryMs(jwtWithExp(1_800_000_000)), 1_800_000_000_000); + assert.equal(readJwtExpiryMs("opaque"), null); + }); + + it("strips the WorkOS composite prefix from OAuth session tokens only", () => { + assert.equal(stripCursorOAuthTokenPrefix("user_123::jwt.part.sig"), "jwt.part.sig"); + assert.equal(stripCursorOAuthTokenPrefix("jwt.part.sig"), "jwt.part.sig"); + }); + + it("POSTs the key as Bearer to the exchange endpoint and parses the session", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const { fetchImpl, calls } = fakeFetch(() => okExchange(exp)); + + const session = await exchangeCursorApiKey(API_KEY, { fetchImpl }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, CURSOR_API_KEY_EXCHANGE_URL); + assert.equal(calls[0].init?.method, "POST"); + const headers = calls[0].init?.headers as Record; + assert.equal(headers.authorization, `Bearer ${API_KEY}`); + assert.equal(headers["content-type"], "application/json"); + assert.equal(calls[0].init?.body, "{}"); + assert.equal(session.accessToken, jwtWithExp(exp)); + assert.equal(session.expiresAt, exp * 1000); + }); + + it("rejects non-crsr keys without calling upstream", async () => { + const { fetchImpl, calls } = fakeFetch(() => okExchange(1)); + await assert.rejects( + exchangeCursorApiKey("sk-not-cursor", { fetchImpl }), + (err: unknown) => err instanceof CursorApiKeyExchangeError && err.status === 400 + ); + assert.equal(calls.length, 0); + }); + + it("maps upstream 401/403 to a 401 exchange error", async () => { + for (const status of [401, 403]) { + const { fetchImpl } = fakeFetch(() => new Response("nope", { status })); + await assert.rejects( + exchangeCursorApiKey(API_KEY, { fetchImpl }), + (err: unknown) => + err instanceof CursorApiKeyExchangeError && + err.status === 401 && + !err.message.includes(API_KEY) + ); + } + }); + + it("maps upstream 5xx and malformed bodies to 502", async () => { + const cases: Array<() => Response> = [ + () => new Response("boom", { status: 503 }), + () => new Response("not json", { status: 200 }), + () => new Response(JSON.stringify({ refreshToken: "x" }), { status: 200 }), + ]; + for (const responder of cases) { + const { fetchImpl } = fakeFetch(responder); + await assert.rejects( + exchangeCursorApiKey(API_KEY, { fetchImpl }), + (err: unknown) => err instanceof CursorApiKeyExchangeError && err.status === 502 + ); + } + }); + + it("maps network failures to 502", async () => { + const fetchImpl = async () => { + throw new Error("ECONNRESET"); + }; + await assert.rejects( + exchangeCursorApiKey(API_KEY, { fetchImpl }), + (err: unknown) => err instanceof CursorApiKeyExchangeError && err.status === 502 + ); + }); + + it("caches the session per key and re-exchanges 5 minutes before expiry", async () => { + let nowMs = 1_000_000_000_000; + const now = () => nowMs; + const { fetchImpl, calls } = fakeFetch(() => okExchange(Math.floor(now() / 1000) + 3600)); + + const first = await resolveCursorSessionToken(API_KEY, { fetchImpl, now }); + const second = await resolveCursorSessionToken(API_KEY, { fetchImpl, now }); + assert.equal(calls.length, 1); + assert.equal(second.accessToken, first.accessToken); + + nowMs += 54 * 60 * 1000; + await resolveCursorSessionToken(API_KEY, { fetchImpl, now }); + assert.equal(calls.length, 1, "still fresh at 54 min"); + + nowMs += 2 * 60 * 1000; + const refreshed = await resolveCursorSessionToken(API_KEY, { fetchImpl, now }); + assert.equal(calls.length, 2, "re-exchanged inside the 5 min skew window"); + assert.notEqual(refreshed.accessToken, first.accessToken); + }); + + it("dedupes concurrent exchanges for the same key", async () => { + let release: (() => void) | null = null; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { fetchImpl, calls } = fakeFetch(() => okExchange(Math.floor(Date.now() / 1000) + 3600)); + const gatedFetch = async (url: string, init?: RequestInit) => { + await gate; + return fetchImpl(url, init); + }; + + const pending = Promise.all([ + resolveCursorSessionToken(API_KEY, { fetchImpl: gatedFetch }), + resolveCursorSessionToken(API_KEY, { fetchImpl: gatedFetch }), + resolveCursorSessionToken(API_KEY, { fetchImpl: gatedFetch }), + ]); + release?.(); + const tokens = await pending; + assert.equal(calls.length, 1); + assert.equal(new Set(tokens.map((t) => t.accessToken)).size, 1); + }); + + it("invalidation forces a fresh exchange on the next call", async () => { + const { fetchImpl, calls } = fakeFetch(() => okExchange(Math.floor(Date.now() / 1000) + 3600)); + await resolveCursorSessionToken(API_KEY, { fetchImpl }); + invalidateCursorSessionToken(API_KEY); + await resolveCursorSessionToken(API_KEY, { fetchImpl }); + assert.equal(calls.length, 2); + }); + + it("does not cache a failed exchange", async () => { + let attempt = 0; + const { fetchImpl } = fakeFetch(() => { + attempt += 1; + return attempt === 1 + ? new Response("down", { status: 503 }) + : okExchange(Math.floor(Date.now() / 1000) + 3600); + }); + await assert.rejects(resolveCursorSessionToken(API_KEY, { fetchImpl })); + const session = await resolveCursorSessionToken(API_KEY, { fetchImpl }); + assert.ok(session.accessToken.length > 0); + assert.equal(attempt, 2); + }); + + it("resolveCursorBearerToken prefers the exchanged token for API-key connections", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const { fetchImpl } = fakeFetch(() => okExchange(exp)); + const bearer = await resolveCursorBearerToken( + { apiKey: API_KEY, accessToken: "user_1::stale" }, + { fetchImpl } + ); + assert.equal(bearer, jwtWithExp(exp)); + }); + + it("resolveCursorBearerToken keeps the OAuth path untouched and rejects empty creds", async () => { + const { fetchImpl, calls } = fakeFetch(() => okExchange(1)); + assert.equal( + await resolveCursorBearerToken({ accessToken: "user_1::session.jwt.sig" }, { fetchImpl }), + "session.jwt.sig" + ); + assert.equal(calls.length, 0); + await assert.rejects( + resolveCursorBearerToken({}, { fetchImpl }), + (err: unknown) => err instanceof CursorApiKeyExchangeError && err.status === 401 + ); + }); +}); diff --git a/tests/unit/cursor-apikey-provider.test.ts b/tests/unit/cursor-apikey-provider.test.ts new file mode 100644 index 0000000000..b705d2fc93 --- /dev/null +++ b/tests/unit/cursor-apikey-provider.test.ts @@ -0,0 +1,176 @@ +/** + * `cursor-api` is the API-key sibling of the `cursor` (IDE session) provider: + * same executor, format and model catalog, its own registry/catalog entry so + * API-key and IDE-session connections never share renewal or dashboard + * semantics. The executor swaps the crsr_ key for the exchanged session token + * before dialing. + */ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +const { APIKEY_PROVIDERS, OAUTH_PROVIDERS } = + await import("../../src/shared/constants/providers.ts"); +const { isManagedProviderConnectionId } = await import("../../src/lib/providers/catalog.ts"); +const { cursorProvider, cursor_apiProvider } = + await import("../../open-sse/config/providers/registry/cursor/index.ts"); +const { REGISTRY, generateAliasMap, getProviderCategory } = + await import("../../open-sse/config/providerRegistry.ts"); +const { CursorExecutor, getExecutor, hasSpecializedExecutor } = + await import("../../open-sse/executors/index.ts"); +const { __resetCursorApiKeyAuthForTest } = + await import("../../open-sse/services/cursorApiKeyAuth.ts"); +const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); + +const API_KEY = "crsr_provider_test_key"; + +function jwt(exp: number): string { + const b64 = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${b64({ alg: "HS256" })}.${b64({ exp })}.sig`; +} + +describe("cursor-api provider wiring", () => { + it("is a distinct API-key registry entry sharing the cursor executor, format and models", () => { + assert.equal(cursor_apiProvider.id, "cursor-api"); + assert.equal(cursor_apiProvider.authType, "apikey"); + assert.equal(cursor_apiProvider.format, cursorProvider.format); + assert.equal(cursor_apiProvider.baseUrl, cursorProvider.baseUrl); + assert.equal(cursor_apiProvider.models, cursorProvider.models); + assert.equal(REGISTRY["cursor-api"], cursor_apiProvider); + assert.equal(generateAliasMap()["cursor-api"], "cua"); + assert.equal(getProviderCategory("cursor-api"), "apikey"); + }); + + it("leaves the IDE cursor provider OAuth-only", () => { + assert.equal(cursorProvider.authType, "oauth"); + assert.equal(getProviderCategory("cursor"), "oauth"); + assert.ok(OAUTH_PROVIDERS.cursor); + assert.ok(!APIKEY_PROVIDERS.cursor); + }); + + it("has its own API-key catalog card admitted by the managed-connection gate", () => { + assert.ok(APIKEY_PROVIDERS["cursor-api"]); + assert.equal(APIKEY_PROVIDERS["cursor-api"].alias, "cua"); + assert.ok(!OAUTH_PROVIDERS["cursor-api"]); + assert.equal(isManagedProviderConnectionId("cursor-api"), true); + }); + + it("routes cursor-api and its alias to a CursorExecutor bound to the cursor-api id", () => { + for (const key of ["cursor-api", "cua"]) { + assert.equal(hasSpecializedExecutor(key), true, key); + const executor = getExecutor(key); + assert.ok(executor instanceof CursorExecutor, key); + assert.equal(executor.getProvider(), "cursor-api"); + } + assert.equal(getExecutor("cursor").getProvider(), "cursor"); + }); +}); + +describe("CursorExecutor credential resolution", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + __resetCursorApiKeyAuthForTest(); + }); + + it("sends the stripped IDE session token for OAuth connections", () => { + const executor = new CursorExecutor(); + const headers = executor.buildHeaders({ + accessToken: "user_01::ide.session.jwt", + providerSpecificData: {}, + }); + assert.equal(headers.authorization, "Bearer ide.session.jwt"); + assert.equal(headers["x-cursor-client-type"], "cli"); + }); + + it("exchanges a crsr_ key and sends the session JWT, never the raw key", async () => { + const calls: string[] = []; + const exp = Math.floor(Date.now() / 1000) + 3600; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return new Response(JSON.stringify({ accessToken: jwt(exp), refreshToken: jwt(exp) }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const executor = new CursorExecutor("cursor-api"); + const resolved = await executor.resolveExecutionCredentials({ + apiKey: API_KEY, + providerSpecificData: {}, + }); + assert.ok(!(resolved instanceof Response)); + const headers = executor.buildHeaders(resolved); + assert.equal(headers.authorization, `Bearer ${jwt(exp)}`); + assert.ok(!headers.authorization.includes(API_KEY)); + assert.equal(calls.length, 1); + assert.match(calls[0], /\/auth\/exchange_user_api_key$/); + }); + + it("returns a sanitized 401 response when Cursor rejects the key", async () => { + globalThis.fetch = (async () => new Response("bad key", { status: 401 })) as typeof fetch; + const executor = new CursorExecutor("cursor-api"); + const resolved = await executor.resolveExecutionCredentials({ apiKey: API_KEY }); + assert.ok(resolved instanceof Response); + assert.equal(resolved.status, 401); + const body = (await resolved.json()) as { error: { message: string; type: string } }; + assert.equal(body.error.type, "authentication_error"); + assert.ok(!body.error.message.includes(API_KEY)); + assert.ok(!body.error.message.includes("at /")); + }); + + it("leaves OAuth credentials untouched without calling the exchange endpoint", async () => { + globalThis.fetch = (async () => { + throw new Error("exchange must not be called"); + }) as typeof fetch; + const executor = new CursorExecutor(); + const credentials = { accessToken: "user_01::ide.session.jwt" }; + const resolved = await executor.resolveExecutionCredentials(credentials); + assert.equal(resolved, credentials); + }); +}); + +describe("cursor-api connection test", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + __resetCursorApiKeyAuthForTest(); + }); + + it("validates by exchanging the key and reports the exchange as the method", async () => { + let exchangeCalls = 0; + globalThis.fetch = (async () => { + exchangeCalls += 1; + return new Response(JSON.stringify({ accessToken: jwt(1_900_000_000), refreshToken: "r" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + const result = await validateProviderApiKey({ provider: "cursor-api", apiKey: API_KEY }); + assert.equal(result.valid, true); + assert.equal(result.unsupported, false); + assert.equal(exchangeCalls, 1); + }); + + it("reports a rejected key as invalid (401), not as unsupported", async () => { + globalThis.fetch = (async () => new Response("nope", { status: 401 })) as typeof fetch; + const result = await validateProviderApiKey({ provider: "cursor-api", apiKey: API_KEY }); + assert.equal(result.valid, false); + assert.equal(result.unsupported, false); + assert.equal(result.statusCode, 401); + assert.ok(!String(result.error).includes(API_KEY)); + }); + + it("rejects keys without the crsr_ prefix locally", async () => { + globalThis.fetch = (async () => { + throw new Error("must not be called"); + }) as typeof fetch; + const result = await validateProviderApiKey({ + provider: "cursor-api", + apiKey: "sk-not-cursor", + }); + assert.equal(result.valid, false); + assert.equal(result.statusCode, 400); + }); +}); diff --git a/tests/unit/cursor-cli-proxy.test.ts b/tests/unit/cursor-cli-proxy.test.ts new file mode 100644 index 0000000000..88ea70457e --- /dev/null +++ b/tests/unit/cursor-cli-proxy.test.ts @@ -0,0 +1,417 @@ +/** + * Cursor CLI passthrough handler (open-sse/handlers/cursorCliProxy.ts). + * + * The handler is exercised through its dependency seam so no SQLite, no + * network and no JWT_SECRET env are needed: every collaborator (API-key + * validation, connection listing, bearer resolution, upstream fetch, call + * logging) is injected per test. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { SignJWT, decodeJwt } from "jose"; + +import { + CURSOR_CLI_PROXY_PREFIX, + CURSOR_CLI_REQUEST_TYPE, + CURSOR_CLI_SESSION_AUDIENCE, + CURSOR_CLI_SESSION_ISSUER, + CURSOR_CLI_SESSION_TTL_SECONDS, + handleCursorCliProxy, + mintCursorCliSessionToken, + normalizeCursorCliPath, + type CursorCliProxyDeps, +} from "@omniroute/open-sse/handlers/cursorCliProxy.ts"; +import { CursorApiKeyExchangeError } from "@omniroute/open-sse/services/cursorApiKeyAuth.ts"; + +const SECRET = "unit-test-jwt-secret-with-enough-entropy-0123456789"; +const OMNI_KEY = "sk-omniroute-unit-key"; +const CURSOR_KEY = "crsr_unit_cursor_key"; +const UPSTREAM = "https://upstream.example"; +const NOW = 1_800_000_000_000; + +type UpstreamCall = { url: string; init: RequestInit }; + +function makeDeps(overrides: Partial = {}): { + deps: Partial; + upstreamCalls: UpstreamCall[]; + logs: Record[]; +} { + const upstreamCalls: UpstreamCall[] = []; + const logs: Record[] = []; + const deps: Partial = { + fetchImpl: (async (url: string, init: RequestInit) => { + upstreamCalls.push({ url, init }); + return new Response("upstream-ok", { + status: 200, + headers: { "content-type": "application/proto", "content-encoding": "identity" }, + }); + }) as unknown as typeof fetch, + now: () => NOW, + getSecret: () => SECRET, + validateApiKey: async (key) => key === OMNI_KEY, + getApiKeyMetadata: async (key) => (key === OMNI_KEY ? { id: "key-1", name: "unit key" } : null), + getApiKeyById: async (id) => (id === "key-1" ? { isActive: true, revokedAt: null } : null), + requireApiKey: () => true, + listCursorConnections: async () => [{ id: "conn-1", apiKey: CURSOR_KEY, priority: 1 }], + resolveBearer: async ({ apiKey }) => + apiKey === CURSOR_KEY ? "cursor-session-jwt" : "oauth-jwt", + invalidateBearer: () => undefined, + saveCallLog: async (entry) => { + logs.push(entry); + }, + upstreamBaseUrl: UPSTREAM, + ...overrides, + }; + return { deps, upstreamCalls, logs }; +} + +function exchangeRequest(bearer: string | null, body = "{}"): Request { + return new Request("http://omniroute.local/api/cursor-cli/auth/exchange_user_api_key", { + method: "POST", + headers: { + ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), + "content-type": "application/json", + }, + body, + }); +} + +function rpcRequest( + bearer: string | null, + path = "/aiserver.v1.DashboardService/GetMe", + body: BodyInit | null = new Uint8Array([0, 0, 0, 0, 0]) +): Request { + return new Request(`http://omniroute.local/api/cursor-cli${path}?x=1`, { + method: "POST", + headers: { + ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), + "content-type": "application/proto", + "connect-protocol-version": "1", + host: "omniroute.local", + "accept-encoding": "gzip,br", + cookie: "auth_token=dashboard", + }, + body, + }); +} + +async function mintedToken(): Promise { + return mintCursorCliSessionToken({ apiKeyId: "key-1", apiKeyName: "unit key" }, SECRET, NOW); +} + +async function flushLogs(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("cursorCliProxy: path normalisation", () => { + it("joins catch-all segments into the upstream RPC path", () => { + assert.equal( + normalizeCursorCliPath(["aiserver.v1.DashboardService", "GetMe"]), + "/aiserver.v1.DashboardService/GetMe" + ); + assert.equal( + normalizeCursorCliPath(["auth", "exchange_user_api_key"]), + "/auth/exchange_user_api_key" + ); + assert.equal(normalizeCursorCliPath(["v1", "traces"]), "/v1/traces"); + }); + + it("re-encodes segments so encoded slashes and spaces cannot smuggle extra path", () => { + assert.equal(normalizeCursorCliPath(["%2Fetc", "x y"]), "/%2Fetc/x%20y"); + assert.equal(normalizeCursorCliPath(["a%2Fb"]), "/a%2Fb"); + }); +}); + +describe("cursorCliProxy: /auth/exchange_user_api_key", () => { + it("mints a 1h OmniRoute session JWT for a valid OmniRoute API key", async () => { + const { deps, logs } = makeDeps(); + const res = await handleCursorCliProxy( + exchangeRequest(OMNI_KEY), + ["auth", "exchange_user_api_key"], + deps + ); + assert.equal(res.status, 200); + const body = (await res.json()) as { accessToken: string; refreshToken: string }; + assert.equal(body.refreshToken, body.accessToken); + const claims = decodeJwt(body.accessToken); + assert.equal(claims.iss, CURSOR_CLI_SESSION_ISSUER); + assert.equal(claims.aud, CURSOR_CLI_SESSION_AUDIENCE); + assert.equal(claims.sub, "key-1"); + assert.equal(claims.exp, Math.floor(NOW / 1000) + CURSOR_CLI_SESSION_TTL_SECONDS); + assert.ok(!body.accessToken.includes(OMNI_KEY)); + await flushLogs(); + assert.equal(logs.length, 1); + assert.equal(logs[0].path, `${CURSOR_CLI_PROXY_PREFIX}/auth/exchange_user_api_key`); + assert.equal(logs[0].requestType, CURSOR_CLI_REQUEST_TYPE); + assert.equal(logs[0].apiKeyId, "key-1"); + }); + + it("rejects an unknown key with 401 when OmniRoute requires API keys", async () => { + const { deps } = makeDeps(); + const res = await handleCursorCliProxy( + exchangeRequest("sk-wrong"), + ["auth", "exchange_user_api_key"], + deps + ); + assert.equal(res.status, 401); + const body = (await res.json()) as { code: string; message: string }; + assert.equal(body.code, "unauthenticated"); + assert.ok(!body.message.includes("at /")); + }); + + it("falls back to an anonymous session when REQUIRE_API_KEY is off", async () => { + const { deps } = makeDeps({ requireApiKey: () => false }); + const res = await handleCursorCliProxy( + exchangeRequest("anything"), + ["auth", "exchange_user_api_key"], + deps + ); + assert.equal(res.status, 200); + const body = (await res.json()) as { accessToken: string }; + assert.equal(decodeJwt(body.accessToken).sub, "anonymous"); + }); + + it("returns 503 without minting when JWT_SECRET is missing", async () => { + const { deps } = makeDeps({ getSecret: () => undefined }); + const res = await handleCursorCliProxy( + exchangeRequest(OMNI_KEY), + ["auth", "exchange_user_api_key"], + deps + ); + assert.equal(res.status, 503); + }); + + it("rejects non-JSON and non-POST exchange calls", async () => { + const { deps } = makeDeps(); + const bad = await handleCursorCliProxy( + exchangeRequest(OMNI_KEY, "not-json"), + ["auth", "exchange_user_api_key"], + deps + ); + assert.equal(bad.status, 400); + const get = await handleCursorCliProxy( + new Request("http://omniroute.local/api/cursor-cli/auth/exchange_user_api_key", { + method: "GET", + }), + ["auth", "exchange_user_api_key"], + deps + ); + assert.equal(get.status, 405); + }); +}); + +describe("cursorCliProxy: forwarded RPCs", () => { + it("swaps the OmniRoute session token for the Cursor bearer and strips hop headers", async () => { + const { deps, upstreamCalls, logs } = makeDeps(); + const res = await handleCursorCliProxy( + rpcRequest(await mintedToken()), + ["aiserver.v1.DashboardService", "GetMe"], + deps + ); + assert.equal(res.status, 200); + assert.equal(await res.text(), "upstream-ok"); + assert.equal(res.headers.get("content-type"), "application/proto"); + assert.equal(res.headers.get("content-encoding"), null); + + assert.equal(upstreamCalls.length, 1); + assert.equal(upstreamCalls[0].url, `${UPSTREAM}/aiserver.v1.DashboardService/GetMe?x=1`); + const headers = upstreamCalls[0].init.headers as Headers; + assert.equal(headers.get("authorization"), "Bearer cursor-session-jwt"); + assert.equal(headers.get("connect-protocol-version"), "1"); + assert.equal(headers.get("host"), null); + assert.equal(headers.get("cookie"), null); + assert.equal(headers.get("accept-encoding"), null); + assert.equal(upstreamCalls[0].init.method, "POST"); + + await flushLogs(); + assert.equal(logs.length, 1); + assert.equal(logs[0].path, `${CURSOR_CLI_PROXY_PREFIX}/aiserver.v1.DashboardService/GetMe`); + assert.equal(logs[0].provider, "cursor-api"); + assert.equal(logs[0].connectionId, "conn-1"); + assert.equal(logs[0].status, 200); + assert.equal(logs[0].apiKeyId, "key-1"); + }); + + it("rejects a raw OmniRoute API key on RPC paths so the CLI exchanges first", async () => { + const { deps, upstreamCalls } = makeDeps(); + const res = await handleCursorCliProxy( + rpcRequest(OMNI_KEY), + ["aiserver.v1.DashboardService", "GetMe"], + deps + ); + assert.equal(res.status, 401); + assert.equal(upstreamCalls.length, 0); + }); + + it("rejects missing, expired, foreign-audience and tampered tokens with 401", async () => { + const { deps, upstreamCalls } = makeDeps(); + const expired = await mintCursorCliSessionToken( + { apiKeyId: "key-1", apiKeyName: null }, + SECRET, + NOW - (CURSOR_CLI_SESSION_TTL_SECONDS + 60) * 1000 + ); + const foreign = await new SignJWT({}) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer(CURSOR_CLI_SESSION_ISSUER) + .setAudience("dashboard") + .setSubject("key-1") + .setExpirationTime(Math.floor(NOW / 1000) + 600) + .sign(new TextEncoder().encode(SECRET)); + const tampered = (await mintedToken()).slice(0, -4) + "AAAA"; + for (const token of [null, expired, foreign, tampered]) { + const res = await handleCursorCliProxy( + rpcRequest(token), + ["aiserver.v1.DashboardService", "GetMe"], + deps + ); + assert.equal(res.status, 401, `token=${token === null ? "none" : token.slice(0, 12)}`); + } + assert.equal(upstreamCalls.length, 0); + }); + + it("rejects a session whose OmniRoute API key was revoked or deactivated", async () => { + const revoked = makeDeps({ + getApiKeyById: async () => ({ isActive: true, revokedAt: "2026-01-01T00:00:00Z" }), + }); + const deactivated = makeDeps({ + getApiKeyById: async () => ({ isActive: false, revokedAt: null }), + }); + const gone = makeDeps({ getApiKeyById: async () => null }); + for (const { deps } of [revoked, deactivated, gone]) { + const res = await handleCursorCliProxy( + rpcRequest(await mintedToken()), + ["aiserver.v1.DashboardService", "GetMe"], + deps + ); + assert.equal(res.status, 401); + } + }); + + it("returns 503 when no active Cursor connection exists", async () => { + const { deps, logs } = makeDeps({ listCursorConnections: async () => [] }); + const res = await handleCursorCliProxy( + rpcRequest(await mintedToken()), + ["aiserver.v1.DashboardService", "GetMe"], + deps + ); + assert.equal(res.status, 503); + const body = (await res.json()) as { code: string; message: string }; + assert.equal(body.code, "unavailable"); + assert.ok(!body.message.includes("at /")); + await flushLogs(); + assert.equal(logs[0].status, 503); + }); + + it("skips cooling-down connections and falls through when a key cannot be exchanged", async () => { + const { deps, upstreamCalls } = makeDeps({ + listCursorConnections: async () => [ + { + id: "cooling", + apiKey: "crsr_cooling", + priority: 0, + rateLimitedUntil: new Date(NOW + 60_000).toISOString(), + }, + { id: "broken", apiKey: "crsr_broken", priority: 1 }, + { id: "oauth", accessToken: "user::session", priority: 2 }, + ], + resolveBearer: async ({ apiKey, accessToken }) => { + if (apiKey === "crsr_broken") + throw new CursorApiKeyExchangeError("Cursor rejected the API key", 401); + if (apiKey === "crsr_cooling") throw new Error("must not be used"); + return accessToken === "user::session" ? "oauth-jwt" : "unexpected"; + }, + }); + const res = await handleCursorCliProxy( + rpcRequest(await mintedToken()), + ["aiserver.v1.DashboardService", "GetMe"], + deps + ); + assert.equal(res.status, 200); + assert.equal( + (upstreamCalls[0].init.headers as Headers).get("authorization"), + "Bearer oauth-jwt" + ); + }); + + it("surfaces an exchange failure as 401 when no connection resolves", async () => { + const { deps } = makeDeps({ + resolveBearer: async () => { + throw new CursorApiKeyExchangeError("Cursor rejected the API key", 401); + }, + }); + const res = await handleCursorCliProxy( + rpcRequest(await mintedToken()), + ["aiserver.v1.DashboardService", "GetMe"], + deps + ); + assert.equal(res.status, 401); + const body = (await res.json()) as { code: string }; + assert.equal(body.code, "unauthenticated"); + }); + + it("invalidates the cached Cursor session when upstream answers 401", async () => { + const invalidated: string[] = []; + const { deps } = makeDeps({ + fetchImpl: (async () => new Response("expired", { status: 401 })) as unknown as typeof fetch, + invalidateBearer: (key) => { + invalidated.push(key); + }, + }); + const res = await handleCursorCliProxy( + rpcRequest(await mintedToken()), + ["aiserver.v1.DashboardService", "GetMe"], + deps + ); + assert.equal(res.status, 401); + assert.deepEqual(invalidated, [CURSOR_KEY]); + }); + + it("streams SSE bodies through and logs once when the stream completes", async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("event: a\ndata: 1\n\n")); + controller.enqueue(encoder.encode("event: b\ndata: 2\n\n")); + controller.close(); + }, + }); + const { deps, logs } = makeDeps({ + fetchImpl: (async () => + new Response(upstreamBody, { + status: 200, + headers: { "content-type": "text/event-stream", "transfer-encoding": "chunked" }, + })) as unknown as typeof fetch, + }); + const res = await handleCursorCliProxy( + rpcRequest(await mintedToken(), "/agent.v1.AgentService/RunSSE"), + ["agent.v1.AgentService", "RunSSE"], + deps + ); + assert.equal(res.status, 200); + assert.equal(res.headers.get("content-type"), "text/event-stream"); + assert.equal(res.headers.get("transfer-encoding"), null); + assert.equal(await res.text(), "event: a\ndata: 1\n\nevent: b\ndata: 2\n\n"); + await flushLogs(); + assert.equal(logs.length, 1); + assert.equal(logs[0].path, `${CURSOR_CLI_PROXY_PREFIX}/agent.v1.AgentService/RunSSE`); + assert.equal(logs[0].status, 200); + }); + + it("maps upstream network failures to a sanitized 502", async () => { + const { deps, logs } = makeDeps({ + fetchImpl: (async () => { + throw new Error("connect ECONNREFUSED at /home/user/OmniRoute/open-sse/x.ts:1:1"); + }) as unknown as typeof fetch, + }); + const res = await handleCursorCliProxy( + rpcRequest(await mintedToken()), + ["aiserver.v1.DashboardService", "GetMe"], + deps + ); + assert.equal(res.status, 502); + const body = (await res.json()) as { message: string }; + assert.ok(!body.message.includes("at /"), body.message); + await flushLogs(); + assert.equal(logs[0].status, 502); + }); +}); diff --git a/tests/unit/db-adapters/driverFactory.test.ts b/tests/unit/db-adapters/driverFactory.test.ts index f5fc774e3f..b73ea87a6f 100644 --- a/tests/unit/db-adapters/driverFactory.test.ts +++ b/tests/unit/db-adapters/driverFactory.test.ts @@ -10,6 +10,7 @@ import { runtimeRequire } from "../../../src/lib/db/adapters/runtimeRequire.ts"; const { createSyncDriverFactory, + createBetterSqliteProbe, isPackBootForcedSqlJsSmoke, tryOpenSync, openDatabaseAsync, @@ -81,6 +82,55 @@ describe("driverFactory", () => { } ); + test("rejected probe skips better-sqlite3 and falls through to node:sqlite", (t) => { + const databasePath = createTempDatabasePath(t); + const openWithoutBrokenAddon = createSyncDriverFactory( + (moduleName: string) => { + if (moduleName === "better-sqlite3") { + throw new Error("better-sqlite3 must not load when the probe rejects it"); + } + return require(moduleName); + }, + () => false + ); + + const adapter = openWithoutBrokenAddon(databasePath); + assert.ok(adapter); + assert.equal(adapter.driver, "node:sqlite"); + adapter.exec("CREATE TABLE items (value TEXT)"); + adapter.prepare("INSERT INTO items VALUES (?)").run("ok"); + assert.equal( + (adapter.prepare("SELECT value FROM items").get() as { value: string }).value, + "ok" + ); + adapter.close(); + }); + + test("passed probe still prefers better-sqlite3 in the cascade", () => { + let betterSqliteRequested = false; + const openWithPassedProbe = createSyncDriverFactory( + (moduleName: string) => { + if (moduleName === "better-sqlite3") { + betterSqliteRequested = true; + return function FakeBetterSqlite() { + return { close() {}, name: ":memory:", open: true }; + }; + } + if (moduleName === "node:sqlite") { + throw new Error("node:sqlite must not load when better-sqlite3 passes the probe"); + } + throw new Error(`unexpected driver load: ${moduleName}`); + }, + () => true + ); + + const adapter = openWithPassedProbe(":memory:"); + assert.ok(adapter); + assert.equal(adapter.driver, "better-sqlite3"); + assert.equal(betterSqliteRequested, true); + adapter.close(); + }); + test("prefers better-sqlite3 before node:sqlite in the driver cascade", () => { const fakeBetterSqlite = { close() {}, @@ -337,6 +387,74 @@ describe("driverFactory", () => { }); } + // #10627 — the Windows driver-hang guard. On Windows a mismatched-ABI + // better-sqlite3 addon can HANG inside DllMain instead of throwing, so the + // cascade's try/catch never fires and the fallback never runs. The probe + // loads the addon in a child process with a bounded timeout, turning a hang + // into a cached "bad" verdict that skips the branch. + test("probe: non-Windows platforms skip the child probe and report ok", () => { + let spawned = 0; + const probe = createBetterSqliteProbe({ + platform: "linux", + execPath: "node", + spawn: () => { + spawned += 1; + return { status: 0 }; + }, + }); + assert.equal(probe(), true); + assert.equal(probe(), true); + assert.equal(spawned, 0, "POSIX must not spawn a probe child process"); + }); + + test("probe: successful child probe is cached (spawned at most once)", () => { + let spawned = 0; + const probe = createBetterSqliteProbe({ + platform: "win32", + execPath: "node", + spawn: () => { + spawned += 1; + return { status: 0 }; + }, + }); + assert.equal(probe(), true); + assert.equal(probe(), true); + assert.equal(probe(), true); + assert.equal(spawned, 1, "verdict must be cached per process"); + }); + + test("probe: non-zero child exit rejects better-sqlite3", () => { + const probe = createBetterSqliteProbe({ + platform: "win32", + execPath: "node", + spawn: () => ({ status: 1 }), + }); + assert.equal(probe(), false); + assert.equal(probe(), false); + }); + + test("probe: child spawn throw rejects better-sqlite3", () => { + const probe = createBetterSqliteProbe({ + platform: "win32", + execPath: "node", + spawn: () => { + throw new Error("spawn failed"); + }, + }); + assert.equal(probe(), false); + }); + + test("probe: timed-out child (status null) rejects better-sqlite3 — the #10627 hang case", () => { + // status === null is exactly what spawnSync returns when the child is + // killed by the timeout — i.e. the DllMain hang that never throws. + const probe = createBetterSqliteProbe({ + platform: "win32", + execPath: "node", + spawn: () => ({ status: null }), + }); + assert.equal(probe(), false); + }); + test("retains the existing cascade when native drivers are unavailable", () => { const openWithoutNativeDrivers = createSyncDriverFactory(() => { throw new Error("forced driver load failure"); diff --git a/tests/unit/db-health-driver.test.ts b/tests/unit/db-health-driver.test.ts new file mode 100644 index 0000000000..efbc6b08f6 --- /dev/null +++ b/tests/unit/db-health-driver.test.ts @@ -0,0 +1,75 @@ +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-db-health-driver-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "db-health-driver-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const healthCheckDb = await import("../../src/lib/db/healthCheck.ts"); +const driverFactory = await import("../../src/lib/db/adapters/driverFactory.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── describeDbDriver: pure decision ─────────────────── + +test("the sql.js WASM fallback is reported degraded", () => { + assert.deepEqual( + healthCheckDb.describeDbDriver({ driver: "sql.js", name: "/data/storage.sqlite" }), + { + name: "sql.js", + degraded: true, + } + ); +}); + +test("a native driver backed by a real file is not degraded", () => { + for (const driver of ["better-sqlite3", "node:sqlite", "bun:sqlite"] as const) { + assert.deepEqual(healthCheckDb.describeDbDriver({ driver, name: "/data/storage.sqlite" }), { + name: driver, + degraded: false, + }); + } +}); + +test("an in-memory database is degraded even on a native driver", () => { + for (const driver of ["better-sqlite3", "node:sqlite", "bun:sqlite", "sql.js"] as const) { + assert.deepEqual(healthCheckDb.describeDbDriver({ driver, name: ":memory:" }), { + name: driver, + degraded: true, + }); + } +}); + +// ─── runDbHealthCheck: wiring against ground truth ───── + +test("the health check reports the driver of the database it actually ran against", () => { + const db = core.getDbInstance(); + const result = healthCheckDb.runDbHealthCheck(db, { autoRepair: false }); + + assert.equal(result.driver.name, db.driver); + + // Precondition: the fixture is file-backed, so `degraded` is asserted as a literal + // rather than re-derived from the implementation's own condition. + assert.ok(db.name.endsWith(".sqlite"), `expected a file-backed fixture, got ${db.name}`); + assert.equal(result.driver.degraded, false); +}); + +test("an in-memory database opened through the real cascade is reported degraded", () => { + // Same tryOpenSync() the cloud/build path reaches through openSqliteDatabase(), so the + // `:memory:` name is observed from the adapter rather than assumed. + const memoryAdapter = driverFactory.tryOpenSync(":memory:"); + assert.ok(memoryAdapter, "expected the native cascade to open an in-memory database"); + try { + assert.equal(memoryAdapter.name, ":memory:"); + assert.equal(healthCheckDb.describeDbDriver(memoryAdapter).degraded, true); + } finally { + memoryAdapter.close(); + } +}); diff --git a/tests/unit/db-logs-cache-3500.test.ts b/tests/unit/db-logs-cache-3500.test.ts index 6377bbe3d0..914a3f9a6b 100644 --- a/tests/unit/db-logs-cache-3500.test.ts +++ b/tests/unit/db-logs-cache-3500.test.ts @@ -1,5 +1,5 @@ /** - * #3500 — usage_logs / semantic_cache / proxy_logs SQL extracted into db modules + * #3500: semantic_cache / proxy_logs SQL extracted into db modules * (Hard Rule #5, slice 4). * * Seeds an in-memory temp SQLite DB for each table and asserts each new db @@ -17,35 +17,9 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-db-logs-cache- process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); -const usageLogs = await import("../../src/lib/db/usageLogs.ts"); const semanticCache = await import("../../src/lib/db/semanticCache.ts"); const proxyLogs = await import("../../src/lib/db/proxyLogs.ts"); -// --------------------------------------------------------------------------- -// Helpers — usage_logs seeding -// usage_logs is NOT in the core.ts schema; create it as a lightweight table -// mirroring the columns used by the auto-routing queries (model, provider). -// --------------------------------------------------------------------------- - -function ensureUsageLogsTable() { - const db = core.getDbInstance(); - db.prepare( - `CREATE TABLE IF NOT EXISTS usage_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - model TEXT NOT NULL, - provider TEXT NOT NULL, - timestamp TEXT NOT NULL - )` - ).run(); -} - -function insertUsageLog(row: { model: string; provider: string }) { - const db = core.getDbInstance(); - db.prepare( - `INSERT INTO usage_logs (model, provider, timestamp) VALUES (?, ?, ?)` - ).run(row.model, row.provider, new Date().toISOString()); -} - // --------------------------------------------------------------------------- // Helpers — semantic_cache seeding // --------------------------------------------------------------------------- @@ -70,7 +44,7 @@ function insertSemanticCache(row: { "hash_" + row.id, "{}", row.tokens_saved ?? 0, - row.hit_count ?? 0, + row.hit_count ?? 0 ); } @@ -91,7 +65,6 @@ function insertProxyLog(row: { id: string; timestamp: string; provider?: string test.before(() => { core.resetDbInstance(); - ensureUsageLogsTable(); }); test.after(() => { @@ -99,61 +72,6 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -// =========================================================================== -// usageLogs — getAutoRoutingTotalCount -// =========================================================================== - -test("#3500 getAutoRoutingTotalCount — returns 0 when no rows", () => { - const result = usageLogs.getAutoRoutingTotalCount(); - assert.equal(result.count, 0); -}); - -test("#3500 getAutoRoutingTotalCount — counts auto and auto/* models", () => { - insertUsageLog({ model: "auto", provider: "openai" }); - insertUsageLog({ model: "auto/fast", provider: "anthropic" }); - insertUsageLog({ model: "gpt-4", provider: "openai" }); // must NOT be counted - - const result = usageLogs.getAutoRoutingTotalCount(); - assert.ok(result.count >= 2, `expected >= 2, got ${result.count}`); -}); - -// =========================================================================== -// usageLogs — getAutoRoutingVariantBreakdown -// =========================================================================== - -test("#3500 getAutoRoutingVariantBreakdown — maps auto → default, auto/X → X", () => { - // Insert another auto and auto/fast to have stable counts - insertUsageLog({ model: "auto", provider: "openai" }); - insertUsageLog({ model: "auto/fast", provider: "anthropic" }); - - const rows = usageLogs.getAutoRoutingVariantBreakdown(); - const byVariant: Record = {}; - for (const r of rows) byVariant[r.variant] = r.count; - - assert.ok("default" in byVariant, "should have a 'default' variant for bare 'auto'"); - assert.ok("fast" in byVariant, "should have a 'fast' variant for 'auto/fast'"); - assert.ok(byVariant["default"] >= 1, "default count >= 1"); - assert.ok(byVariant["fast"] >= 1, "fast count >= 1"); -}); - -// =========================================================================== -// usageLogs — getAutoRoutingTopProviders -// =========================================================================== - -test("#3500 getAutoRoutingTopProviders — returns top providers for auto/* models", () => { - const rows = usageLogs.getAutoRoutingTopProviders(); - assert.ok(Array.isArray(rows), "result is array"); - assert.ok(rows.length > 0, "at least one provider row"); - for (const r of rows) { - assert.equal(typeof r.provider, "string"); - assert.equal(typeof r.count, "number"); - } - // Should be ordered descending by count (first row has highest count) - if (rows.length > 1) { - assert.ok(rows[0].count >= rows[1].count, "ordered descending by count"); - } -}); - // =========================================================================== // semanticCache — listSemanticCacheEntries // =========================================================================== @@ -316,8 +234,16 @@ test("#3500 exportProxyLogsSince — returns rows with timestamp >= since", () = const base = new Date("2025-01-15T10:00:00.000Z"); const old = new Date("2025-01-14T10:00:00.000Z"); - insertProxyLog({ id: "pl-new-1", timestamp: new Date("2025-01-15T11:00:00.000Z").toISOString(), provider: "openai" }); - insertProxyLog({ id: "pl-new-2", timestamp: new Date("2025-01-15T12:00:00.000Z").toISOString(), provider: "anthropic" }); + insertProxyLog({ + id: "pl-new-1", + timestamp: new Date("2025-01-15T11:00:00.000Z").toISOString(), + provider: "openai", + }); + insertProxyLog({ + id: "pl-new-2", + timestamp: new Date("2025-01-15T12:00:00.000Z").toISOString(), + provider: "anthropic", + }); insertProxyLog({ id: "pl-old-1", timestamp: old.toISOString(), provider: "openai" }); // outside window const rows = proxyLogs.exportProxyLogsSince(base.toISOString()); diff --git a/tests/unit/db-wal-truncate-scheduler.test.ts b/tests/unit/db-wal-truncate-scheduler.test.ts new file mode 100644 index 0000000000..cbd93cde7e --- /dev/null +++ b/tests/unit/db-wal-truncate-scheduler.test.ts @@ -0,0 +1,82 @@ +/** + * A WAL never shrinks on its own: only `wal_checkpoint(TRUNCATE)` reclaims the file, and a + * long-running server never closes its DB (observed locally: a 154 MB WAL on a 143 MB base). + * + * The scheduler cannot be exercised directly in a unit test: it gates itself off under + * `isAutomatedTestProcess()`, same as the pre-existing DB health-check scheduler it is + * modeled on (see tests/unit/lib/jobRegistry/boot-wiring.test.ts for the same constraint). + * This reads the wiring instead. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function readSource(relativePath: string): string { + return fs.readFileSync(path.join(process.cwd(), relativePath), "utf8"); +} + +const CORE_PATH = "src/lib/db/core.ts"; + +test("a periodic WAL truncate scheduler is started when the DB instance boots", () => { + const source = readSource(CORE_PATH); + assert.match( + source, + /startWalTruncateScheduler\(db\)/, + "getDbInstance() must start the WAL truncate scheduler alongside the DB health-check scheduler" + ); +}); + +test("the WAL truncate scheduler runs wal_checkpoint(TRUNCATE), not a lighter mode", () => { + const source = readSource(CORE_PATH); + const fnStart = source.indexOf("function startWalTruncateScheduler"); + assert.notEqual(fnStart, -1, "startWalTruncateScheduler must exist"); + const fnBody = source.slice(fnStart, fnStart + 1200); + assert.match( + fnBody, + /checkpointDb\(db, "TRUNCATE"\)/, + "the scheduled checkpoint must request TRUNCATE mode — a lighter mode would not shrink the WAL file" + ); +}); + +test("the WAL truncate scheduler is cleared on close, like the health-check scheduler", () => { + const source = readSource(CORE_PATH); + const fnStart = source.indexOf("export function closeDbInstance"); + assert.notEqual(fnStart, -1, "closeDbInstance must exist"); + const fnBody = source.slice(fnStart, fnStart + 300); + assert.match(fnBody, /clearDbHealthCheckScheduler\(\)/); + assert.match( + fnBody, + /clearWalTruncateScheduler\(\)/, + "closeDbInstance() must clear the WAL truncate timer so it does not outlive the DB handle" + ); +}); + +test("the truncate interval is overridable via OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS", () => { + const source = readSource(CORE_PATH); + assert.match( + source, + /OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS/, + "the interval must be operator-configurable, matching OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS" + ); +}); + +test("the scheduler self-gates the same way the DB health-check scheduler does", () => { + const source = readSource(CORE_PATH); + const fnStart = source.indexOf("function startWalTruncateScheduler"); + const fnBody = source.slice(fnStart, fnStart + 300); + assert.match( + fnBody, + /isCloud \|\| isBuildPhase \|\| isAutomatedTestProcess\(\)/, + "must not run during cloud/build/test contexts, same as startDbHealthCheckScheduler" + ); +}); + +test("the new env var is documented", () => { + const docs = readSource("docs/reference/ENVIRONMENT.md"); + assert.match( + docs, + /OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS/, + "docs/reference/ENVIRONMENT.md must document the new env var (check:env-doc-sync)" + ); +}); diff --git a/tests/unit/docker-llmlingua-optionals-9166.test.ts b/tests/unit/docker-llmlingua-optionals-9166.test.ts index da994ed5c4..ac57d0d362 100644 --- a/tests/unit/docker-llmlingua-optionals-9166.test.ts +++ b/tests/unit/docker-llmlingua-optionals-9166.test.ts @@ -16,7 +16,6 @@ import { assembleStandalone } from "../../scripts/build/assembleStandalone.mjs"; const REQUIRED_RUNTIME_PACKAGES = [ "@atjsh/llmlingua-2", "@huggingface/transformers", - "@tensorflow/tfjs", "js-tiktoken", ]; @@ -47,7 +46,7 @@ function mkPkg( function buildLlmlinguaRoot( rootDir: string, - transformersVersion = "3.5.2" + transformersVersion = "4.2.0" ): void { const rootNm = join(rootDir, "node_modules"); @@ -61,7 +60,6 @@ function buildLlmlinguaRoot( }, peerDependencies: { "@huggingface/transformers": "*", - "@tensorflow/tfjs": "*", "js-tiktoken": "*", }, }, @@ -72,18 +70,6 @@ function buildLlmlinguaRoot( mkPkg(rootNm, "es-toolkit"); - mkPkg(rootNm, "@tensorflow/tfjs", { - dependencies: { - "@tensorflow/tfjs-core": "4.22.0", - }, - }); - mkPkg(rootNm, "@tensorflow/tfjs-core", { - dependencies: { - long: "^5.0.0", - }, - }); - mkPkg(rootNm, "long"); - mkPkg(rootNm, "js-tiktoken", { dependencies: { "base64-js": "^1.5.1", @@ -138,8 +124,6 @@ test("#9166 standalone assembly includes the complete LLMLingua runtime closure" for (const packageName of [ ...REQUIRED_RUNTIME_PACKAGES, "es-toolkit", - "@tensorflow/tfjs-core", - "long", "base64-js", "onnxruntime-node", ]) { @@ -175,14 +159,14 @@ test("#9166 standalone assembly never overwrites an already pinned transformers ); try { - buildLlmlinguaRoot(root, "4.2.0"); + buildLlmlinguaRoot(root, "5.0.0"); const { distDir, standaloneDir } = createStandalone(root); mkPkg( join(standaloneDir, "node_modules"), "@huggingface/transformers", { - version: "3.5.2", + version: "4.2.0", } ); @@ -208,7 +192,7 @@ test("#9166 standalone assembly never overwrites an already pinned transformers assert.equal( targetManifest.version, - "3.5.2", + "4.2.0", "standalone's pinned transformers version must not be overwritten" ); @@ -286,9 +270,6 @@ test("#9166 co-location is not skipped when every closure dir exists but one is // llmlingua-2 one is the partial NFT-trace shell without its main. for (const packageName of [ "es-toolkit", - "@tensorflow/tfjs", - "@tensorflow/tfjs-core", - "long", "js-tiktoken", "base64-js", "@huggingface/transformers", diff --git a/tests/unit/early-stream-keepalive.test.ts b/tests/unit/early-stream-keepalive.test.ts index 59fdd5f9c0..df7450430b 100644 --- a/tests/unit/early-stream-keepalive.test.ts +++ b/tests/unit/early-stream-keepalive.test.ts @@ -3,7 +3,7 @@ * @description Unit tests for withEarlyStreamKeepalive (fast/slow path, frames, abort). * * @changes - * - [2026-07-28] [Cursor Grok 4.5] - Assert brand-neutral startup thinking text (✨) + * - [2026-08-16] - Assert Responses startup and recurring keepalives are neutral JSON events */ import test from "node:test"; import assert from "node:assert/strict"; @@ -13,12 +13,11 @@ import { ANTHROPIC_PING_FRAME, OPENAI_KEEPALIVE_FRAME, OPENAI_STARTUP_FRAME, - RESPONSES_STARTUP_THINKING_FRAME, OPENAI_CHAT_ERROR_FRAME, OPENAI_RESPONSES_ERROR_FRAME, } from "../../open-sse/utils/earlyStreamKeepalive.ts"; -import { assertResponsesOutputIndexLifecycle } from "../helpers/assertResponsesOutputIndexLifecycle.ts"; import { takeEarlyKeepaliveBytes } from "../../open-sse/utils/earlyKeepaliveByteBuffer.ts"; +import { OPENAI_RESPONSES_IN_PROGRESS_FRAME } from "../../open-sse/utils/sseHeartbeat.ts"; async function readAll(response: Response): Promise { const reader = response.body!.getReader(); @@ -175,131 +174,46 @@ test("startupFrame defaults to keepaliveFrame when omitted (no behavior change)" ); }); -// #7360 follow-up round 2: OpenClaw calls via /v1/responses (Responses API -// format), which only had the generic bare-comment keepalive — a live -// incident showed it disconnecting after ~56s waiting on a slow gemma-4 -// response. RESPONSES_STARTUP_THINKING_FRAME gives Responses-API clients the -// same real-content keepalive OpenAI chat/completions already got, as a -// self-contained (opened AND closed within this one frame) synthetic -// reasoning item — it never claims a response_id, so it can't collide with -// the real response's own independent response.created lifecycle that follows. -test("RESPONSES_STARTUP_THINKING_FRAME is a self-closed synthetic reasoning item with the expected text", () => { - const decoded = new TextDecoder().decode(RESPONSES_STARTUP_THINKING_FRAME); - const events = decoded - .split("\n\n") - .filter(Boolean) - .map((frame) => { - const [eventLine, dataLine] = frame.split("\n"); - return { - event: eventLine.replace(/^event: /, ""), - data: JSON.parse(dataLine.replace(/^data: /, "")), - }; - }); - - assert.deepEqual( - events.map((e) => e.event), - [ - "response.output_item.added", - "response.reasoning_summary_part.added", - "response.reasoning_summary_text.delta", - "response.reasoning_summary_part.done", - "response.output_item.done", - ] - ); - - const [added, partAdded, delta, partDone, itemDone] = events; - assert.equal(added.data.item.type, "reasoning"); - const itemId = added.data.item.id; - assert.ok(itemId, "reasoning item must have an id"); - - assert.equal(partAdded.data.item_id, itemId); - assert.equal(delta.data.item_id, itemId); - assert.equal(delta.data.delta, "✨"); - assert.equal(partDone.data.item_id, itemId); - assert.equal(partDone.data.part.text, "✨"); - - // Regression for the live 2026-08-13 incident (OpenClaw issue #123342): - // reasoning_summary_part.done only closes the nested summary part, not the - // output item itself. Without a matching response.output_item.done here, - // a client tracking open items by output_index still sees this synthetic - // item open at index 0 when the real upstream response later reuses that - // same index for its own response.output_item.added, and throws a - // collision ("Responses stream reused active output index 0"). - assert.equal(itemDone.data.output_index, added.data.output_index); - assert.equal(itemDone.data.item.id, itemId); - assert.equal(itemDone.data.item.type, "reasoning"); - - // General-purpose form of the same check: this frame alone must be a fully - // self-closed lifecycle (no output_item left open at the end). - assertResponsesOutputIndexLifecycle(events); -}); - -test("RESPONSES_STARTUP_THINKING_FRAME does not collide when the real upstream response reuses output_index 0", () => { - // Reproduces the actual live failure shape (OpenClaw issue #123342): the - // keepalive placeholder fires, then the real upstream response starts its - // own independent response.created lifecycle and reuses output_index 0 for - // its own real reasoning item. Concatenating the two and replaying them - // through the same output_index-lifecycle contract a real client enforces - // is what actually would have caught the missing output_item.done — the - // frame-shape-only test above could pass while this still failed. - const decoded = new TextDecoder().decode(RESPONSES_STARTUP_THINKING_FRAME); - const keepaliveEvents = decoded - .split("\n\n") - .filter(Boolean) - .map((frame) => { - const [eventLine, dataLine] = frame.split("\n"); - return { - event: eventLine.replace(/^event: /, ""), - data: JSON.parse(dataLine.replace(/^data: /, "")), - }; - }); - - const realResponseEvents = [ - { event: "response.created", data: { type: "response.created" } }, - { event: "response.in_progress", data: { type: "response.in_progress" } }, - { - event: "response.output_item.added", - data: { - type: "response.output_item.added", - output_index: 0, - item: { id: "rs_real", type: "reasoning", summary: [] }, - }, - }, - { - event: "response.output_item.done", - data: { - type: "response.output_item.done", - output_index: 0, - item: { id: "rs_real", type: "reasoning", summary: [] }, - }, - }, - ]; - - assert.doesNotThrow(() => - assertResponsesOutputIndexLifecycle([...keepaliveEvents, ...realResponseEvents]) - ); -}); - -test("slow handler emits the Responses API startup frame before the real body", async () => { +// Responses clients need both frequent raw bytes and occasional parsed events while +// upstream readiness is pending. Keep those cadences separate: comments cover the +// short idle-read timeout, while sparse response.in_progress events reset parsers that +// ignore comments without flooding the application event stream. +test("slow Responses handler uses comments plus sparse in_progress events", async () => { const slow = new Promise((resolve) => { - setTimeout( - () => resolve(sseResponse("event: response.created\ndata: {}\n\ndata: [DONE]\n\n")), - 120 - ); + setTimeout(() => resolve(sseResponse('data: {"type":"response.completed"}\n\n')), 900); }); const result = await withEarlyStreamKeepalive(slow, { - thresholdMs: 25, - intervalMs: 20, - startupFrame: RESPONSES_STARTUP_THINKING_FRAME, + thresholdMs: 20, + intervalMs: 250, + startupFrame: OPENAI_RESPONSES_IN_PROGRESS_FRAME, + applicationKeepalive: { + frame: OPENAI_RESPONSES_IN_PROGRESS_FRAME, + intervalMs: 500, + }, }); const body = await readAll(result); - assert.match(body, /event: response\.output_item\.added/); - assert.match(body, /✨/); - assert.match(body, /event: response\.reasoning_summary_part\.done/); - assert.match(body, /event: response\.created/, "should forward the real upstream body"); - assert.match(body, /data: \[DONE\]/); + const frames = body.split("\n\n").filter(Boolean); + const earlyFrames = frames.slice(0, -1); + assert.equal(earlyFrames[0], 'data: {"type":"response.in_progress"}'); + assert.ok( + earlyFrames.some((frame) => frame === ": keepalive"), + "transport ticks must remain lightweight SSE comments" + ); + const applicationFrames = earlyFrames.filter((frame) => frame.startsWith("data: ")); + assert.ok(applicationFrames.length >= 2, "expected startup and sparse application keepalives"); + for (const frame of applicationFrames) { + assert.deepEqual(JSON.parse(frame.slice("data: ".length)), { + type: "response.in_progress", + }); + assert.doesNotMatch(frame, /output_item|reasoning|✨/); + } + assert.ok( + applicationFrames.length < earlyFrames.length, + "application events must be sparser than transport heartbeats" + ); + assert.match(body, /data: {"type":"response.completed"}/, "real upstream body forwarded"); }); test("a correlationId records the startup frame and keepalive ticks, but not the forwarded body", async () => { @@ -307,20 +221,29 @@ test("a correlationId records the startup frame and keepalive ticks, but not the const slow = new Promise((resolve) => { setTimeout( () => resolve(sseResponse("event: response.created\ndata: {}\n\ndata: [DONE]\n\n")), - 65 + 650 ); }); const result = await withEarlyStreamKeepalive(slow, { thresholdMs: 25, - intervalMs: 20, - startupFrame: RESPONSES_STARTUP_THINKING_FRAME, + intervalMs: 250, + startupFrame: OPENAI_RESPONSES_IN_PROGRESS_FRAME, + applicationKeepalive: { + frame: OPENAI_RESPONSES_IN_PROGRESS_FRAME, + intervalMs: 500, + }, correlationId, }); await readAll(result); const recorded = takeEarlyKeepaliveBytes(correlationId).join(""); - assert.match(recorded, /event: response\.output_item\.added/, "startup frame must be recorded"); + assert.match( + recorded, + /data: {"type":"response\.in_progress"}/, + "startup frame must be recorded" + ); + assert.match(recorded, /: keepalive/, "transport heartbeat must be recorded"); assert.doesNotMatch( recorded, /event: response\.created/, @@ -337,7 +260,8 @@ test("omitting correlationId leaves the buffer untouched (today's behavior, unch const result = await withEarlyStreamKeepalive(slow, { thresholdMs: 25, intervalMs: 20, - startupFrame: RESPONSES_STARTUP_THINKING_FRAME, + keepaliveFrame: OPENAI_RESPONSES_IN_PROGRESS_FRAME, + startupFrame: OPENAI_RESPONSES_IN_PROGRESS_FRAME, }); await readAll(result); diff --git a/tests/unit/electron-lazy-window.test.ts b/tests/unit/electron-lazy-window.test.ts new file mode 100644 index 0000000000..f9d3f97677 --- /dev/null +++ b/tests/unit/electron-lazy-window.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +import { describe, it } from "node:test"; + +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", () => { + assert.equal(shouldStartHidden({ argv: ["electron", "--hidden"] }), true); + assert.equal(shouldStartHidden({ argv: ["electron", "--minimized"] }), true); + assert.equal( + shouldStartHidden({ argv: ["electron"], loginItemSettings: { wasOpenedAsHidden: true } }), + true + ); + assert.equal(shouldStartHidden({ argv: ["electron"], loginItemSettings: {} }), false); + }); + + it("creates the dashboard only when an explicit open action has no live window", () => { + const createdWindow = { id: "created" }; + let createCalls = 0; + const result = showOrCreateWindow({ + appReady: true, + getWindow: () => null, + createWindow: () => { + createCalls += 1; + return createdWindow; + }, + }); + + assert.equal(result, createdWindow); + assert.equal(createCalls, 1); + }); + + it("restores, shows, and focuses an existing dashboard without recreating it", () => { + const calls: string[] = []; + const existingWindow = { + isDestroyed: () => false, + isMinimized: () => true, + restore: () => calls.push("restore"), + show: () => calls.push("show"), + focus: () => calls.push("focus"), + }; + + const result = showOrCreateWindow({ + appReady: true, + getWindow: () => existingWindow, + createWindow: () => { + throw new Error("must not recreate a live dashboard"); + }, + }); + + assert.equal(result, existingWindow); + assert.deepEqual(calls, ["restore", "show", "focus"]); + }); + + it("does not create a BrowserWindow before Electron is ready", () => { + let createCalls = 0; + const result = showOrCreateWindow({ + appReady: false, + getWindow: () => null, + createWindow: () => { + createCalls += 1; + }, + }); + + assert.equal(result, null); + assert.equal(createCalls, 0); + }); + + it("routes tray, second-instance, and macOS activation opens through the lazy helper", () => { + const mainSource = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + + // #10328 added a headless guard ahead of the lazy-open call; second-instance + // must still route through showMainWindow() once past that guard. + assert.match(mainSource, /app\.on\("second-instance", \(\) => \{[\s\S]*?showMainWindow\(\);/); + assert.match(mainSource, /label: "Open OmniRoute",\s*click: \(\) => showMainWindow\(\)/); + assert.match(mainSource, /tray\.on\("double-click", \(\) => showMainWindow\(\)\);/); + assert.match(mainSource, /app\.on\("activate", \(\) => \{[\s\S]*?showMainWindow\(\);/); + }); + + it("keeps hidden startup renderer-free until an explicit open action", () => { + const mainSource = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + const readyBlock = mainSource.slice(mainSource.indexOf("app.whenReady().then")); + + assert.match( + readyBlock, + /startNextServer\(\);\s*if \(!isHeadless\) \{\s*createTray\(\);\s*\}/, + "the server and tray must start before the hidden/visible renderer decision" + ); + assert.match( + readyBlock, + /if \(isHeadless\)[\s\S]*?else if \(startHidden\)[\s\S]*?else \{\s*showMainWindow\(\);/ + ); + assert.doesNotMatch( + readyBlock.match(/else if \(startHidden\)[\s\S]*?\} else \{/s)?.[0] ?? "", + /createWindow\(|showMainWindow\(/ + ); + assert.match( + readyBlock, + /\}\s*setupIpcHandlers\(\);\s*setupAutoUpdater\(\);/, + "IPC and updater setup must remain active when no renderer was created" + ); + }); +}); diff --git a/tests/unit/electron-remote-server.test.ts b/tests/unit/electron-remote-server.test.ts index 05903db782..9595a0d6fe 100644 --- a/tests/unit/electron-remote-server.test.ts +++ b/tests/unit/electron-remote-server.test.ts @@ -25,6 +25,7 @@ const { const { readPreferences, writeRemoteServerUrl, + writeCloseBehavior, } = require("../../electron/lib/remoteServerPreferences"); function withTempDir(fn: (dir: string) => void) { @@ -126,7 +127,10 @@ describe("remoteServerPreferences read/write", () => { withTempDir((dir) => { const prefsPath = join(dir, "electron-preferences.json"); writeRemoteServerUrl(prefsPath, "http://localhost:20128"); - assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: "http://localhost:20128" }); + assert.deepEqual(readPreferences(prefsPath), { + remoteServerUrl: "http://localhost:20128", + closeBehavior: "keep-loaded", + }); }); }); @@ -135,7 +139,10 @@ describe("remoteServerPreferences read/write", () => { const prefsPath = join(dir, "electron-preferences.json"); writeRemoteServerUrl(prefsPath, "http://localhost:20128"); writeRemoteServerUrl(prefsPath, null); - assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: null }); + assert.deepEqual(readPreferences(prefsPath), { + remoteServerUrl: null, + closeBehavior: "keep-loaded", + }); }); }); @@ -144,14 +151,32 @@ describe("remoteServerPreferences read/write", () => { const prefsPath = join(dir, "nested", "deep", "electron-preferences.json"); assert.doesNotThrow(() => writeRemoteServerUrl(prefsPath, "http://localhost:20128")); assert.equal(existsSync(prefsPath), true); - assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: "http://localhost:20128" }); + assert.deepEqual(readPreferences(prefsPath), { + remoteServerUrl: "http://localhost:20128", + closeBehavior: "keep-loaded", + }); }); }); it("reading a nonexistent prefs file returns remoteServerUrl: null", () => { withTempDir((dir) => { const prefsPath = join(dir, "electron-preferences.json"); - assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: null }); + assert.deepEqual(readPreferences(prefsPath), { + remoteServerUrl: null, + closeBehavior: "keep-loaded", + }); + }); + }); + + it("persists close behavior without discarding the remote server URL", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + writeRemoteServerUrl(prefsPath, "https://omniroute.example.com"); + writeCloseBehavior(prefsPath, "unload"); + assert.deepEqual(readPreferences(prefsPath), { + remoteServerUrl: "https://omniroute.example.com", + closeBehavior: "unload", + }); }); }); }); @@ -243,6 +268,7 @@ describe("Electron packaging manifest includes Remote Server Mode files", () => for (const expected of [ "lib/resolveRemoteServerUrl.js", "lib/remoteServerPreferences.js", + "lib/windowClosePolicy.js", "remoteServerPromptPreload.js", "remoteServerPromptRenderer.js", "assets/remoteServerPrompt.html", diff --git a/tests/unit/electron-window-close-policy.test.ts b/tests/unit/electron-window-close-policy.test.ts new file mode 100644 index 0000000000..1febea3b6f --- /dev/null +++ b/tests/unit/electron-window-close-policy.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + CLOSE_BEHAVIOR_KEEP_LOADED, + CLOSE_BEHAVIOR_UNLOAD, + normalizeCloseBehavior, + resolveRendererUrl, +} = require("../../electron/lib/windowClosePolicy"); + +describe("Electron window close policy", () => { + it("accepts only the two deliberate close behaviors", () => { + assert.equal(normalizeCloseBehavior("keep-loaded"), CLOSE_BEHAVIOR_KEEP_LOADED); + assert.equal(normalizeCloseBehavior("unload"), CLOSE_BEHAVIOR_UNLOAD); + assert.equal(normalizeCloseBehavior("destroy"), null); + assert.equal(normalizeCloseBehavior(undefined), null); + }); + + it("preserves same-origin dashboard navigation when recreating the renderer", () => { + assert.equal( + resolveRendererUrl( + "http://localhost:20128/dashboard/settings?tab=providers", + "http://localhost:20128" + ), + "http://localhost:20128/dashboard/settings?tab=providers" + ); + }); + + it("falls back to the active server for invalid or cross-origin URLs", () => { + assert.equal( + resolveRendererUrl("https://example.com/dashboard", "http://localhost:20128"), + "http://localhost:20128/" + ); + assert.equal( + resolveRendererUrl("not a url", "http://localhost:20128"), + "http://localhost:20128" + ); + }); +}); + +describe("Electron main-process close policy wiring", () => { + const mainSrc = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + + it("defaults to keeping the renderer loaded and exposes both policies in the tray", () => { + assert.match(mainSrc, /electronPreferences\.closeBehavior/); + assert.match(mainSrc, /Keep Loaded \(Faster Reopen\)/); + assert.match(mainSrc, /Unload Renderer \(Lower Memory\)/); + assert.match(mainSrc, /writeCloseBehavior\(REMOTE_SERVER_PREFS_PATH, closeBehavior\)/); + }); + + it("destroys only the renderer in unload mode and otherwise hides the window", () => { + const closeHandler = mainSrc.slice( + mainSrc.indexOf('window.on("close"'), + mainSrc.indexOf('window.on("closed"') + ); + assert.ok(closeHandler.includes("closeBehavior === CLOSE_BEHAVIOR_UNLOAD")); + assert.ok(closeHandler.includes("window.destroy()")); + assert.ok(closeHandler.includes("window.hide()")); + assert.ok(!closeHandler.includes("stopNextServer")); + }); + + it("recreates the renderer from every explicit reopen path", () => { + const secondInstanceHandler = mainSrc.slice( + mainSrc.indexOf('app.on("second-instance"'), + mainSrc.indexOf("// ── Environment Detection") + ); + assert.ok(secondInstanceHandler.includes("showMainWindow()")); + assert.ok(secondInstanceHandler.includes("if (isHeadless) return")); + assert.match(mainSrc, /label: "Open OmniRoute",\s*click: \(\) => showMainWindow\(\)/); + assert.match(mainSrc, /tray\.on\("double-click", \(\) => showMainWindow\(\)\)/); + assert.match( + mainSrc, + /app\.on\("activate", \(\) => \{\s*if \(isHeadless\) return;\s*showMainWindow\(\);/ + ); + }); + + it("keeps the non-macOS app alive when unloading its last renderer", () => { + assert.match( + mainSrc, + /process\.platform !== "darwin"[\s\S]*closeBehavior !== CLOSE_BEHAVIOR_UNLOAD[\s\S]*app\.quit\(\)/ + ); + }); +}); diff --git a/tests/unit/embedding-account-cooldown-10347.test.ts b/tests/unit/embedding-account-cooldown-10347.test.ts new file mode 100644 index 0000000000..a9c1d8b5ce --- /dev/null +++ b/tests/unit/embedding-account-cooldown-10347.test.ts @@ -0,0 +1,95 @@ +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-embed-cooldown-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "embed-cooldown-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedConnection(provider: string): Promise { + const conn = await providersDb.createProviderConnection({ + provider, + authType: "apikey", + apiKey: `${provider}-key`, + isActive: true, + testStatus: "active", + }); + return (conn as Record).id as string; +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#10347: markAccountUnavailable triggers cooldown on embedding 402", async () => { + await resetStorage(); + const connId = await seedConnection("mistral"); + + const result = await auth.markAccountUnavailable( + connId, + 402, + "Check your subscription on https://admin.mistral.ai/subscription", + "mistral", + "mistral-embed" + ); + + assert.strictEqual(result.shouldFallback, true, "402 must trigger account cooldown"); + + // Verify the connection was marked — 402 is terminal (credits_exhausted), + // which sets testStatus but not rateLimitedUntil + const conn = await providersDb.getProviderConnectionById(connId); + assert.strictEqual( + conn.testStatus, + "credits_exhausted", + "402 must mark connection credits_exhausted" + ); +}); + +test("#10347: markAccountUnavailable triggers cooldown on embedding 500", async () => { + await resetStorage(); + const connId = await seedConnection("mistral"); + + const result = await auth.markAccountUnavailable( + connId, + 500, + "Internal server error", + "mistral", + "mistral-embed" + ); + + assert.strictEqual(result.shouldFallback, true, "500 must trigger account cooldown"); + const conn = await providersDb.getProviderConnectionById(connId); + assert.strictEqual(conn.testStatus, "unavailable", "500 must mark connection unavailable"); +}); + +test("#10347: embedding 400 (bad request) does NOT trigger account cooldown", async () => { + await resetStorage(); + const connId = await seedConnection("mistral"); + + // 400 bad request is a client error, not an account issue — should not cool down + const result = await auth.markAccountUnavailable( + connId, + 400, + "Invalid embedding input format", + "mistral", + "mistral-embed" + ); + + // Generic 400 returns shouldFallback:false (not account-fallback-worthy) + assert.strictEqual(result.shouldFallback, false, "400 bad request must not trigger cooldown"); + const conn = await providersDb.getProviderConnectionById(connId); + assert.strictEqual(conn.testStatus, "active", "400 must keep connection active"); +}); diff --git a/tests/unit/embedding-cooldown-integration-10347.test.ts b/tests/unit/embedding-cooldown-integration-10347.test.ts new file mode 100644 index 0000000000..f7bff8ea7a --- /dev/null +++ b/tests/unit/embedding-cooldown-integration-10347.test.ts @@ -0,0 +1,184 @@ +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"; + +// #10347 integration: exercise createEmbeddingResponse end-to-end with a +// mocked upstream that returns 402, then verify the connection gets cooled +// down. This proves the production code path actually calls +// markAccountUnavailable — the direct-call tests in +// embedding-account-cooldown-10347.test.ts would pass even if the +// production block were removed. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-embed-int-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "embed-int-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedConnection( + provider: string, + overrides: Record = {} +): Promise { + const conn = await providersDb.createProviderConnection({ + provider, + authType: "apikey", + apiKey: `${provider}-key`, + isActive: true, + testStatus: "active", + ...overrides, + }); + return (conn as Record).id as string; +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("createEmbeddingResponse marks connection on upstream 402", async () => { + resetStorage(); + const connId = await seedConnection("mistral"); + + // Mock upstream to return 402 (subscription expired). + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ error: "Check your subscription on https://admin.mistral.ai/subscription" }), + { status: 402, headers: { "Content-Type": "application/json" } } + )) as typeof globalThis.fetch; + + try { + // Import the service AFTER seeding the DB so its module-level caches + // see the seeded connection. + const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts"); + + // Call the production path — this must exercise the markAccountUnavailable + // block we added in #10347. + const res = await createEmbeddingResponse( + { model: "mistral-embed", input: "hello" }, + { connectionId: connId } + ); + + assert.equal(res.status, 402, "must return upstream status"); + + // Give the fire-and-forget markAccountUnavailable call time to settle. + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + // Verify the connection was actually marked — this is the assertion + // that would FAIL if the production block were removed. + const conn = await providersDb.getProviderConnectionById(connId); + assert.equal( + conn.testStatus, + "credits_exhausted", + "402 must mark connection credits_exhausted via production code path" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("cooled account is skipped on next request — second connection selected", async () => { + resetStorage(); + const conn1 = await seedConnection("mistral", { apiKey: "mistral-key-1" }); + const conn2 = await seedConnection("mistral", { apiKey: "mistral-key-2" }); + + const originalFetch = globalThis.fetch; + let fetchCallCount = 0; + + try { + const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts"); + + // First request: upstream returns 402 → conn1 gets cooled. + globalThis.fetch = (async () => { + fetchCallCount++; + return new Response(JSON.stringify({ error: "subscription expired" }), { + status: 402, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + const res1 = await createEmbeddingResponse( + { model: "mistral-embed", input: "hello" }, + { connectionId: conn1 } + ); + assert.equal(res1.status, 402); + + // Wait for fire-and-forget cooldown write. + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + // Verify conn1 is cooled. + const conn1After = await providersDb.getProviderConnectionById(conn1); + assert.equal(conn1After.testStatus, "credits_exhausted", "conn1 must be cooled"); + + // Second request: upstream returns 200. + globalThis.fetch = (async () => { + fetchCallCount++; + return new Response( + JSON.stringify({ + data: [{ embedding: [0.1, 0.2], index: 0 }], + model: "mistral-embed", + usage: { prompt_tokens: 1, total_tokens: 1 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof globalThis.fetch; + + // Call without specifying connectionId — credential selection should + // skip conn1 (credits_exhausted) and pick conn2. + const res2 = await createEmbeddingResponse({ model: "mistral-embed", input: "world" }, {}); + assert.equal(res2.status, 200, "second request must succeed via conn2"); + + // Verify conn2 is still healthy. + const conn2After = await providersDb.getProviderConnectionById(conn2); + assert.equal(conn2After.testStatus, "active", "conn2 must remain active"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("createEmbeddingResponse skips 400 (bad request) — no cooldown", async () => { + resetStorage(); + const connId = await seedConnection("mistral"); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "Invalid embedding input format" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + })) as typeof globalThis.fetch; + + try { + const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts"); + + const res = await createEmbeddingResponse( + { model: "mistral-embed", input: "hello" }, + { connectionId: connId } + ); + + assert.equal(res.status, 400, "must return upstream status"); + + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + const conn = await providersDb.getProviderConnectionById(connId); + assert.equal( + conn.testStatus, + "active", + "400 must NOT mark connection — account is fine, request was wrong" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/error-classifier.test.ts b/tests/unit/error-classifier.test.ts index d9259f5355..c4b7e8b2d4 100644 --- a/tests/unit/error-classifier.test.ts +++ b/tests/unit/error-classifier.test.ts @@ -368,3 +368,36 @@ test("isCloudflareFingerprintRejection: space-separated and URL-path forms match "URL path" ); }); + +test("classifyProviderError: 422 + gcp_project_required => GCP_PROJECT_REQUIRED (BYOP fast-fail)", () => { + const body = JSON.stringify({ + error: { + message: + "GCP_PROJECT_REQUIRED: Google Antigravity now requires a free GCP Project ID. " + + "Create one at console.cloud.google.com and enter it in Providers → Antigravity.", + type: "gcp_project_required", + code: "gcp_project_required", + }, + }); + assert.equal( + classifyProviderError(422, body, "antigravity"), + PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED + ); +}); + +test("classifyProviderError: 422 without the BYOP code stays unclassified (no model lockout)", () => { + // The sibling missing-project error (code missing_project_id) and any other + // 422 must NOT map to GCP_PROJECT_REQUIRED — and never to MODEL_NOT_FOUND, + // so chatCore keeps its fail-closed behavior without locking the model. + assert.equal( + classifyProviderError( + 422, + JSON.stringify({ + error: { code: "missing_project_id", message: "Missing Google projectId" }, + }), + "antigravity" + ), + null + ); + assert.equal(classifyProviderError(422, "some other body", "antigravity"), null); +}); diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 9913e02b5c..359768a3bd 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -450,7 +450,7 @@ test("CodexExecutor.transformRequest strips store from compact requests even whe assert.equal(result.instructions, "keep this"); }); -test("CodexExecutor.transformRequest preserves native assistant commentary history", () => { +test("CodexExecutor.transformRequest preserves commentary and strips orphan summaries", () => { const executor = new CodexExecutor(); const body = { _nativeCodexPassthrough: true, @@ -526,9 +526,8 @@ test("CodexExecutor.transformRequest preserves native assistant commentary histo ), true ); - // Reasoning items are stripped from the Responses input — encrypted_content is - // unusable with store=false (previous_response_id deleted) and the summary blob - // only inflates context on every subsequent agentic turn (decolua/9router#1599). + // Summary-only reasoning is display state, not continuation state. Replaying it + // with store=false only inflates every subsequent agentic turn (decolua/9router#1599). assert.equal( result.input.some((item) => item.type === "reasoning"), false @@ -543,6 +542,54 @@ test("CodexExecutor.transformRequest preserves native assistant commentary histo ); }); +test("CodexExecutor.transformRequest preserves active opaque reasoning with a summary", () => { + const executor = new CodexExecutor(); + const reasoning = { + type: "reasoning", + encrypted_content: "provider-state", + summary: [{ type: "summary_text", text: "Display summary" }], + }; + + const result = executor.transformRequest( + "gpt-5.5-low", + { + _nativeCodexPassthrough: true, + input: [reasoning], + stream: false, + }, + false, + { requestEndpointPath: "/responses" } + ); + + assert.equal(result.store, false); + assert.deepEqual(result.input, [reasoning]); +}); + +test("CodexExecutor.transformRequest preserves orphan summaries when store is enabled", () => { + const executor = new CodexExecutor(); + const reasoning = { + type: "reasoning", + summary: [{ type: "summary_text", text: "Display summary" }], + }; + + const result = executor.transformRequest( + "gpt-5.5-low", + { + _nativeCodexPassthrough: true, + input: [reasoning], + stream: false, + }, + false, + { + requestEndpointPath: "/responses", + providerSpecificData: { openaiStoreEnabled: true }, + } + ); + + assert.equal(result.store, true); + assert.deepEqual(result.input, [reasoning]); +}); + test("CodexExecutor.transformRequest still strips assistant commentary outside native passthrough", () => { const executor = new CodexExecutor(); const result = executor.transformRequest( diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index ac75c52bfe..f26fe182de 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -28,9 +28,10 @@ const { isModelCatalogNamesEnabled, isArenaEloSyncEnabled, isControlPlaneProxyDirectFallbackEnabled, + areContextWindowChecksDisabled, } = await import("../../src/shared/utils/featureFlags.ts"); -const EXPECTED_FEATURE_FLAG_COUNT = 50; +const EXPECTED_FEATURE_FLAG_COUNT = 51; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -207,6 +208,16 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.warningLevel, "caution"); } }); + + it("defines context-window check bypass as a dangerous opt-in policy flag", () => { + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "DISABLE_CONTEXT_WINDOW_CHECKS"); + assert.ok(def, "DISABLE_CONTEXT_WINDOW_CHECKS should exist"); + assert.strictEqual(def.category, "policies"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + assert.strictEqual(def.warningLevel, "danger"); + }); }); // ────────────────────────────────────────────────────── @@ -429,6 +440,34 @@ describe("resolveFeatureFlag", () => { removeFeatureFlagOverride("OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK"); } }); + + it("areContextWindowChecksDisabled defaults off and follows DB overrides", () => { + assert.strictEqual(areContextWindowChecksDisabled(), false); + try { + setFeatureFlagOverride("DISABLE_CONTEXT_WINDOW_CHECKS", "true"); + assert.strictEqual(areContextWindowChecksDisabled(), true); + } finally { + removeFeatureFlagOverride("DISABLE_CONTEXT_WINDOW_CHECKS"); + } + }); + + it("areContextWindowChecksDisabled keeps checks enabled when the flag store is unreadable", () => { + const originalError = console.error; + console.error = () => {}; + try { + core.resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.mkdirSync(tmpDir, { recursive: true }); + const blockerPath = path.join(tmpDir, "storage.sqlite"); + fs.mkdirSync(blockerPath, { recursive: true }); + assert.strictEqual(areContextWindowChecksDisabled(), false); + } finally { + console.error = originalError; + core.resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.mkdirSync(tmpDir, { recursive: true }); + } + }); }); }); diff --git a/tests/unit/firecrawl-search.test.ts b/tests/unit/firecrawl-search.test.ts index 601fa1a8ad..c37a6a9b99 100644 --- a/tests/unit/firecrawl-search.test.ts +++ b/tests/unit/firecrawl-search.test.ts @@ -12,8 +12,13 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { SEARCH_PROVIDERS, SEARCH_CREDENTIAL_FALLBACKS, getSearchProvider, selectProvider } = - await import("../../open-sse/config/searchRegistry.ts"); +const { + SEARCH_PROVIDERS, + SEARCH_CREDENTIAL_FALLBACKS, + getSearchProvider, + selectProvider, + resolveSearchProvider, +} = await import("../../open-sse/config/searchRegistry.ts"); const { handleSearch } = await import("../../open-sse/handlers/search.ts"); const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); @@ -54,8 +59,18 @@ test("v1SearchSchema accepts firecrawl for search (unified id)", () => { search_type: "news", }); assert.equal(news.success, true); + // #10849: v1SearchSchema.provider is a free-form string, not a hard-coded enum, so + // the runtime catalog (resolveSearchProvider()) is the source of truth for whether an + // id is valid — the legacy "firecrawl-search" id is still rejected, just downstream of + // the schema (route.ts replies "Unknown search provider: firecrawl-search") instead of + // by an opaque schema-level 400. const legacy = v1SearchSchema.safeParse({ query: "q", provider: "firecrawl-search" }); - assert.equal(legacy.success, false, "legacy firecrawl-search id is not accepted"); + assert.equal(legacy.success, true, "provider is a free-form string at the schema layer"); + assert.equal( + resolveSearchProvider("firecrawl-search"), + null, + "legacy firecrawl-search id does not resolve to a registered provider" + ); }); test("handleSearch firecrawl hits /v2/search with sources web and normalizes data.web", async () => { diff --git a/tests/unit/fix-error-message-candidates.test.ts b/tests/unit/fix-error-message-candidates.test.ts index d141da40b0..3bdfa50d51 100644 --- a/tests/unit/fix-error-message-candidates.test.ts +++ b/tests/unit/fix-error-message-candidates.test.ts @@ -17,7 +17,8 @@ test("handleNoCredentials includes candidate aliases hint when supplied", async /* model */ "claude-opus-5", /* lastError */ null, /* lastStatus */ null, - /* candidateAliases */ ["anthropic", "claude", "agentrouter"] + /* candidateAliases */ ["anthropic", "claude", "agentrouter"], + /* isCombo */ true ); assert.equal(res.status, 404); @@ -42,8 +43,10 @@ test("handleNoCredentials omits hint when no candidates supplied", async () => { "kiro", "claude-opus-5", null, - null + null, /* no candidateAliases */ + undefined, + /* isCombo */ true ); assert.equal(res.status, 404); @@ -65,14 +68,18 @@ test("handleNoCredentials trims candidate list to top 3", async () => { "claude-opus-5", null, null, - ["anthropic", "claude", "agentrouter", "github", "vertex-partner"] + ["anthropic", "claude", "agentrouter", "github", "vertex-partner"], + /* isCombo */ true ); const body = (await res.json()) as { error?: { message?: string } }; const message = body?.error?.message ?? ""; // Top-3 (anthropic, claude, agentrouter) — github and vertex-partner are // dropped to keep the hint actionable. - assert.match(message, /Try one of: anthropic\/claude-opus-5, claude\/claude-opus-5, agentrouter\/claude-opus-5/); + assert.match( + message, + /Try one of: anthropic\/claude-opus-5, claude\/claude-opus-5, agentrouter\/claude-opus-5/ + ); assert.doesNotMatch(message, /github\/claude-opus-5/); assert.doesNotMatch(message, /vertex-partner\/claude-opus-5/); -}); \ No newline at end of file +}); diff --git a/tests/unit/flat-rate-cost-5552.test.ts b/tests/unit/flat-rate-cost-5552.test.ts index e467024e9b..2df378ee3e 100644 --- a/tests/unit/flat-rate-cost-5552.test.ts +++ b/tests/unit/flat-rate-cost-5552.test.ts @@ -23,6 +23,8 @@ test("isFlatRateProvider: dedicated subscription / coding-plan providers are fla "qwen-cloud-token-plan", "glm", "glm-cn", + "claude", + "cc", ]) { assert.equal(isFlatRateProvider(id), true, `${id} should be flat-rate`); } @@ -36,7 +38,8 @@ test("isFlatRateProvider: case-insensitive + trimmed", () => { test("isFlatRateProvider: metered / cost-tracked providers are NOT flat-rate (no hidden cost)", () => { // codex/cx = OmniRoute actively tracks Codex token cost (Fast-tier multipliers, // GPT-5.x pricing) and Codex can be a metered account; byteplus = metered ModelArk; - // minimax-cn = metered China API; glm-thinking = metered tier. + // minimax-cn = metered China API; glm-thinking = metered tier; anthropic = the + // metered Anthropic API, distinct from the `claude`/`cc` Claude Code plan. for (const id of [ "openai", "anthropic", @@ -68,6 +71,11 @@ test("computeCostFromPricing: flat-rate provider with flatRateAsZero → $0", () computeCostFromPricing(PRICING, TOKENS, { provider: "minimax", flatRateAsZero: true }), 0 ); + // Claude Code is billed by the Pro/Max subscription, never per token. + assert.equal( + computeCostFromPricing(PRICING, TOKENS, { provider: "claude", flatRateAsZero: true }), + 0 + ); }); test("computeCostFromPricing: opt-in only — flat-rate provider WITHOUT the flag still estimates", () => { @@ -85,4 +93,9 @@ test("computeCostFromPricing: metered provider with the flag still estimates", ( computeCostFromPricing(PRICING, TOKENS, { provider: "byteplus", flatRateAsZero: true }), 3 ); + // The metered Anthropic API keeps its real cost — only the Claude Code plan is flat-rate. + assert.equal( + computeCostFromPricing(PRICING, TOKENS, { provider: "anthropic", flatRateAsZero: true }), + 3 + ); }); diff --git a/tests/unit/freebuff-provider.test.ts b/tests/unit/freebuff-provider.test.ts new file mode 100644 index 0000000000..d67de75f97 --- /dev/null +++ b/tests/unit/freebuff-provider.test.ts @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { FreebuffExecutor } from "../../open-sse/executors/freebuff.ts"; +import type { ExecuteInput } from "../../open-sse/executors/base.ts"; +import { freebuffProvider } from "../../open-sse/config/providers/registry/freebuff/index.ts"; +import { APIKEY_PROVIDERS_GATEWAYS } from "../../src/shared/constants/providers/apikey/gateways.ts"; +import { validateFreebuffProvider } from "../../src/lib/providers/validation.ts"; + +test("FreebuffExecutor: constructor initializes provider name correctly", () => { + const executor = new FreebuffExecutor(); + assert.equal(executor.getProvider(), "freebuff"); +}); + +test("FreebuffExecutor: returns 401 response when credentials are missing", async () => { + const executor = new FreebuffExecutor(); + const res = await executor.execute({ + model: "deepseek/deepseek-v4-flash", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: false, + credentials: { apiKey: "" }, + } as unknown as ExecuteInput); + + assert.equal(res.response.status, 401); + const data = (await res.response.json()) as { error: { message: string } }; + assert.match(data.error.message, /Freebuff Auth Token required/i); +}); + +test("freebuffProvider: registry entry has valid structure and catalog", () => { + assert.equal(freebuffProvider.id, "freebuff"); + assert.equal(freebuffProvider.format, "openai"); + assert.equal(freebuffProvider.executor, "freebuff"); + assert.equal(freebuffProvider.baseUrl, "https://www.codebuff.com/api/v1"); + assert.ok(Array.isArray(freebuffProvider.models)); + assert.ok(freebuffProvider.models.length >= 8); + + const flash = freebuffProvider.models.find((m) => m.id === "deepseek/deepseek-v4-flash"); + assert.ok(flash, "deepseek/deepseek-v4-flash must exist in freebuff models"); + assert.equal(flash?.supportsReasoning, true); + + const minimax = freebuffProvider.models.find((m) => m.id === "minimax/minimax-m3"); + assert.ok(minimax, "minimax/minimax-m3 must exist in freebuff models"); + assert.equal(minimax?.supportsVision, true); +}); + +test("APIKEY_PROVIDERS_GATEWAYS: freebuff gateway metadata is defined", () => { + const fb = APIKEY_PROVIDERS_GATEWAYS.freebuff; + assert.ok(fb, "freebuff must be in APIKEY_PROVIDERS_GATEWAYS"); + assert.equal(fb.id, "freebuff"); + assert.equal(fb.name, "Freebuff"); + assert.equal(fb.color, "#10B981"); + assert.equal(fb.hasFree, true); +}); + +test("validateFreebuffProvider: returns invalid when apiKey is empty", async () => { + const res = await validateFreebuffProvider({ apiKey: "" }); + assert.equal(res.valid, false); + assert.match(res.error || "", /Freebuff Auth Token required/i); +}); diff --git a/tests/unit/freepik-image-handler.test.ts b/tests/unit/freepik-image-handler.test.ts deleted file mode 100644 index 610445e5af..0000000000 --- a/tests/unit/freepik-image-handler.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import dns from "node:dns"; - -import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts"; -import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts"; -import { APIKEY_PROVIDERS } from "../../src/shared/constants/providers.ts"; -import { IMAGE_ONLY_PROVIDER_IDS } from "../../src/shared/constants/providers.ts"; - -// Stub DNS for fetchRemoteImage/direct-fetch DNS-rebinding guards, mirroring -// tests/unit/nanobanana-image-handler.test.ts. -const originalDnsLookup = dns.promises.lookup; -(dns.promises as { lookup: unknown }).lookup = (async ( - _hostname: string, - options?: { all?: boolean } -) => { - const record = { address: "203.0.113.1", family: 4 }; - return options && options.all ? [record] : record; -}) as typeof dns.promises.lookup; -process.on("exit", () => { - (dns.promises as { lookup: unknown }).lookup = originalDnsLookup; -}); - -test("freepik provider is registered (registry shape)", () => { - assert.ok(APIKEY_PROVIDERS.freepik, "freepik should be in APIKEY_PROVIDERS"); - assert.equal(APIKEY_PROVIDERS.freepik.id, "freepik"); - assert.ok(IMAGE_ONLY_PROVIDER_IDS.has("freepik"), "freepik should be in IMAGE_ONLY_PROVIDER_IDS"); - - const provider = IMAGE_PROVIDERS.freepik; - assert.ok(provider, "freepik should be in IMAGE_PROVIDERS"); - assert.equal(provider.format, "freepik-image"); - assert.equal(provider.authType, "apikey"); - assert.equal(provider.authHeader, "x-freepik-api-key"); - assert.ok(provider.models.some((m) => m.id === "realism")); - assert.ok(provider.models.some((m) => m.id === "fluid")); -}); - -test("handleImageGeneration(freepik): async submit+poll returns b64_json payload", async () => { - const originalFetch = globalThis.fetch; - let pollCount = 0; - - globalThis.fetch = (async (url: string, options: { headers?: Record; body?: string } = {}) => { - const u = String(url); - - if (u === "https://api.freepik.com/v1/ai/mystic") { - assert.equal(options.headers?.["x-freepik-api-key"], "test-key"); - const parsed = JSON.parse(options.body as string); - assert.equal(parsed.prompt, "a red panda astronaut"); - assert.equal(parsed.model, "realism"); - return new Response( - JSON.stringify({ data: { task_id: "task-freepik-1", status: "CREATED" } }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - if (u === "https://api.freepik.com/v1/ai/mystic/task-freepik-1") { - pollCount += 1; - if (pollCount < 2) { - return new Response( - JSON.stringify({ data: { task_id: "task-freepik-1", status: "IN_PROGRESS" } }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - return new Response( - JSON.stringify({ - data: { - task_id: "task-freepik-1", - status: "COMPLETED", - generated: ["https://cdn.example.com/freepik-result.png"], - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - if (u === "https://cdn.example.com/freepik-result.png") { - return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200 }); - } - - throw new Error(`Unexpected URL: ${u}`); - }) as typeof fetch; - - try { - const result = await handleImageGeneration({ - body: { - model: "freepik/realism", - prompt: "a red panda astronaut", - poll_interval_ms: 1, - }, - credentials: { apiKey: "test-key" }, - log: null, - }); - - assert.equal(result.success, true); - assert.equal(result.data.data.length, 1); - assert.equal(result.data.data[0].b64_json, "iVBORw=="); - assert.equal(pollCount, 2); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("handleImageGeneration(freepik): FAILED status returns sanitized 502 error", async () => { - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (url: string) => { - const u = String(url); - if (u === "https://api.freepik.com/v1/ai/mystic") { - return new Response(JSON.stringify({ data: { task_id: "task-fail", status: "CREATED" } }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (u === "https://api.freepik.com/v1/ai/mystic/task-fail") { - return new Response(JSON.stringify({ data: { task_id: "task-fail", status: "FAILED" } }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`Unexpected URL: ${u}`); - }) as typeof fetch; - - try { - const result = await handleImageGeneration({ - body: { model: "freepik/realism", prompt: "broken prompt", poll_interval_ms: 1 }, - credentials: { apiKey: "test-key" }, - log: null, - }); - - assert.equal(result.success, false); - assert.equal(result.status, 502); - assert.match(result.error, /Freepik Mystic image generation failed/); - // Hard Rule #12: error responses must never leak a raw stack trace / file path. - assert.ok(!result.error.includes("at /")); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("handleImageGeneration(freepik): submit error response is sanitized, not raw upstream body", async () => { - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async () => { - // Simulate an upstream error body containing something that looks like a - // stack trace / absolute source path, to prove sanitizeErrorMessage runs. - const stackyBody = "Error: boom\n at /srv/app/handlers/mystic.ts:42:10"; - return new Response(stackyBody, { status: 500 }); - }) as typeof fetch; - - try { - const result = await handleImageGeneration({ - body: { model: "freepik/realism", prompt: "x" }, - credentials: { apiKey: "test-key" }, - log: null, - }); - - assert.equal(result.success, false); - assert.equal(result.status, 500); - assert.ok(!result.error.includes("/srv/app/handlers/mystic.ts")); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tests/unit/fusion-vision-panel-3378.test.ts b/tests/unit/fusion-vision-panel-3378.test.ts new file mode 100644 index 0000000000..572891d252 --- /dev/null +++ b/tests/unit/fusion-vision-panel-3378.test.ts @@ -0,0 +1,142 @@ +// Regression guard for upstream decolua/9router#3378: "Fusion combo sometimes +// can't see images even when all models support vision". +// +// Every non-fusion combo strategy runs the request through +// filterTargetsByRequestCompatibility (comboStructure.ts) before dispatch, which +// treats a target whose vision support is not *confirmed* `=== true` (unknown OR +// false) as vision-incompatible and excludes it (#8332). The fusion dispatch +// branch (dispatchPrelude.ts::tryFusionDispatch) resolves its panel via the raw +// resolveComboTargets() and skips that compat filter entirely — so a panel +// member whose model id is unrecognized by the capability registry (and thus +// resolves to supportsVision !== true) still receives the unmodified +// image-bearing body, without any signal that its capability could not be +// confirmed. +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-fusion-vision-3378-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "fusion-vision-3378-test-secret"; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { saveModelsDevCapabilities, clearModelsDevCapabilities } = await import( + "../../src/lib/modelsDevSync.ts" +); +const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); +const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); +const { resetAll: resetAllSemaphores } = await import( + "../../open-sse/services/rateLimitSemaphore.ts" +); +const core = await import("../../src/lib/db/core.ts"); + +function createLog() { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; +} + +function okResponse(content: string) { + return new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function capabilityEntry(overrides: Record = {}) { + return { + tool_call: true, + reasoning: false, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: false, + limit_context: 128000, + limit_input: 128000, + limit_output: 4096, + interleaved_field: null, + ...overrides, + }; +} + +const imageRequestBody = { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image_url", image_url: { url: "https://example.com/cat.png" } }, + ], + }, + ], +}; + +test.beforeEach(() => { + resetAllComboMetrics(); + resetAllCircuitBreakers(); + resetAllSemaphores(); + clearModelsDevCapabilities(); +}); + +test.after(() => { + resetAllComboMetrics(); + resetAllCircuitBreakers(); + resetAllSemaphores(); + clearModelsDevCapabilities(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +}); + +test( + "fusion panel must not dispatch an image_url request to a member whose vision " + + "support cannot be confirmed (#3378)", + async () => { + // fusion-vision-a is confirmed vision-capable. fusion-unknown has no + // capability entry at all (unrecognized id) -> getResolvedModelCapabilities + // resolves supportsVision to something other than `true`, exactly like the + // "unknown id silently treated as no vision" failure mode from the upstream + // report. + saveModelsDevCapabilities({ + openai: { + "fusion-vision-a": capabilityEntry({ attachment: true }), + }, + }); + + const dispatched: string[] = []; + const result = await handleComboChat({ + body: imageRequestBody, + combo: { + name: "fusion-vision-panel-3378", + strategy: "fusion", + models: ["openai/fusion-vision-a", "openai/fusion-unknown"], + config: { judgeModel: "openai/fusion-vision-a" }, + }, + handleSingleModel: async (_body, modelStr) => { + dispatched.push(modelStr); + return okResponse(`answer from ${modelStr}`); + }, + log: createLog(), + settings: {}, + allCombos: [], + }); + + assert.ok(result.status < 500, "combo call should not hard-fail"); + assert.ok( + !dispatched.includes("openai/fusion-unknown"), + "a panel member with unconfirmed vision support must never receive the raw image_url body" + ); + } +); diff --git a/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts b/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts new file mode 100644 index 0000000000..d14c136e7b --- /dev/null +++ b/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts @@ -0,0 +1,180 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// GLM-5.3 support (released 2026-08-14, https://z.ai/blog/glm-5.3). +// +// Upstream ships ONE model id (`glm-5.3`) — effort is a request parameter +// (`reasoning_effort`: low|high|max, default max) on the coding chat/completions +// endpoint, and `thinking.type: "disabled"` is rejected (converted to low by the +// coding endpoint). OmniRoute keeps the GLM-5.2 tier UX: `glm-5.3-high` / +// `glm-5.3-low` pseudo-ids resolved by the GlmExecutor only. Base `glm-5.3` uses +// the upstream default (max). Unlike the 5.2 tiers (Anthropic-transport effort +// beta header), the 5.3 tiers use the documented `reasoning_effort` param on the +// OpenAI coding transport. +// +// Spec caveat: Z.ai has not yet published the default context window — 1M is +// mirrored from GLM-5.2 (same base model) per operator decision; correct when +// the official spec lands. + +const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); +const { GlmExecutor } = await import("../../open-sse/executors/glm.ts"); +const { MODEL_SPECS } = await import("../../src/shared/constants/modelSpecs.ts"); +const { GLM_PRICING } = await import("../../src/shared/constants/pricing/shared-tiers.ts"); + +const GLM_5_3_IDS = ["glm-5.3", "glm-5.3-high", "glm-5.3-low"] as const; + +// transformForTransport returns an opaque body; surface only the fields asserted below. +type TransformedRequest = { + model?: string; + reasoning_effort?: string; + thinking?: { type?: string } | null; + max_tokens?: number; + effort?: string; +}; + +function modelIds(provider: string): string[] { + const entry = getRegistryEntry(provider); + assert.ok(entry, `provider "${provider}" should be registered`); + return (entry.models ?? []).map((m) => m.id); +} + +for (const provider of ["glm", "glm-cn", "glmt"]) { + test(`${provider} advertises the GLM-5.3 base model and effort tiers (GLM_SHARED_MODELS)`, () => { + const ids = modelIds(provider); + for (const id of GLM_5_3_IDS) { + assert.ok(ids.includes(id), `${provider} should expose ${id}; got ${ids.join(", ")}`); + } + }); + + test(`${provider} GLM-5.3 entries mirror the GLM-5.2 shape (1M ctx, 128K out)`, () => { + const models = getRegistryEntry(provider)!.models ?? []; + const base = models.find((m) => m.id === "glm-5.3"); + assert.ok(base, "glm-5.3 entry missing"); + assert.equal(base.contextLength, 1_000_000); + assert.equal(base.maxOutputTokens, 131_072); + assert.equal(base.toolCalling, true); + assert.equal(base.supportsReasoning, true); + }); +} + +test("zai advertises the GLM-5.3 base model only (DefaultExecutor sends ids verbatim)", () => { + const ids = modelIds("zai"); + assert.ok(ids.includes("glm-5.3"), `zai should advertise glm-5.3; got ${ids.join(", ")}`); + for (const alias of ["glm-5.3-high", "glm-5.3-low"]) { + assert.ok( + !ids.includes(alias), + `zai must not list ${alias}: GlmExecutor-only alias, unknown upstream on the Anthropic endpoint` + ); + } +}); + +test("modelSpecs carries 1M/128K specs for all GLM-5.3 ids", () => { + for (const id of GLM_5_3_IDS) { + const spec = MODEL_SPECS[id]; + assert.ok(spec, `MODEL_SPECS should include ${id}`); + assert.equal(spec.contextWindow, 1_000_000); + assert.equal(spec.maxOutputTokens, 131_072); + assert.equal(spec.supportsThinking, true); + } +}); + +test("GLM_PRICING covers the GLM-5.3 ids with GLM-5.2-parity rates", () => { + const reference = GLM_PRICING["glm-5.2"]; + assert.ok(reference, "glm-5.2 pricing reference missing"); + for (const id of GLM_5_3_IDS) { + const pricing = GLM_PRICING[id]; + assert.ok(pricing, `GLM_PRICING should include ${id}`); + assert.deepEqual(pricing, reference); + } +}); + +test("GlmExecutor resolves glm-5.3-high to reasoning_effort=high on the OpenAI coding transport", () => { + const executor = new GlmExecutor("glm"); + const transformed = executor.transformForTransport( + "glm-5.3-high", + { messages: [{ role: "user", content: "hi" }] }, + false, + { apiKey: "glm-key" }, + "openai" + ) as TransformedRequest; + + assert.equal(transformed.model, "glm-5.3", "upstream must receive the base model id"); + assert.equal(transformed.reasoning_effort, "high"); + assert.equal(transformed.thinking?.type, "enabled"); +}); + +test("GlmExecutor resolves glm-5.3-low to reasoning_effort=low with thinking enabled", () => { + const executor = new GlmExecutor("glm"); + const transformed = executor.transformForTransport( + "glm-5.3-low", + { messages: [{ role: "user", content: "hi" }] }, + false, + { apiKey: "glm-key" }, + "openai" + ) as TransformedRequest; + + assert.equal(transformed.model, "glm-5.3"); + assert.equal(transformed.reasoning_effort, "low"); + assert.equal(transformed.thinking?.type, "enabled"); +}); + +test("GlmExecutor leaves base glm-5.3 without an injected reasoning_effort (upstream default = max)", () => { + const executor = new GlmExecutor("glm"); + const transformed = executor.transformForTransport( + "glm-5.3", + { model: "glm-5.3", messages: [{ role: "user", content: "hi" }] }, + false, + { apiKey: "glm-key" }, + "openai" + ) as TransformedRequest; + + assert.equal(transformed.model, "glm-5.3"); + assert.equal(transformed.reasoning_effort, undefined); + // Thinking-model max_tokens default applies to 5.3 (GLM_THINKING_MODEL_PATTERN) + assert.equal(transformed.max_tokens, 131_072); +}); + +test("GLM-5.3 effort tiers execute on the OpenAI coding transport (no Anthropic-only pinning)", async () => { + const executor = new GlmExecutor("glm"); + const originalFetch = globalThis.fetch; + const calls: string[] = []; + + globalThis.fetch = async (url) => { + calls.push(String(url)); + return new Response( + 'data: {"id":"chatcmpl-glm53","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}\n\ndata: [DONE]\n\n', + { headers: { "Content-Type": "text/event-stream" } } + ); + }; + + try { + await executor.execute({ + model: "glm-5.3-high", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: { + apiKey: "glm-key", + providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" }, + }, + }); + + assert.deepEqual(calls, ["https://api.z.ai/api/coding/paas/v4/chat/completions"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GLM-5.2 effort tiers still pin the Anthropic transport (effort beta header) — regression guard", () => { + const executor = new GlmExecutor("glm"); + const transformed = executor.transformForTransport( + "glm-5.2-max", + { messages: [{ role: "user", content: "hi" }] }, + false, + { apiKey: "glm-key" }, + "anthropic" + ) as TransformedRequest; + + assert.equal(transformed.model, "glm-5.2"); + assert.equal(transformed.effort, "max"); + assert.equal(transformed.thinking?.type, "enabled"); +}); diff --git a/tests/unit/grok-cli-responses-compat.test.ts b/tests/unit/grok-cli-responses-compat.test.ts index 3af78305b7..d2455320b4 100644 --- a/tests/unit/grok-cli-responses-compat.test.ts +++ b/tests/unit/grok-cli-responses-compat.test.ts @@ -177,6 +177,7 @@ test("grok-cli live model discovery uses the authenticated session contract", () owned_by: "grok-cli", inputTokenLimit: 500000, supportsThinking: true, + supportedThinkingEfforts: ["low", "medium", "high"], apiFormat: "responses", supportedEndpoints: ["responses"], }, diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index 428b75bdcf..9736a33e07 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -13,15 +13,19 @@ type BypassClass = "A" | "B" | "C"; const EXPECTED: Record> = { credential: { - "open-sse/handlers/chatCore.ts": 1, + "open-sse/handlers/chatCore.ts": 2, "open-sse/services/imageCombo.ts": 1, + "open-sse/services/speechCombo.ts": 1, + "open-sse/services/videoCombo.ts": 2, "src/app/api/compression/compare/verify/route.ts": 1, "src/app/api/internal/codex-responses-ws/route.ts": 1, "src/app/api/search/providers/route.ts": 3, "src/app/api/v1/audio/speech/route.ts": 1, - "src/app/api/v1/audio/transcriptions/route.ts": 1, + "src/app/api/v1/_shared/videoModelResolution.ts": 1, + "src/app/api/v1/audio/transcriptions/route.ts": 2, "src/app/api/v1/audio/translations/route.ts": 1, - "src/app/api/v1/images/edits/route.ts": 5, + "src/app/api/v1/classify/route.ts": 1, + "src/app/api/v1/images/edits/route.ts": 6, "src/app/api/v1/images/generations/route.ts": 3, "src/app/api/v1/images/upscale/route.ts": 1, "src/app/api/v1/messages/count_tokens/route.ts": 1, @@ -32,8 +36,9 @@ const EXPECTED: Record> = { "src/app/api/v1/providers/[provider]/images/generations/route.ts": 1, "src/app/api/v1/rerank/route.ts": 2, "src/app/api/v1/search/route.ts": 2, + "src/app/api/v1/segment/route.ts": 1, "src/app/api/v1/session-leases/route.ts": 1, - "src/app/api/v1/videos/generations/route.ts": 3, + "src/app/api/v1/videos/generations/route.ts": 2, "src/app/api/v1/web/fetch/route.ts": 1, "src/lib/embeddings/service.ts": 2, "src/lib/memory/embedding/index.ts": 1, @@ -49,6 +54,7 @@ const EXPECTED: Record> = { "open-sse/handlers/chatCore/cliproxyapiCredentials.ts": 1, "open-sse/handlers/imageGeneration.ts": 1, "open-sse/handlers/imageGeneration/providers/chatgptWeb.ts": 1, + "open-sse/handlers/imageGeneration/providers/geminiWeb.ts": 1, "open-sse/handlers/videoGeneration.ts": 1, "open-sse/services/compression/eval/executorModelClient.ts": 1, "src/lib/compression/judgeModelClient.ts": 1, @@ -57,6 +63,7 @@ const EXPECTED: Record> = { connection: { "open-sse/handlers/autoComboCandidates.ts": 1, "open-sse/handlers/chatCore.ts": 2, + "open-sse/handlers/cursorCliProxy.ts": 1, "open-sse/services/alibabaFreeTier.ts": 1, "open-sse/services/alibabaFreeTierQuotaFetcher.ts": 1, "open-sse/services/combo/providerWildcard.ts": 1, @@ -149,6 +156,7 @@ const CLASSIFICATION: Record> = { "open-sse/handlers/chatCore/cliproxyapiCredentials.ts": "A", "open-sse/handlers/imageGeneration.ts": "B", "open-sse/handlers/imageGeneration/providers/chatgptWeb.ts": "B", + "open-sse/handlers/imageGeneration/providers/geminiWeb.ts": "B", "open-sse/handlers/videoGeneration.ts": "B", "open-sse/services/compression/eval/executorModelClient.ts": "B", "src/lib/compression/judgeModelClient.ts": "B", diff --git a/tests/unit/health-root-public-liveness.test.ts b/tests/unit/health-root-public-liveness.test.ts new file mode 100644 index 0000000000..c8373d9180 --- /dev/null +++ b/tests/unit/health-root-public-liveness.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { isPublicApiRoute } from "../../src/shared/constants/publicApiRoutes.ts"; +import { GET } from "../../src/app/api/health/route.ts"; + +// Without a root /api/health route, the path fell through to the /api/* catch-all and the +// management-auth boundary answered first: an unauthenticated probe got a 401, the same answer +// a wrong key returns. These assertions fail against the base — the route does not exist and +// isPublicApiRoute("/api/health") is false. +describe("GET /api/health is a public liveness probe", () => { + it("is reachable without a key, for read methods only", () => { + assert.equal(isPublicApiRoute("/api/health", "GET"), true); + assert.equal(isPublicApiRoute("/api/health", "HEAD"), true); + assert.equal(isPublicApiRoute("/api/health", "POST"), false); + }); + + it("does not open the rest of the health subtree", () => { + // A prefix entry would have exposed this one, which is authenticated today. + assert.equal(isPublicApiRoute("/api/health/degradation", "GET"), false); + assert.equal(isPublicApiRoute("/api/healthzzz", "GET"), false); + }); + + it("is reachable with a trailing slash, like the other exact-match public routes", () => { + // getRequestPathname() (src/shared/utils/apiAuth.ts) does not strip a trailing slash, + // unlike classify.ts's normalizePathname() — a raw Set.has() lookup would miss "/api/health/" + // even though it is the same probe. Same trailing-slash tolerance as PUBLIC_CLOUD_API_ROUTES. + assert.equal(isPublicApiRoute("/api/health/", "GET"), true); + }); + + it("answers 200 with the minimum an orchestrator needs", async () => { + const response = await GET(); + const body = (await response.json()) as Record; + + assert.equal(response.status, 200); + assert.equal(body.status, "ok"); + assert.equal(typeof body.timestamp, "string"); + + // Nothing that should not be public on an exposed instance. + for (const leak of ["version", "uptime", "memoryUsage", "system", "nodeVersion"]) { + assert.equal(leak in body, false, `${leak} must not be exposed without a key`); + } + }); +}); diff --git a/tests/unit/i18n-disabled-not-person-with-disability.test.ts b/tests/unit/i18n-disabled-not-person-with-disability.test.ts new file mode 100644 index 0000000000..19acc194c2 --- /dev/null +++ b/tests/unit/i18n-disabled-not-person-with-disability.test.ts @@ -0,0 +1,99 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +// Regression guard for #10812: several locales rendered the *status* "Disabled" +// with the noun for a person who has a disability (ja 障害者, es Discapacitado, +// hi विकलांग, …). It is wrong in context and, for a status badge on a provider +// row, needlessly offensive. +// +// The glossary gate (scripts/i18n/glossary/.json) already enforces this +// for ko/zh-CN/zh-TW, but it only runs for locales that have a glossary file. +// This test covers every locale in the catalog, so a machine-translation pass +// cannot reintroduce the term in an ungated language. + +const messagesDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../src/i18n/messages" +); + +// Nouns meaning "a person with a disability". None of these is ever a correct +// rendering of the "Disabled" status, so they are checked against the value of +// every key whose English source is "Disable"/"Disabled". +const PERSON_WITH_DISABILITY_TERMS = [ + "障害者", // ja + "장애인", // ko + "残疾", // zh-CN + "殘疾", // zh-TW + "残障", // zh-CN + "殘障", // zh-TW + "discapacitad", // es + "minusvál", // es + "deficiente físic", // pt + "handicapé", // fr + "инвалид", // ru + "інвалід", // uk + "विकलांग", // hi + "వికలాంగ", // te + "معذور", // ur + "معاق", // ar + "niepełnospraw", // pl + "gehandicapt", // nl + "khuyết tật", // vi + "ผู้พิการ", // th + "נכה", // he +]; + +type Json = string | number | boolean | null | Json[] | { [k: string]: Json }; + +function flatten(value: Json, prefix = "", out = new Map()) { + if (typeof value === "string") { + out.set(prefix, value); + } else if (value && typeof value === "object" && !Array.isArray(value)) { + for (const [k, v] of Object.entries(value)) { + flatten(v as Json, prefix ? `${prefix}.${k}` : k, out); + } + } + return out; +} + +function load(locale: string) { + return flatten(JSON.parse(readFileSync(path.join(messagesDir, `${locale}.json`), "utf8"))); +} + +test("no locale renders the Disabled status as a person with a disability (#10812)", () => { + const en = load("en"); + const disabledKeys = [...en.entries()] + .filter( + ([, v]) => v.toLowerCase().replace(/\.$/, "") === "disabled" || v.toLowerCase() === "disable" + ) + .map(([k]) => k); + + assert.ok(disabledKeys.length > 0, "expected the en catalog to define Disable/Disabled keys"); + + const locales = readdirSync(messagesDir) + .filter((f) => f.endsWith(".json")) + .map((f) => f.slice(0, -".json".length)) + .filter((l) => l !== "en"); + + const violations: string[] = []; + for (const locale of locales) { + const messages = load(locale); + for (const key of disabledKeys) { + const value = messages.get(key); + if (!value) continue; + const hit = PERSON_WITH_DISABILITY_TERMS.find((term) => + value.toLowerCase().includes(term.toLowerCase()) + ); + if (hit) violations.push(`${locale} ${key} = ${JSON.stringify(value)} (contains ${hit})`); + } + } + + assert.deepEqual( + violations, + [], + `Disabled status mistranslated as a person with a disability:\n ${violations.join("\n ")}` + ); +}); diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index d525e60d4d..49709a13c3 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -2026,3 +2026,57 @@ test("handleImageGeneration (codex) forwards size and maps GPT-Image quality to globalThis.fetch = originalFetch; } }); + +// #8307 — some ChatGPT accounts can run Codex but lack entitlement for the specific +// requested image model, and the upstream 400 for that exact case is retryable on a +// sibling account: executeImageWithCredentialFallback (route.ts) already retries on +// this signal when the handler marks the failure `retryable: true` — mirroring the +// existing 401 auto-rotate path, no new retry loop needed in the handler itself. +test("handleImageGeneration (codex) marks the ChatGPT-account model-access 400 as retryable", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + error: { + message: + "The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account.", + }, + }), + { status: 400, headers: { "content-type": "application/json" } } + ); + + try { + const result = await handleImageGeneration({ + body: { model: "codex/gpt-5.6-sol", prompt: "kitten" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.equal(result.success, false); + assert.equal(result.status, 400); + assert.equal(result.retryable, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration (codex) does not mark an ordinary 400 as retryable", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ error: { message: "Invalid prompt" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + + try { + const result = await handleImageGeneration({ + body: { model: "codex/gpt-5.6-sol", prompt: "kitten" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.equal(result.success, false); + assert.equal(result.status, 400); + assert.equal(result.retryable, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/instrumentation-warm-catalog-cache.test.ts b/tests/unit/instrumentation-warm-catalog-cache.test.ts index 5f73283249..84fa161e78 100644 --- a/tests/unit/instrumentation-warm-catalog-cache.test.ts +++ b/tests/unit/instrumentation-warm-catalog-cache.test.ts @@ -70,10 +70,15 @@ test.after(async () => { const REAL_FETCH = globalThis.fetch; let fetchCallCount = 0; +function isOpenRouterCatalogUrl(input: RequestInfo | URL): boolean { + const url = String(input instanceof Request ? input.url : input); + return url.includes("openrouter.ai"); +} + function installFakeOpenRouterFetch(): void { fetchCallCount = 0; - globalThis.fetch = (async () => { - fetchCallCount++; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (isOpenRouterCatalogUrl(input)) fetchCallCount++; return new Response(JSON.stringify({ data: [{ id: "test/fake-model", architecture: {} }] }), { status: 200, headers: { "content-type": "application/json" }, @@ -83,8 +88,8 @@ function installFakeOpenRouterFetch(): void { function installFailingOpenRouterFetch(): void { fetchCallCount = 0; - globalThis.fetch = (async () => { - fetchCallCount++; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (isOpenRouterCatalogUrl(input)) fetchCallCount++; throw new Error("simulated OpenRouter network failure"); }) as typeof fetch; } diff --git a/tests/unit/kimi-coding-billing-ui.test.ts b/tests/unit/kimi-coding-billing-ui.test.ts new file mode 100644 index 0000000000..985ba05205 --- /dev/null +++ b/tests/unit/kimi-coding-billing-ui.test.ts @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildKimiBillingCardRows, KIMI_CODE_ADDITIONAL_CREDITS_URL, sanitizeKimiBillingStatus } = + await import("../../src/shared/utils/kimiBilling.ts"); +const { isKimiBillingStatus, isProviderBillingProvider, sanitizeProviderBillingStatus } = + await import("../../src/shared/utils/providerBilling.ts"); +const { PROVIDER_LABEL } = + await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/constants.ts"); +const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + +const baseBilling = { + currency: "CNY", + extraUsageStatus: "unavailable" as const, + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, +}; + +test("Kimi billing rows show the real Extra Usage status when the wallet is unavailable", () => { + const rows = buildKimiBillingCardRows(baseBilling, "en-US"); + assert.deepEqual(rows, [ + { kind: "status", label: "Extra Usage", value: "Unavailable" }, + { + kind: "link", + label: "Additional Credits", + href: KIMI_CODE_ADDITIONAL_CREDITS_URL, + target: "_blank", + rel: "noreferrer noopener", + }, + ]); +}); + +test("Kimi billing rows show balance, wallet status, monthly spend, cap and buy link", () => { + const rows = buildKimiBillingCardRows( + { + ...baseBilling, + extraCreditsMinorUnits: 1234, + monthlyUsedMinorUnits: 250, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 5000, + extraUsageStatus: "enabled", + }, + "en-US" + ); + + assert.deepEqual(rows, [ + { kind: "balance", label: "Extra Usage Credits", value: "CN¥12.34" }, + { kind: "status", label: "Extra Usage", value: "Enabled" }, + { kind: "status", label: "Used this month", value: "CN¥2.50" }, + { kind: "status", label: "Monthly limit", value: "CN¥50.00" }, + { + kind: "link", + label: "Additional Credits", + href: KIMI_CODE_ADDITIONAL_CREDITS_URL, + target: "_blank", + rel: "noreferrer noopener", + }, + ]); +}); + +test("Kimi monthly cap displays Unlimited when disabled or zero", () => { + for (const billing of [ + { ...baseBilling, extraCreditsMinorUnits: 0, monthlyLimitEnabled: false }, + { + ...baseBilling, + extraCreditsMinorUnits: 0, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 0, + }, + ]) { + const row = buildKimiBillingCardRows(billing, "en-US").find( + (candidate) => candidate.kind === "status" && candidate.label === "Monthly limit" + ); + assert.deepEqual(row, { kind: "status", label: "Monthly limit", value: "Unlimited" }); + } +}); + +test("Kimi billing labels support localized translation fallbacks", () => { + const translate = (key: string, fallback: string) => + ({ + kimiExtraUsageCredits: "加油包余额", + kimiExtraUsage: "额度加油包", + kimiExtraUsageEnabled: "已开启", + kimiExtraUsageDisabled: "已关闭", + kimiExtraUsageFrozen: "已冻结", + kimiExtraUsageUnavailable: "不可用", + kimiMonthlyUsed: "本月已用", + kimiMonthlyLimit: "每月限额", + kimiMonthlyLimitUnlimited: "无限制", + kimiAdditionalCredits: "充值加油包", + })[key] ?? fallback; + + assert.deepEqual( + buildKimiBillingCardRows( + { + ...baseBilling, + extraCreditsMinorUnits: 0, + monthlyLimitEnabled: false, + extraUsageStatus: "disabled", + }, + "zh-CN", + translate + ), + [ + { kind: "balance", label: "加油包余额", value: "¥0.00" }, + { kind: "status", label: "额度加油包", value: "已关闭" }, + { kind: "status", label: "每月限额", value: "无限制" }, + { + kind: "link", + label: "充值加油包", + href: KIMI_CODE_ADDITIONAL_CREDITS_URL, + target: "_blank", + rel: "noreferrer noopener", + }, + ] + ); +}); + +test("Kimi billing sanitizer strips private fields and rejects forged public contracts", () => { + const billing = sanitizeKimiBillingStatus({ + currency: "cny", + extraCreditsMinorUnits: 0, + monthlyUsedMinorUnits: 250, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 5000, + extraUsageStatus: "disabled", + paymentMethodId: "secret", + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + rawBody: "secret", + }); + + assert.deepEqual(billing, { + currency: "CNY", + extraCreditsMinorUnits: 0, + monthlyUsedMinorUnits: 250, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 5000, + extraUsageStatus: "disabled", + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + }); + assert.equal(buildKimiBillingCardRows(billing!, "zh-CN")[0]?.value, "¥0.00"); + assert.equal(isKimiBillingStatus(billing!), true); + assert.deepEqual(sanitizeProviderBillingStatus(billing), billing); + + for (const forged of [ + { ...baseBilling, currency: "US