diff --git a/.env.example b/.env.example index 4a0198dc28..0ca0ae476f 100644 --- a/.env.example +++ b/.env.example @@ -1425,6 +1425,12 @@ APP_LOG_TO_FILE=true # NANOBANANA_POLL_TIMEOUT_MS=120000 # Max wait for job completion (default: 120s) # NANOBANANA_POLL_INTERVAL_MS=2500 # Poll frequency (default: 2.5s) +# ── Microsoft Designer Web (Image Generation) ── +# Polling config for the microsoft-designer-web submit-then-poll image job. +# Used by: open-sse/handlers/imageGeneration/providers/designerWeb.ts +# DESIGNER_WEB_POLL_TIMEOUT_MS=60000 # Max wait for job completion (default: 60s) +# DESIGNER_WEB_POLL_INTERVAL_MS=2000 # Poll frequency (default: 2s) + # ── AWS Bedrock (Kiro / Audio) ── # Region used to construct AWS Bedrock endpoints. Used by: # src/lib/providers/validation.ts and open-sse/handlers/audioSpeech.ts. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 917ca860aa..d97161e470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -519,7 +519,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} - name: Fetch base branch - run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1 + run: git fetch --no-tags origin "${GITHUB_BASE_REF}" - name: Validate source changes include tests run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md # Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests. diff --git a/AGENTS.md b/AGENTS.md index 42209a90d0..0c8d27008a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,12 +3,12 @@ ## Project Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support -with **250 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, +with **259 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra, SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more) with **MCP Server** (94 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. -> **Live counts (v3.8.47)**: providers 250 · MCP tools 94 · MCP scopes 30 · A2A skills 6 · +> **Live counts (v3.8.49)**: providers 259 · MCP tools 94 · MCP scopes 30 · A2A skills 6 · > open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 · > DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 · > i18n locales 42. **Refresh with `npm run check:docs-all`.** diff --git a/CLAUDE.md b/CLAUDE.md index 65100f4fc4..82e30fb40d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit ## Project at a Glance -**OmniRoute** — unified AI proxy/router. One endpoint, 250 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 259 LLM providers, auto-fallback. | Layer | Location | Purpose | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/README.md b/README.md index 0ccfa90a47..528ca049d7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # 🚀 OmniRoute — The Free AI Gateway -### Never stop coding. Connect every AI tool to **250 providers** — **90+ free** — through one endpoint. +### Never stop coding. Connect every AI tool to **259 providers** — **90+ free** — through one endpoint. **Plug Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini. Auto-fallback.**
@@ -31,8 +31,8 @@
-[![250 AI Providers](https://img.shields.io/badge/250-AI_Providers-6C5CE7?style=for-the-badge)](#-250-ai-providers--90-free) -[![90+ Free](https://img.shields.io/badge/90%2B-Free_Tiers-00B894?style=for-the-badge)](#-250-ai-providers--90-free) +[![251 AI Providers](https://img.shields.io/badge/251-AI_Providers-6C5CE7?style=for-the-badge)](#-251-ai-providers--90-free) +[![90+ Free](https://img.shields.io/badge/90%2B-Free_Tiers-00B894?style=for-the-badge)](#-251-ai-providers--90-free) [![1.6B Free Tokens/mo](https://img.shields.io/badge/1.6B-Free_Tokens%2Fmo-00B894?style=for-the-badge)](docs/reference/FREE_TIERS.md) [![Token Savings](https://img.shields.io/badge/up_to_95%25-Token_Savings-E17055?style=for-the-badge)](#%EF%B8%8F-save-1595-tokens--automatically) [![18 Strategies](https://img.shields.io/badge/18-Routing_Strategies-0984E3?style=for-the-badge)](#-combos--the-flagship) @@ -42,13 +42,13 @@ ### 💬 Join the community -[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/EkzRkpzKYt) +[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/U47eFqAXCn) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/omnirouteOficial) [![WhatsApp Global](https://img.shields.io/badge/WhatsApp_Global-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) -[![WhatsApp Brasil](https://img.shields.io/badge/WhatsApp_Brasil-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI) +[![WhatsApp Brasil](https://img.shields.io/badge/WhatsApp_Brasil-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz) [![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online) -**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/EkzRkpzKYt) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 Brasil](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)** +**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/U47eFqAXCn) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 Brasil](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)**
@@ -61,7 +61,7 @@ ![Docker Pulls](https://img.shields.io/docker/pulls/diegosouzapw/omniroute?label=docker%20pulls&logo=docker&color=2496ED) ![Electron Downloads](https://img.shields.io/github/downloads/diegosouzapw/omniroute/total?style=flat&label=electron%20downloads&logo=electron&color=47848F) -[**🚀 Quick Start**](#-quick-start) • [**🎯 Combos**](#-combos--the-flagship) • [**🌐 Providers**](#-250-ai-providers--90-free) • [**🔌 CLI & MCP**](#-full-cli--a2a--mcp) • [**🗜️ Compression**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 Website**](https://omniroute.online) +[**🚀 Quick Start**](#-quick-start) • [**🎯 Combos**](#-combos--the-flagship) • [**🌐 Providers**](#-251-ai-providers--90-free) • [**🔌 CLI & MCP**](#-full-cli--a2a--mcp) • [**🗜️ Compression**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 Website**](https://omniroute.online) [💥 The Promise](#-the-promise) • [🤔 Why](#-why-omniroute) • [🏆 What Sets Apart](#-what-sets-omniroute-apart) • [🤖 Compatible CLIs](#-compatible-clis--coding-agents) • [🖥️ Where It Runs](#%EF%B8%8F-where-omniroute-runs--anywhere) • [🔒 Private](#-private--local-first) • [🎬 In Action](#-omniroute-in-action) • [📚 Explore More](#-explore-more) • [📧 Support](#-support--community) @@ -149,11 +149,11 @@ -> One endpoint. **250 providers.** Never stop building — and let OmniRoute pick the cheapest one that works. +> One endpoint. **259 providers.** Never stop building — and let OmniRoute pick the cheapest one that works. - + @@ -186,24 +186,7 @@
-``` -┌──────────────────────────────────────────────────────────┐ -│ Your IDE / CLI (Claude Code, Cursor, Cline…) │ -└─────────────────────────┬──────────────────────────────────┘ - │ http://localhost:20128/v1 - ▼ -┌──────────────────────────────────────────────────────────┐ -│ OmniRoute — Smart Router │ -│ RTK + Caveman compression · 18 routing strategies │ -│ Circuit breakers · TLS stealth · MCP · A2A · Guardrails │ -└─────────────────────────┬──────────────────────────────────┘ - ┌─────────────┬────┴────────┬─────────────┐ - ▼ Tier 1 ▼ Tier 2 ▼ Tier 3 ▼ Tier 4 - SUBSCRIPTION API KEY CHEAP FREE - Claude Code, DeepSeek, GLM $0.5, Kiro, Qoder, - Codex, Copilot Groq, xAI MiniMax $0.2 Pollinations - quota out? ───▶ budget hit? ─▶ budget hit? ─▶ always on -``` +OmniRoute request flow: your IDE or CLI (Claude Code, Cursor, Cline…) calls one local endpoint (http://localhost:20128/v1); the OmniRoute Smart Router (RTK + Caveman compression, 18 routing strategies, circuit breakers, TLS stealth, MCP, A2A, guardrails) auto-falls back across 4 provider tiers — Tier 1 Subscription (Claude Code, Codex, Copilot), quota out? Tier 2 API Key (DeepSeek, Groq, xAI), budget hit? Tier 3 Cheap (GLM $0.5, MiniMax $0.2), budget hit? Tier 4 Free (Kiro, Qoder, Pollinations) — always on.
@@ -314,7 +297,7 @@ Result: 4 layers of fallback = zero downtime | Feature | OmniRoute | Other routers | | -------------------------------------- | ------------------------------------------------------------------- | ------------- | -| 🌐 Providers | **250** | 20–100 | +| 🌐 Providers | **251** | 20–100 | | 🆓 Free providers | **90+ (11 free forever)** | 1–5 | | 🔀 Routing strategies | **18** (priority, weighted, cost-optimized, context-relay, fusion…) | 1–3 | | 🗜️ Token compression | **RTK + Caveman stacked (15–95%)** | None / 20–40% | @@ -395,11 +378,11 @@ Result: 4 layers of fallback = zero downtime
-# 🌐 250 AI Providers — 90+ Free +# 🌐 251 AI Providers — 90+ Free
-> The most complete catalog of any open-source router: **250 providers**, **90+ with a free tier**, **11 free forever**. +> The most complete catalog of any open-source router: **259 providers**, **90+ with a free tier**, **11 free forever**.
@@ -907,7 +890,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo **Will I be charged by OmniRoute?** No — it's free, open-source software on your machine. You only pay paid providers directly. OmniRoute has no billing system. **Are FREE providers really unlimited?** Mostly — Qoder, Pollinations, LongCat, and Cloudflare are free with no per-account credit cap. Kiro is free too but capped at ~50 credits/month per account. Stack multiple free providers in a combo and auto-fallback keeps you serving for $0. **Will compression hurt quality?** No — it only compresses the **input**; code, URLs, JSON are always protected. -**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 250 providers. +**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 259 providers. 📖 [User Guide](docs/guides/USER_GUIDE.md) · [API Reference](docs/reference/API_REFERENCE.md) · [Environment Config](docs/reference/ENVIRONMENT.md) diff --git a/bin/cli/commands/dashboard.mjs b/bin/cli/commands/dashboard.mjs index ff7d8c7b01..44d2da1df9 100644 --- a/bin/cli/commands/dashboard.mjs +++ b/bin/cli/commands/dashboard.mjs @@ -1,17 +1,22 @@ import { execFile } from "node:child_process"; import { t } from "../i18n.mjs"; +function parsePort(value, fallback) { + const parsed = parseInt(String(value), 10); + return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; +} + export function registerDashboard(program) { program .command("dashboard") .description(t("dashboard.description")) .option("--url", t("dashboard.urlOnly")) - .option("--port ", "Port the server is running on", "20128") + .option("--port ", "Port the server is running on") .option("--tui", t("dashboard.tui") || "Open interactive TUI dashboard (terminal UI)") .action(async (opts, cmd) => { if (opts.tui) { const globalOpts = cmd.optsWithGlobals(); - const port = opts.port ? parseInt(String(opts.port), 10) : 20128; + const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128); const baseUrl = globalOpts.baseUrl ?? `http://localhost:${port}`; const apiKey = globalOpts.apiKey ?? null; const { startInteractiveTui } = await import("../tui/Dashboard.jsx"); @@ -24,7 +29,7 @@ export function registerDashboard(program) { } export async function runDashboardCommand(opts = {}) { - const port = opts.port ? parseInt(String(opts.port), 10) : 20128; + const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128); const dashboardUrl = `http://localhost:${port}`; if (opts.url) { diff --git a/bin/cli/utils/versionFastPath.mjs b/bin/cli/utils/versionFastPath.mjs new file mode 100644 index 0000000000..13103b6309 --- /dev/null +++ b/bin/cli/utils/versionFastPath.mjs @@ -0,0 +1,25 @@ +/** + * Decide whether a CLI invocation is a bare `--version`/`-V` query that should + * short-circuit BEFORE the runtime polyfill import, env-file loading, and + * Commander's command registration (~70 command modules) are loaded. + * + * Scope is intentionally narrow — only a single, unambiguous `--version`/`-V` + * argument fast-paths. Anything else (extra args, a subcommand, `--help`, + * global options like `--lang`/`--output` alongside it) falls through to the + * normal Commander flow. Unlike `--version`, OmniRoute's `--help` output is + * generated dynamically from every registered subcommand, so skipping + * registration would change (truncate) the help text — that flag is + * deliberately NOT fast-pathed here. + * + * Mirrors the intent of upstream 9router PR #2414 (fast-path help/version + * before expensive self-heal hooks), adapted to OmniRoute's Commander-based + * CLI where the equivalent expensive work is eager command registration + * rather than npm-install-based runtime self-healing. + * + * @param {string[]} argv - process.argv (node + script + args). + * @returns {boolean} + */ +export function isVersionFastPath(argv) { + const args = Array.isArray(argv) ? argv.slice(2) : []; + return args.length === 1 && (args[0] === "--version" || args[0] === "-V"); +} diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index e1ef7b0e9a..4d6720e097 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -4,6 +4,9 @@ * OmniRoute CLI entry point. * * Special bypasses (handled before Commander): + * --version / -V (alone) Fast-path: print the version and exit, skipping the + * tsx/esm + polyfill imports, env-file loading, and + * Commander's ~70-command registration entirely. * --mcp Start MCP server over stdio * reset-encrypted-columns Recovery tool for broken encrypted credentials * reset-password Reset the admin/management password @@ -19,6 +22,26 @@ import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat. import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs"; import { getDefaultDataDir } from "./cli/data-dir.mjs"; import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs"; +import { isVersionFastPath } from "./cli/utils/versionFastPath.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = join(__dirname, ".."); + +// Fast-path a bare `--version`/`-V` query BEFORE the tsx/esm registration, the +// polyfill import, env-file loading, or Commander's command registration (~70 +// modules — DB, providers, OAuth, etc.) run. None of that work is needed to answer +// "what version is this" — mirrors upstream 9router PR #2414 (fast-path help/version +// ahead of expensive self-heal hooks), adapted to OmniRoute's Commander CLI where the +// equivalent expensive work is eager command registration rather than npm-install-based +// runtime self-healing. `--help` is intentionally NOT fast-pathed here: its output is +// generated dynamically from every registered subcommand, so skipping registration +// would truncate the help text instead of just speeding it up. +if (isVersionFastPath(process.argv)) { + const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); + console.log(pkg.version); + process.exit(0); +} // Register tsx so dynamic imports of .ts source files (referenced as .js per // TypeScript conventions) resolve correctly. The build never emits .js for @@ -26,10 +49,6 @@ import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs"; await import("tsx/esm"); await import("../open-sse/utils/setupPolyfill.ts"); -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const ROOT = join(__dirname, ".."); - // MCP stdio transport uses stdout exclusively for JSON-RPC messages. // Redirect console.log/warn to stderr early (before loadEnvFile and DB init) // so no startup output corrupts the protocol. diff --git a/changelog.d/features/6540-hidepaid-ui-selects.md b/changelog.d/features/6540-hidepaid-ui-selects.md new file mode 100644 index 0000000000..ff4bbdd77f --- /dev/null +++ b/changelog.d/features/6540-hidepaid-ui-selects.md @@ -0,0 +1 @@ +- **feat(dashboard):** Replace free-text model inputs in the Routing (web search route), Combo Defaults (handoff model), and Background Degradation tabs with a `hidePaidModels`-aware `ModelSelectField`, add a fail-open "paid-only pattern" warning to the per-model routing rule pattern field, and reject paid-only model targets at save time on `PATCH /api/settings`, `PATCH /api/settings/combo-defaults`, and `PUT /api/settings/background-degradation` when `hidePaidModels` is on ([#6540](https://github.com/diegosouzapw/OmniRoute/issues/6540)) diff --git a/changelog.d/features/6653-deepinfra-video-provider.md b/changelog.d/features/6653-deepinfra-video-provider.md new file mode 100644 index 0000000000..8957b95d28 --- /dev/null +++ b/changelog.d/features/6653-deepinfra-video-provider.md @@ -0,0 +1 @@ +- feat(sse): add DeepInfra as a video-generation provider via its native synchronous inference endpoint (#6653) diff --git a/changelog.d/features/6654-freepik-pikaso-image-provider.md b/changelog.d/features/6654-freepik-pikaso-image-provider.md new file mode 100644 index 0000000000..9804299d2d --- /dev/null +++ b/changelog.d/features/6654-freepik-pikaso-image-provider.md @@ -0,0 +1 @@ +- feat(providers): add Freepik (Magnific Mystic) API-key image generation provider — async submit/poll flow with realism/fluid/zen/flexible/super_real/editorial_portraits models (#6654) diff --git a/changelog.d/features/6655-revai-stt-provider.md b/changelog.d/features/6655-revai-stt-provider.md new file mode 100644 index 0000000000..5677687649 --- /dev/null +++ b/changelog.d/features/6655-revai-stt-provider.md @@ -0,0 +1 @@ +- feat(providers): add Rev AI speech-to-text provider with async job upload/poll/transcript flow (#6655) diff --git a/changelog.d/features/6656-segmind-image-video-provider.md b/changelog.d/features/6656-segmind-image-video-provider.md new file mode 100644 index 0000000000..26e7ecbb90 --- /dev/null +++ b/changelog.d/features/6656-segmind-image-video-provider.md @@ -0,0 +1 @@ +- **feat(providers):** add **Segmind** as an image + video generation provider — `x-api-key` auth against `POST https://api.segmind.com/v1/{model}`, with a curated starter model list (Flux, Stable Diffusion XL/3.5, Kandinsky for image; Wan, Hunyuan, LTX, Kling for video) (#6656). diff --git a/changelog.d/features/6657-gladia-stt-provider.md b/changelog.d/features/6657-gladia-stt-provider.md new file mode 100644 index 0000000000..f143dec103 --- /dev/null +++ b/changelog.d/features/6657-gladia-stt-provider.md @@ -0,0 +1 @@ +- feat(providers): add Gladia as an async speech-to-text provider (#6657) diff --git a/changelog.d/features/6658-novita-video-gen-provider.md b/changelog.d/features/6658-novita-video-gen-provider.md new file mode 100644 index 0000000000..9050c94327 --- /dev/null +++ b/changelog.d/features/6658-novita-video-gen-provider.md @@ -0,0 +1 @@ +- feat(video): add Novita AI as a video-generation provider (Wan/Kling async submit-poll) (#6658) diff --git a/changelog.d/features/6660-mixedbread-embeddings-provider.md b/changelog.d/features/6660-mixedbread-embeddings-provider.md new file mode 100644 index 0000000000..219b58b39f --- /dev/null +++ b/changelog.d/features/6660-mixedbread-embeddings-provider.md @@ -0,0 +1 @@ +- feat(providers): add Mixedbread AI as an embeddings provider (`mxbai-embed-large-v1`, `mxbai-embed-2d-large-v1`, free tier) (#6660) diff --git a/changelog.d/features/6666-felo-chat-aggregator-provider.md b/changelog.d/features/6666-felo-chat-aggregator-provider.md new file mode 100644 index 0000000000..bea5f71f5a --- /dev/null +++ b/changelog.d/features/6666-felo-chat-aggregator-provider.md @@ -0,0 +1 @@ +- **feat(providers):** add Felo (felo.ai) as a free, no-signup, no-API-key chat/search-agent aggregator provider (`felo-web`) — joins the existing `-web` family (DuckDuckGo AI Chat, Blackbox, etc). Five models (`felo-chat`, `felo-search`, `felo-scholar`, `felo-social`, `felo-document`) map to Felo's search categories; the executor opens a search thread then translates Felo's bespoke SSE stream into OpenAI-compatible chunks (#6666). diff --git a/changelog.d/features/6668-edgetts-audio-tts-provider.md b/changelog.d/features/6668-edgetts-audio-tts-provider.md new file mode 100644 index 0000000000..28b0f9995b --- /dev/null +++ b/changelog.d/features/6668-edgetts-audio-tts-provider.md @@ -0,0 +1 @@ +- **feat(sse):** add EdgeTTS (Microsoft Edge "Read Aloud") as a free, no-API-key `audio-tts` provider — the first WebSocket-transport speech provider, with per-client-IP rate limiting. (#6668) diff --git a/changelog.d/features/6670-freetheai-gateway-provider.md b/changelog.d/features/6670-freetheai-gateway-provider.md new file mode 100644 index 0000000000..85e087a7a5 --- /dev/null +++ b/changelog.d/features/6670-freetheai-gateway-provider.md @@ -0,0 +1 @@ +- feat(providers): add FreeTheAi as an OpenAI-compatible gateway provider with a free Discord-signup tier (#6670) diff --git a/changelog.d/features/6672-microsoftdesigner-image-provider.md b/changelog.d/features/6672-microsoftdesigner-image-provider.md new file mode 100644 index 0000000000..3e425535f4 --- /dev/null +++ b/changelog.d/features/6672-microsoftdesigner-image-provider.md @@ -0,0 +1 @@ +- feat(sse): add Microsoft Designer as an unofficial web-session image provider, reverse-engineered submit-then-poll DallE.ashx flow (#6672) diff --git a/changelog.d/features/6737-vary-accept-encoding.md b/changelog.d/features/6737-vary-accept-encoding.md new file mode 100644 index 0000000000..82ced9ae0a --- /dev/null +++ b/changelog.d/features/6737-vary-accept-encoding.md @@ -0,0 +1 @@ +- **feat(api):** add `Vary: Accept-Encoding` to token-authenticated `/v1*`/`/v1beta*` responses so downstream caches distinguish compressed vs uncompressed variants (RFC 9110 §12.5.5). (thanks @chirag127) diff --git a/changelog.d/features/6758-notion-web-provider.md b/changelog.d/features/6758-notion-web-provider.md new file mode 100644 index 0000000000..c6c94262ab --- /dev/null +++ b/changelog.d/features/6758-notion-web-provider.md @@ -0,0 +1 @@ +- feat(sse): add Notion AI Web (Unofficial/Experimental) cookie-session provider (#6758) diff --git a/changelog.d/features/6760-compression-mode-selector-context-cache.md b/changelog.d/features/6760-compression-mode-selector-context-cache.md new file mode 100644 index 0000000000..ae25386636 --- /dev/null +++ b/changelog.d/features/6760-compression-mode-selector-context-cache.md @@ -0,0 +1 @@ +- **feat(dashboard):** add per-routing-combo compression-mode override to the Compression Combos page under Context & Cache, alongside the existing combo-card quick override. (#6760) diff --git a/changelog.d/features/6771-fusion-preserve-tools-bypass.md b/changelog.d/features/6771-fusion-preserve-tools-bypass.md new file mode 100644 index 0000000000..84215e4c4c --- /dev/null +++ b/changelog.d/features/6771-fusion-preserve-tools-bypass.md @@ -0,0 +1 @@ +- **feat(sse):** preserve `tools`/`tool_choice` for tool-bearing requests through fusion combos — bypass panel synthesis and route straight to the judge with tools intact (#6771 — thanks @chirag127). diff --git a/changelog.d/features/6801-xp-audit-log-retention.md b/changelog.d/features/6801-xp-audit-log-retention.md new file mode 100644 index 0000000000..6847e48cca --- /dev/null +++ b/changelog.d/features/6801-xp-audit-log-retention.md @@ -0,0 +1 @@ +- feat(db): include `xp_audit_log` in the automatic retention/prune cycle, with a configurable `retention.xpAuditLog` setting (#6801) diff --git a/changelog.d/features/6872-relay-routing-fallback-reason-header.md b/changelog.d/features/6872-relay-routing-fallback-reason-header.md new file mode 100644 index 0000000000..85856e2245 --- /dev/null +++ b/changelog.d/features/6872-relay-routing-fallback-reason-header.md @@ -0,0 +1 @@ +- feat(api): add a structured `X-Routing-Fallback-Reason` header to relay routing responses, exposing a stable machine-readable reason code alongside the legacy `X-Routing-Fallback` detail string (#6872) diff --git a/changelog.d/features/6873-model-latency-stats-api.md b/changelog.d/features/6873-model-latency-stats-api.md new file mode 100644 index 0000000000..1b5b0656e5 --- /dev/null +++ b/changelog.d/features/6873-model-latency-stats-api.md @@ -0,0 +1 @@ +- **feat(api):** new **GET /api/usage/model-latency-stats** management endpoint exposes the existing rolling per-provider/model latency aggregate (avg/p50/p95/p99, success rate) already used internally by auto-combo routing — supports `windowHours`/`minSamples`/`maxRows`/`provider`/`model` filters (#6873). diff --git a/changelog.d/features/6880-connection-cache-override.md b/changelog.d/features/6880-connection-cache-override.md new file mode 100644 index 0000000000..0f013bb59f --- /dev/null +++ b/changelog.d/features/6880-connection-cache-override.md @@ -0,0 +1 @@ +- **feat(providers):** let a custom/openai-compatible connection opt into prompt-cache behavior via a per-connection `cache` capability override, unblocking `prompt_cache_key` injection, the compression cache-aware guard, and `cache_control` passthrough for `openai-compatible-chat-`-style connections. (thanks @andrea-kingautomation) diff --git a/changelog.d/features/6915-free-rankings-auth-type-filter.md b/changelog.d/features/6915-free-rankings-auth-type-filter.md new file mode 100644 index 0000000000..782449e384 --- /dev/null +++ b/changelog.d/features/6915-free-rankings-auth-type-filter.md @@ -0,0 +1 @@ +- **feat(dashboard):** add a Type filter (No Signup / OAuth Login / API Key) and an "Easiest first" sort toggle to Free Provider Rankings, so zero-setup NOAUTH providers can be surfaced without eyeballing the Type column. (#6915) diff --git a/changelog.d/features/6928-comfyui-base-url-field.md b/changelog.d/features/6928-comfyui-base-url-field.md new file mode 100644 index 0000000000..f578d96a45 --- /dev/null +++ b/changelog.d/features/6928-comfyui-base-url-field.md @@ -0,0 +1 @@ +- **feat(providers):** expose an editable base-URL field on the ComfyUI connection so Docker-network setups (e.g. `http://comfyui:8188`) work for image, video, and music generation ([#6928](https://github.com/diegosouzapw/OmniRoute/issues/6928)) diff --git a/changelog.d/features/6976-openrouter-embeddings.md b/changelog.d/features/6976-openrouter-embeddings.md new file mode 100644 index 0000000000..1997f3cd5a --- /dev/null +++ b/changelog.d/features/6976-openrouter-embeddings.md @@ -0,0 +1 @@ +- **feat(providers):** refresh the curated OpenRouter embeddings catalog (`open-sse/config/embeddingRegistry.ts`) with the current lineup — `openai/text-embedding-3-small`/`-large`, `qwen/qwen3-embedding-8b`/`-4b`, `baai/bge-m3`, `mistralai/mistral-embed-2312`, `google/gemini-embedding-001` — and fold curated embedding/rerank entries into OpenRouter's live model-discovery response (`src/app/api/providers/[id]/models/route.ts`), additively and deduped by id, so they no longer only appear on the no-config `local_catalog` fallback. OpenRouter serves embeddings via a dedicated `/api/v1/embeddings` endpoint (omitted from `/v1/models`), so the live-discovery success path previously returned chat models only ([#6976](https://github.com/diegosouzapw/OmniRoute/issues/6976)). Regression guard: `tests/unit/openrouter-embeddings-catalog-6976.test.ts`. diff --git a/changelog.d/features/6977-quota-auto-ping.md b/changelog.d/features/6977-quota-auto-ping.md new file mode 100644 index 0000000000..694f4fbf89 --- /dev/null +++ b/changelog.d/features/6977-quota-auto-ping.md @@ -0,0 +1 @@ +- **feat(quota):** Add opt-in auto-ping to keep Codex quota windows warm — per-connection toggle in Settings → AI that sends a tiny request right after a Codex session window resets, so it isn't cold on the next real request ([#6977](https://github.com/diegosouzapw/OmniRoute/issues/6977)) diff --git a/changelog.d/features/7023-optional-enum-null-sentinel.md b/changelog.d/features/7023-optional-enum-null-sentinel.md new file mode 100644 index 0000000000..965cb05307 --- /dev/null +++ b/changelog.d/features/7023-optional-enum-null-sentinel.md @@ -0,0 +1 @@ +- **feat(sse):** Add optional-enum `null`-omission idiom for Responses-API (codex) strict-mode tool schemas, closing the #6951 follow-up ([#7023](https://github.com/diegosouzapw/OmniRoute/issues/7023)) diff --git a/changelog.d/features/7034-x-goog-api-key-client-auth.md b/changelog.d/features/7034-x-goog-api-key-client-auth.md new file mode 100644 index 0000000000..7228d880e6 --- /dev/null +++ b/changelog.d/features/7034-x-goog-api-key-client-auth.md @@ -0,0 +1 @@ +- **feat(auth):** accept the `x-goog-api-key` header for client-facing auth so `gemini-cli` and other `@google/genai`-based clients can use OmniRoute as a native `/v1beta` gateway (#7034 — thanks @QRcode1337). diff --git a/changelog.d/features/7209-kiro-gpt56-family.md b/changelog.d/features/7209-kiro-gpt56-family.md new file mode 100644 index 0000000000..eed9627975 --- /dev/null +++ b/changelog.d/features/7209-kiro-gpt56-family.md @@ -0,0 +1 @@ +- **feat(kiro):** register the GPT-5.6 Sol/Terra/Luna model family (272k context window). (thanks @SemonCat) diff --git a/changelog.d/features/7210-codex-plan-labels.md b/changelog.d/features/7210-codex-plan-labels.md new file mode 100644 index 0000000000..0df8e21855 --- /dev/null +++ b/changelog.d/features/7210-codex-plan-labels.md @@ -0,0 +1 @@ +- **feat(dashboard):** show the Codex subscription plan label in provider connection rows and the quota view, falling back to the plan captured at OAuth import when the live usage endpoint doesn't report one. (thanks @CarmeloCampos) diff --git a/changelog.d/features/7211-reorder-connections-by-availability.md b/changelog.d/features/7211-reorder-connections-by-availability.md new file mode 100644 index 0000000000..54e302a369 --- /dev/null +++ b/changelog.d/features/7211-reorder-connections-by-availability.md @@ -0,0 +1 @@ +- **feat(dashboard):** add a "Reorder" button to provider connections that sorts them by availability (using OmniRoute's connection-cooldown/testStatus model), persisting the new priority order. (thanks @fzrilsh) diff --git a/changelog.d/features/7213-usage-extended-periods.md b/changelog.d/features/7213-usage-extended-periods.md new file mode 100644 index 0000000000..704c08e147 --- /dev/null +++ b/changelog.d/features/7213-usage-extended-periods.md @@ -0,0 +1 @@ +- **feat(dashboard):** add 180D and 365D periods to the Cost Explorer range selector. The new ranges thread through `parseCostRange`/`COST_RANGE_VALUES` and the `getRangeStartIso` handlers in the analytics and requests-by-provider-date usage routes, so cost/usage analytics can be viewed over a half-year and full-year window (#7213) diff --git a/changelog.d/features/7223-github-copilot-claude-native-messages.md b/changelog.d/features/7223-github-copilot-claude-native-messages.md new file mode 100644 index 0000000000..7885ba5596 --- /dev/null +++ b/changelog.d/features/7223-github-copilot-claude-native-messages.md @@ -0,0 +1 @@ +- **feat(sse):** GitHub Copilot Claude models now route through Copilot's native `/v1/messages` endpoint (prompt-cache token counts, no more lossy tool-call round-trip). (thanks @yidecode) diff --git a/changelog.d/features/7228-antigravity-reasoning-effort-overrides.md b/changelog.d/features/7228-antigravity-reasoning-effort-overrides.md new file mode 100644 index 0000000000..a52174b2b1 --- /dev/null +++ b/changelog.d/features/7228-antigravity-reasoning-effort-overrides.md @@ -0,0 +1 @@ +- **feat(mitm):** Antigravity MITM model mappings now support an optional per-model reasoning-effort override (Default/None/Low/Medium/High/XHigh) alongside the destination-model remap. (thanks @trfi) diff --git a/changelog.d/features/7238-xai-grok-imagine-video.md b/changelog.d/features/7238-xai-grok-imagine-video.md new file mode 100644 index 0000000000..9cac2f18b3 --- /dev/null +++ b/changelog.d/features/7238-xai-grok-imagine-video.md @@ -0,0 +1 @@ +- **feat(sse):** add native xAI Grok Imagine video generation provider — `xai/grok-imagine-video` on `/v1/videos/generations` using your own xAI key, instead of only via the kie proxy market. (thanks @anndev-69) diff --git a/changelog.d/features/7241-grok-build-cli-setup.md b/changelog.d/features/7241-grok-build-cli-setup.md new file mode 100644 index 0000000000..1e8c6bee5a --- /dev/null +++ b/changelog.d/features/7241-grok-build-cli-setup.md @@ -0,0 +1 @@ +- **feat(cli):** add Grok Build CLI tool setup — writes a `[model.omniroute]` custom model into `~/.grok/config.toml` and restores your previous default on Reset. (thanks @rixzkiye) diff --git a/changelog.d/features/7246-chenzk-provider.md b/changelog.d/features/7246-chenzk-provider.md new file mode 100644 index 0000000000..cc39d66de6 --- /dev/null +++ b/changelog.d/features/7246-chenzk-provider.md @@ -0,0 +1 @@ +- **feat(provider):** add Chenzk API OpenAI-compatible gateway. (thanks @CahyokPutraDev99) diff --git a/changelog.d/fixes/1253-kiro-sso-cache-clientid.md b/changelog.d/fixes/1253-kiro-sso-cache-clientid.md new file mode 100644 index 0000000000..091d8a223c --- /dev/null +++ b/changelog.d/fixes/1253-kiro-sso-cache-clientid.md @@ -0,0 +1 @@ +- **fix(oauth):** resolve Kiro AWS SSO cache client credentials by matching the token's own `clientId` (including tokens with a direct `clientId` field instead of `clientIdHash`) instead of a region/latest-expiry guess, fixing spurious "Bad credentials" on refresh when multiple stale SSO client registrations are cached (thanks @XCrag). diff --git a/changelog.d/fixes/1904-custom-model-vision-toggle.md b/changelog.d/fixes/1904-custom-model-vision-toggle.md new file mode 100644 index 0000000000..85fd14aff1 --- /dev/null +++ b/changelog.d/fixes/1904-custom-model-vision-toggle.md @@ -0,0 +1 @@ +- **fix(dashboard):** the "Custom Models" add/edit form now has a "Vision capable" toggle so a custom OpenAI-compatible model can be manually flagged as vision-capable when the provider's discovery metadata doesn't report an image input modality (thanks @nguyenphi37) diff --git a/changelog.d/fixes/2057-combo-custom-provider-models.md b/changelog.d/fixes/2057-combo-custom-provider-models.md new file mode 100644 index 0000000000..b6b75b6256 --- /dev/null +++ b/changelog.d/fixes/2057-combo-custom-provider-models.md @@ -0,0 +1 @@ +- **fix(dashboard):** include never-tested custom provider connections in the combo builder's active-provider list so their models load without requiring a manual connection test first. (thanks @fajarbossit) diff --git a/changelog.d/fixes/2482-minimax-image-provider.md b/changelog.d/fixes/2482-minimax-image-provider.md new file mode 100644 index 0000000000..d6fcbbbc4b --- /dev/null +++ b/changelog.d/fixes/2482-minimax-image-provider.md @@ -0,0 +1 @@ +- **fix(providers):** MiniMax Text-to-Image now works — a `minimax` image-generation provider (`minimax-image` format, `image-01`/`image-01-live` models) was registered, since MiniMax previously had entries in the music/audio/video registries but none in the image registry, so any MiniMax image-model request fell through to a 404/unmatched-format response. (thanks @felipeleite) diff --git a/changelog.d/fixes/2540-gpt5-tools-reasoning-effort.md b/changelog.d/fixes/2540-gpt5-tools-reasoning-effort.md new file mode 100644 index 0000000000..bdd460cfcb --- /dev/null +++ b/changelog.d/fixes/2540-gpt5-tools-reasoning-effort.md @@ -0,0 +1 @@ +- **fix(openai):** strip `reasoning_effort`/`reasoning` for GPT-5.x models on the raw `openai` Chat Completions surface when the request carries function `tools` — upstream rejects that combination with HTTP 400 ("Function tools with reasoning_effort are not supported ... Please use /v1/responses instead"), and the dashboard has no `reasoning_effort:"none"` override to work around it client-side — thanks @techsolutionmta diff --git a/changelog.d/fixes/6764-fusion-combo-ref.md b/changelog.d/fixes/6764-fusion-combo-ref.md new file mode 100644 index 0000000000..403336afae --- /dev/null +++ b/changelog.d/fixes/6764-fusion-combo-ref.md @@ -0,0 +1 @@ +- **fix(routing):** fusion combos no longer silently drop `combo-ref` panel members — a referenced combo is now dispatched as one black-box panel voice instead of being dropped (#6764) diff --git a/changelog.d/fixes/6794-electron-turbopack-symlinks.md b/changelog.d/fixes/6794-electron-turbopack-symlinks.md new file mode 100644 index 0000000000..963e35e6bc --- /dev/null +++ b/changelog.d/fixes/6794-electron-turbopack-symlinks.md @@ -0,0 +1 @@ +- **fix(electron): materialize Turbopack hashed-module symlinks during packaging (#6724, #6594)** (#6794 — thanks @huohua-dev). diff --git a/changelog.d/fixes/6916-provider-limits-spacing-local.md b/changelog.d/fixes/6916-provider-limits-spacing-local.md new file mode 100644 index 0000000000..2bfb988ee4 --- /dev/null +++ b/changelog.d/fixes/6916-provider-limits-spacing-local.md @@ -0,0 +1 @@ +- fix(providers): `PROVIDER_LIMITS_SYNC_SPACING_MS` now also throttles local / API-key (Ollama) connections, not just OAuth — spaced between concurrency chunks so a local endpoint isn't hit by a simultaneous refresh burst (#6916) diff --git a/changelog.d/fixes/6953-empty-signature-thinking-block.md b/changelog.d/fixes/6953-empty-signature-thinking-block.md new file mode 100644 index 0000000000..a95324f99a --- /dev/null +++ b/changelog.d/fixes/6953-empty-signature-thinking-block.md @@ -0,0 +1 @@ +- fix(sse): stop forwarding empty-signature thinking blocks verbatim to Anthropic-native legs, which permanently poisoned combo fallback (#6953) diff --git a/changelog.d/fixes/6984-hide-disabled-connections-combos.md b/changelog.d/fixes/6984-hide-disabled-connections-combos.md new file mode 100644 index 0000000000..48d637549b --- /dev/null +++ b/changelog.d/fixes/6984-hide-disabled-connections-combos.md @@ -0,0 +1 @@ +- **fix(dashboard):** the combos builder now hides provider connections the user has explicitly disabled, instead of relying only on stale test-status (#6984 — thanks @attid). diff --git a/changelog.d/fixes/6986-grok-cli-tools-cap.md b/changelog.d/fixes/6986-grok-cli-tools-cap.md new file mode 100644 index 0000000000..8f38f0bc64 --- /dev/null +++ b/changelog.d/fixes/6986-grok-cli-tools-cap.md @@ -0,0 +1 @@ +- **fix(providers):** cap grok-cli tools at 200 per request, matching xAI's cli-chat-proxy limit, and document the non-reasoning capability of grok-build/grok-composer-2.5-fast in the registry (#6986, thanks @gitcommit90) diff --git a/changelog.d/fixes/7049-dashboard-port-env-fallback.md b/changelog.d/fixes/7049-dashboard-port-env-fallback.md new file mode 100644 index 0000000000..b3c51761ea --- /dev/null +++ b/changelog.d/fixes/7049-dashboard-port-env-fallback.md @@ -0,0 +1 @@ +- **fix(cli):** `omniroute dashboard` (no `--port` flag) now respects `PORT` from the environment instead of always opening `localhost:20128`, matching `serve`/`launch` precedence (`--port` > `PORT` env > `20128` default) (#7049 — thanks @kaon0388v1). diff --git a/changelog.d/fixes/7125-onboarding-tiers-layout.md b/changelog.d/fixes/7125-onboarding-tiers-layout.md new file mode 100644 index 0000000000..8bb9f718eb --- /dev/null +++ b/changelog.d/fixes/7125-onboarding-tiers-layout.md @@ -0,0 +1 @@ +- **fix(dashboard):** align onboarding tier descriptions and localize the tier step header and flow copy ([#7125](https://github.com/diegosouzapw/OmniRoute/pull/7125)) — thanks @Wibias diff --git a/changelog.d/fixes/7206-preserve-reasoning-openai-bridge.md b/changelog.d/fixes/7206-preserve-reasoning-openai-bridge.md new file mode 100644 index 0000000000..449071b4c0 --- /dev/null +++ b/changelog.d/fixes/7206-preserve-reasoning-openai-bridge.md @@ -0,0 +1 @@ +- **fix(translator):** preserve Gemini thinking-mode `thought:true` parts as `reasoning_content` instead of leaking them into visible assistant text on the OpenAI request bridge. (thanks @warelik) diff --git a/changelog.d/fixes/7207-openai-projection-gemini-clients.md b/changelog.d/fixes/7207-openai-projection-gemini-clients.md new file mode 100644 index 0000000000..469549c0cc --- /dev/null +++ b/changelog.d/fixes/7207-openai-projection-gemini-clients.md @@ -0,0 +1 @@ +- **fix(translator):** register the missing OpenAI→Gemini response projection so combo-routed OpenAI-native providers no longer leak raw `chat.completion.chunk` shapes to Gemini-format clients. (thanks @warelik) diff --git a/changelog.d/fixes/7208-cli-version-fastpath.md b/changelog.d/fixes/7208-cli-version-fastpath.md new file mode 100644 index 0000000000..8635400667 --- /dev/null +++ b/changelog.d/fixes/7208-cli-version-fastpath.md @@ -0,0 +1 @@ +- **fix(cli):** `omniroute --version` now fast-paths before the tsx/esm + polyfill imports, env-file loading, and Commander's full command registration, cutting local runtime from ~1.5s to ~0.3s. (thanks @Jordannst) diff --git a/changelog.d/fixes/7234-bulk-add-keys-no-overwrite.md b/changelog.d/fixes/7234-bulk-add-keys-no-overwrite.md new file mode 100644 index 0000000000..853a1103d2 --- /dev/null +++ b/changelog.d/fixes/7234-bulk-add-keys-no-overwrite.md @@ -0,0 +1 @@ +- **api:** bulk-add API keys no longer overwrite existing provider connections — a colliding auto- or custom-generated name now gap-fills a free suffix instead of silently replacing a saved connection's key/state. (thanks @asynx6) diff --git a/changelog.d/fixes/7237-vision-compression-authoritative-capability.md b/changelog.d/fixes/7237-vision-compression-authoritative-capability.md new file mode 100644 index 0000000000..8fa0b61e3e --- /dev/null +++ b/changelog.d/fixes/7237-vision-compression-authoritative-capability.md @@ -0,0 +1 @@ +- fix(sse): feed the compression pipeline the authoritative vision capability instead of the conservative model-id heuristic, so vision models absent from the fragment list (e.g. gpt-5.5) no longer have their image_url blocks silently stripped (#7237) diff --git a/changelog.d/fixes/7242-openai-gpt56-responses-routing.md b/changelog.d/fixes/7242-openai-gpt56-responses-routing.md new file mode 100644 index 0000000000..9b4141b817 --- /dev/null +++ b/changelog.d/fixes/7242-openai-gpt56-responses-routing.md @@ -0,0 +1 @@ +- **fix(sse):** route the public OpenAI GPT-5.6 family (`gpt-5.6`, `-sol`, `-terra`, `-luna`) through the Responses API — Chat Completions rejects GPT-5.6 requests that combine function tools with an active `reasoning_effort`. (thanks @Jordannst) diff --git a/changelog.d/fixes/7244-grok-cli-honor-proxy.md b/changelog.d/fixes/7244-grok-cli-honor-proxy.md new file mode 100644 index 0000000000..6a83400d73 --- /dev/null +++ b/changelog.d/fixes/7244-grok-cli-honor-proxy.md @@ -0,0 +1 @@ +- **fix(providers):** honor a configured proxy on Grok Build egress — the grok-cli executor used raw `https.request()` and bypassed the proxy context, leaking the host IP on chat inference and OAuth token refresh. (thanks @ryanngit) diff --git a/changelog.d/fixes/7247-nvidia-nim-catalog.md b/changelog.d/fixes/7247-nvidia-nim-catalog.md new file mode 100644 index 0000000000..9584dcbd97 --- /dev/null +++ b/changelog.d/fixes/7247-nvidia-nim-catalog.md @@ -0,0 +1 @@ +- **fix(nvidia):** expand NIM chat model catalog with newly-observed models. (thanks @spacesky-cell) diff --git a/changelog.d/fixes/7248-claude-bypass-content-reconstruction.md b/changelog.d/fixes/7248-claude-bypass-content-reconstruction.md new file mode 100644 index 0000000000..f55c03398d --- /dev/null +++ b/changelog.d/fixes/7248-claude-bypass-content-reconstruction.md @@ -0,0 +1 @@ +- **fix(sse):** synthetic bypass responses for Claude-format clients no longer drop their content — `mergeChunksToResponse()` now reconstructs the message from streamed content blocks instead of returning an empty array. (thanks @KunN-21) diff --git a/changelog.d/fixes/7249-windows-build-isolation.md b/changelog.d/fixes/7249-windows-build-isolation.md new file mode 100644 index 0000000000..1c2997dc2c --- /dev/null +++ b/changelog.d/fixes/7249-windows-build-isolation.md @@ -0,0 +1 @@ +- **fix(build):** isolate Windows HOME/AppData during next build. (thanks @KunN-21) diff --git a/changelog.d/fixes/7250-provider-model-filter-live-catalog.md b/changelog.d/fixes/7250-provider-model-filter-live-catalog.md new file mode 100644 index 0000000000..b8b3c72266 --- /dev/null +++ b/changelog.d/fixes/7250-provider-model-filter-live-catalog.md @@ -0,0 +1 @@ +- fix(dashboard): providers model-name filter now matches an aggregator's live/synced catalog, not just the static curated registry (#7250) diff --git a/changelog.d/fixes/7255-nonstreaming-gemini-family-projection.md b/changelog.d/fixes/7255-nonstreaming-gemini-family-projection.md new file mode 100644 index 0000000000..8e0ce75dc1 --- /dev/null +++ b/changelog.d/fixes/7255-nonstreaming-gemini-family-projection.md @@ -0,0 +1 @@ +- fix(sse): project non-streaming JSON responses back to the Gemini/Antigravity `{response:{candidates}}` envelope instead of leaking the raw OpenAI `choices[]` shape, so tool calls are no longer dropped for Gemini-family clients on the JSON path (#7255) (thanks @warelik) diff --git a/changelog.d/fixes/7258-zhtw-missing-placeholder.md b/changelog.d/fixes/7258-zhtw-missing-placeholder.md new file mode 100644 index 0000000000..f3b85c0e00 --- /dev/null +++ b/changelog.d/fixes/7258-zhtw-missing-placeholder.md @@ -0,0 +1 @@ +- fix(i18n): treat `__MISSING__:` sync-script placeholders as absent so the EN fallback renders instead of the raw sentinel (#7258) diff --git a/changelog.d/fixes/7265-termux-playwright-static-import.md b/changelog.d/fixes/7265-termux-playwright-static-import.md new file mode 100644 index 0000000000..93eaf69a0a --- /dev/null +++ b/changelog.d/fixes/7265-termux-playwright-static-import.md @@ -0,0 +1 @@ +- fix(sse): lazy-load playwright in claudeTurnstileSolver so unsupported platforms (e.g. Termux/Android) don't crash on boot (#7265) diff --git a/changelog.d/fixes/7266-proxyfetch-caller-abort-log.md b/changelog.d/fixes/7266-proxyfetch-caller-abort-log.md new file mode 100644 index 0000000000..a84b4b9d9b --- /dev/null +++ b/changelog.d/fixes/7266-proxyfetch-caller-abort-log.md @@ -0,0 +1 @@ +- **fix(sse):** stop logging a caller-initiated request abort/timeout as a noisy proxy transport failure in `proxyFetch`. (thanks @TuyulSpam) diff --git a/changelog.d/fixes/7268-model-not-supported-401-lockout.md b/changelog.d/fixes/7268-model-not-supported-401-lockout.md new file mode 100644 index 0000000000..d4ae8888b9 --- /dev/null +++ b/changelog.d/fixes/7268-model-not-supported-401-lockout.md @@ -0,0 +1 @@ +- fix(sse): classify 401 "model X is not supported" as model-not-found so it locks the model out instead of looping forever (#7268) diff --git a/changelog.d/fixes/7272-costs-page-500.md b/changelog.d/fixes/7272-costs-page-500.md new file mode 100644 index 0000000000..f342749da0 --- /dev/null +++ b/changelog.d/fixes/7272-costs-page-500.md @@ -0,0 +1 @@ +- fix(dashboard): resolve `ReferenceError: t is not defined` crashing `/dashboard/costs` when a filtered slice has zero-cost rows (#7272) diff --git a/changelog.d/fixes/7275-windows-cert-check-uninstall-hardcoded-legacy-host.md b/changelog.d/fixes/7275-windows-cert-check-uninstall-hardcoded-legacy-host.md new file mode 100644 index 0000000000..47644a3181 --- /dev/null +++ b/changelog.d/fixes/7275-windows-cert-check-uninstall-hardcoded-legacy-host.md @@ -0,0 +1 @@ +- fix(cli): Windows MITM root-CA check/uninstall keyed off the hardcoded legacy hostname `daily-cloudcode-pa.googleapis.com` instead of the actual generated CA's identity — they now derive a SHA-1 thumbprint from the real `certPath` file (same pattern `#6338` used for the DNS side of this anti-pattern) (#7275) diff --git a/changelog.d/fixes/7279-cli-detector-windows-drift.md b/changelog.d/fixes/7279-cli-detector-windows-drift.md new file mode 100644 index 0000000000..e6a8fd53df --- /dev/null +++ b/changelog.d/fixes/7279-cli-detector-windows-drift.md @@ -0,0 +1 @@ +- fix(cli): reuse cliRuntime's win32-aware `locateCommand`/`shell:true` probe in tool-detector so installed CLIs (npm `.cmd` shims) are no longer reported as absent on native Windows (#7279) diff --git a/changelog.d/fixes/7284-conn-test-429.md b/changelog.d/fixes/7284-conn-test-429.md new file mode 100644 index 0000000000..fe4af30c12 --- /dev/null +++ b/changelog.d/fixes/7284-conn-test-429.md @@ -0,0 +1 @@ +- fix(dashboard): connection Test surfaces a rate-limit warning on 429 chat-probe responses instead of an unqualified pass (#7284) diff --git a/changelog.d/fixes/7285-combo-finish-reason.md b/changelog.d/fixes/7285-combo-finish-reason.md new file mode 100644 index 0000000000..71515e48ca --- /dev/null +++ b/changelog.d/fixes/7285-combo-finish-reason.md @@ -0,0 +1 @@ +- fix(sse): combo failover now detects OpenAI-shape streams truncated without `finish_reason`/`[DONE]` (#7285) diff --git a/changelog.d/fixes/7288-sqljs-preinit-ordering-gap.md b/changelog.d/fixes/7288-sqljs-preinit-ordering-gap.md new file mode 100644 index 0000000000..07acc66712 --- /dev/null +++ b/changelog.d/fixes/7288-sqljs-preinit-ordering-gap.md @@ -0,0 +1 @@ +- **fix(db):** `getDbInstance()` now guarantees sql.js WASM has already been pre-initialized (via a top-level await in `src/lib/db/core.ts`) before ANY consumer can reach it, closing an ordering gap where early startup steps (`ensureSecrets()`, `clearStaleCrashCooldowns()`, `getSettings()`, `initAuditLog()`) called `getDbInstance()` before `ensureDbReadyForBoot()` had a chance to run `preInitSqlJs()` — turning a recoverable driver failure into a hard boot crash (`sql.js WASM ainda não foi pré-inicializado`) whenever both `better-sqlite3` and `node:sqlite` failed to open an existing `storage.sqlite`. `tryOpenSync()` also now logs the real underlying cause of each swallowed sync-driver failure instead of an empty `catch {}`. (#7288, #7494) diff --git a/changelog.d/fixes/7289-cursor-effort-suffix.md b/changelog.d/fixes/7289-cursor-effort-suffix.md new file mode 100644 index 0000000000..27905a5534 --- /dev/null +++ b/changelog.d/fixes/7289-cursor-effort-suffix.md @@ -0,0 +1 @@ +- fix(sse): split effort/reasoning suffix off pinned Claude/GPT model ids before sending to cursor's server (#7289) diff --git a/changelog.d/fixes/7293-strict-system-message-hoist.md b/changelog.d/fixes/7293-strict-system-message-hoist.md new file mode 100644 index 0000000000..f79efd1f8a --- /dev/null +++ b/changelog.d/fixes/7293-strict-system-message-hoist.md @@ -0,0 +1 @@ +- fix(sse): hoist client-injected `system` messages to index 0 for strict OpenAI-compatible providers (xiaomi-mimo) regardless of origin (#7293) diff --git a/changelog.d/fixes/7297-bedrock-images.md b/changelog.d/fixes/7297-bedrock-images.md new file mode 100644 index 0000000000..4d64923517 --- /dev/null +++ b/changelog.d/fixes/7297-bedrock-images.md @@ -0,0 +1 @@ +- fix(sse): treat Uint8Array/Buffer as opaque binary in log redaction to stop per-byte enumeration on Bedrock Converse image requests (#7297) diff --git a/changelog.d/fixes/7357-chatgpt-web-async-image-messages-array.md b/changelog.d/fixes/7357-chatgpt-web-async-image-messages-array.md new file mode 100644 index 0000000000..45ae02efc8 --- /dev/null +++ b/changelog.d/fixes/7357-chatgpt-web-async-image-messages-array.md @@ -0,0 +1 @@ +- fix(chatgpt-web): recognize `update_content.messages[]` (plural array) celsius WebSocket frames so async image_gen pointers are no longer silently dropped (#7357) diff --git a/changelog.d/fixes/7364-glm-4.6v-max-tokens-clamp.md b/changelog.d/fixes/7364-glm-4.6v-max-tokens-clamp.md new file mode 100644 index 0000000000..c4ce943d91 --- /dev/null +++ b/changelog.d/fixes/7364-glm-4.6v-max-tokens-clamp.md @@ -0,0 +1 @@ +- fix(sse): clamp glm-4.6v max_tokens to the 32768 ceiling for zai and glm providers, wiring stripUnsupportedParams into GlmExecutor's own transform path (#7364) diff --git a/changelog.d/fixes/7364-zai-glm-target-format.md b/changelog.d/fixes/7364-zai-glm-target-format.md new file mode 100644 index 0000000000..a2f6fef807 --- /dev/null +++ b/changelog.d/fixes/7364-zai-glm-target-format.md @@ -0,0 +1 @@ +- fix(sse): honor per-model targetFormat override for zai/glm-coding-apikey buildUrl and make custom-model id lookup case-insensitive (#7364) diff --git a/changelog.d/fixes/7387-sticky-quota-exhausted.md b/changelog.d/fixes/7387-sticky-quota-exhausted.md new file mode 100644 index 0000000000..1273c78a37 --- /dev/null +++ b/changelog.d/fixes/7387-sticky-quota-exhausted.md @@ -0,0 +1 @@ +- fix(sse): combo session stickiness now releases a connection whose per-window quota is exhausted, matching the provider-level session-affinity pin (#7387) diff --git a/changelog.d/fixes/7388-codex-ws-history-per-turn.md b/changelog.d/fixes/7388-codex-ws-history-per-turn.md new file mode 100644 index 0000000000..2e6e23a280 --- /dev/null +++ b/changelog.d/fixes/7388-codex-ws-history-per-turn.md @@ -0,0 +1 @@ +- fix(cli): log Codex Responses WebSocket history/usage per logical turn instead of once per connection (#7388) diff --git a/changelog.d/fixes/7521-codex-test-probe-model.md b/changelog.d/fixes/7521-codex-test-probe-model.md new file mode 100644 index 0000000000..8af6bbf9b6 --- /dev/null +++ b/changelog.d/fixes/7521-codex-test-probe-model.md @@ -0,0 +1 @@ +- Fixed the Codex connection **Test** button always reporting success for ChatGPT-account tokens: the probe used `gpt-5.3-codex`, a codex-only model ChatGPT accounts reject with a 400 — the same status the probe treats as "auth OK", so a bad token was indistinguishable from a good one. It now probes with `gpt-5.5`, a model ChatGPT-account sessions actually support (#7521). diff --git a/changelog.d/fixes/7522-codex-import-validate-refresh.md b/changelog.d/fixes/7522-codex-import-validate-refresh.md new file mode 100644 index 0000000000..73ae7b0f20 --- /dev/null +++ b/changelog.d/fixes/7522-codex-import-validate-refresh.md @@ -0,0 +1 @@ +- The Codex account import (`POST /api/oauth/codex/import`) now validates each record's `refresh_token` against OpenAI's OAuth endpoint before persisting the connection: an already-invalidated session (`refresh_token_invalidated` / a dead `auth.json`) is rejected with a clear "run `codex login` again and re-import" message instead of importing as `active` and failing confusingly on first use. Valid tokens import as before, with any rotated tokens applied (#7522). diff --git a/changelog.d/fixes/7523-codex-oauth-remote-host.md b/changelog.d/fixes/7523-codex-oauth-remote-host.md new file mode 100644 index 0000000000..f74a47b464 --- /dev/null +++ b/changelog.d/fixes/7523-codex-oauth-remote-host.md @@ -0,0 +1 @@ +- The PKCE OAuth start (`/api/oauth/[provider]/start-callback-server`, used by Codex/Windsurf/Devin) now detects when OmniRoute is being driven from a remote host and returns a reverse-tunnel hint (`remoteHost`, `tunnelCommand`, `message`) instead of hanging silently: the callback server binds the *server's* localhost:PORT, so a browser on a different machine would redirect to its own localhost and never complete. Loopback access is unchanged (#7523). diff --git a/changelog.d/fixes/7529-search-static-catalog.md b/changelog.d/fixes/7529-search-static-catalog.md new file mode 100644 index 0000000000..3387691b81 --- /dev/null +++ b/changelog.d/fixes/7529-search-static-catalog.md @@ -0,0 +1 @@ +- fix(providers): search providers now expose a static model catalog derived from `searchTypes`, fixing "does not support models listing" 400 for serper-search, brave-search, perplexity-search, exa-search, tavily-search, google-pse-search, youcom-search, searxng-search, zai-search (#7529) diff --git a/changelog.d/fixes/7532-tool-search-responses-to-chat.md b/changelog.d/fixes/7532-tool-search-responses-to-chat.md new file mode 100644 index 0000000000..28b0466b4f --- /dev/null +++ b/changelog.d/fixes/7532-tool-search-responses-to-chat.md @@ -0,0 +1 @@ +- fix(sse): map `tool_search` to a Chat function tool instead of dropping it during Responses->Chat translation (#7532) diff --git a/changelog.d/fixes/7533-verbosity-prompt-cache-key-leak.md b/changelog.d/fixes/7533-verbosity-prompt-cache-key-leak.md new file mode 100644 index 0000000000..7fd90f93f1 --- /dev/null +++ b/changelog.d/fixes/7533-verbosity-prompt-cache-key-leak.md @@ -0,0 +1 @@ +- fix(sse): gate `verbosity`/`prompt_cache_key` on OpenAI destination during Responses->Chat translation, stopping the leak to non-OpenAI upstreams like NVIDIA (#7533) diff --git a/changelog.d/fixes/7534-usage-provider-display-name.md b/changelog.d/fixes/7534-usage-provider-display-name.md new file mode 100644 index 0000000000..2354f914cf --- /dev/null +++ b/changelog.d/fixes/7534-usage-provider-display-name.md @@ -0,0 +1 @@ +- fix(api): Usage page "by provider" table now shows the configured provider display name (e.g. "OpenAI Codex") instead of the raw internal provider id (e.g. "codex") (#7534) diff --git a/changelog.d/fixes/7535-usage-model-dedup-normalized-key.md b/changelog.d/fixes/7535-usage-model-dedup-normalized-key.md new file mode 100644 index 0000000000..77f6dd7ea7 --- /dev/null +++ b/changelog.d/fixes/7535-usage-model-dedup-normalized-key.md @@ -0,0 +1 @@ +- fix(api): Usage page "model usage" table no longer lists the same logical model twice when it was recorded under both a bare and a provider-prefixed spelling (e.g. `glm-5.2` and `z-ai/glm-5.2`) — the in-memory dedup key now uses the normalized model name (#7535) diff --git a/changelog.d/fixes/7536-codex-nonstream-peek-body-double-read.md b/changelog.d/fixes/7536-codex-nonstream-peek-body-double-read.md new file mode 100644 index 0000000000..9f156104fe --- /dev/null +++ b/changelog.d/fixes/7536-codex-nonstream-peek-body-double-read.md @@ -0,0 +1 @@ +- fix(codex): non-stream Codex (ChatGPT-account) chat no longer 502s with "Response body is already used". `peekCodexSseTransientError` now checks the content-type before touching `response.body`: on the wreq-js TLS-fingerprint transport the Response is backed by a native body handle and merely accessing `.body` disturbs it, so the empty-content-type non-stream response was being consumed by the peek guard and then re-read by `readNonStreamingResponseBody`. Streaming was unaffected. Validated live on the VPS (`codex/gpt-5.5` + `codex/gpt-5.6-terra` non-stream now return 200) (#7536) diff --git a/changelog.d/fixes/codex-nonstream-body-double-read.md b/changelog.d/fixes/codex-nonstream-body-double-read.md new file mode 100644 index 0000000000..143eb47edb --- /dev/null +++ b/changelog.d/fixes/codex-nonstream-body-double-read.md @@ -0,0 +1 @@ +- Fixed every non-streaming Codex (ChatGPT-account) chat request failing with `[502]: Response body is already used (reset after 1m)`: `peekCodexSseTransientError` re-acquired a reader on the upstream `response.body` after `releaseLock()` to continue draining it, which throws on undici. It now keeps the single reader it already holds. The thrown TypeError was also being mis-classified as a 60s rate limit (cooldown + circuit breaker) — that misfire disappears with the double-read fixed. diff --git a/changelog.d/maintenance/7213-7603-filesize-baseline.md b/changelog.d/maintenance/7213-7603-filesize-baseline.md new file mode 100644 index 0000000000..3db9a6b953 --- /dev/null +++ b/changelog.d/maintenance/7213-7603-filesize-baseline.md @@ -0,0 +1 @@ +- **maintenance(quality):** re-baseline `file-size` for two legitimately-grown files from the v3.8.49 owner-PR campaign — `src/app/api/usage/analytics/route.ts` 942→948 (180d/365d range cases, #7213) and `tests/unit/audio-transcription-handler.test.ts` added to `testFrozen` at 824 (Gladia STT test cases, #7603). Fast-gates PR→release skip `check:file-size`, so this only surfaced on re-sync; no behavior change. diff --git a/changelog.d/maintenance/7295-avast-readme-false-positive.md b/changelog.d/maintenance/7295-avast-readme-false-positive.md new file mode 100644 index 0000000000..f51a9bf9ce --- /dev/null +++ b/changelog.d/maintenance/7295-avast-readme-false-positive.md @@ -0,0 +1 @@ +- **Antivirus false-positive note** (`docs/guides/TROUBLESHOOTING.md`): documents why Avast/AVG quarantine the packaged `README.md` with `MD:HttpRequest-inf[Susp]` — a heuristic false positive on the ~15 `http://localhost:20128` examples the file ships with (via `package.json` → `files`). Covers how to stop the notifications, how to report the false positive upstream, and why the localhost examples are deliberately left alone. (#7295 — reported by @DemonNCoding, #5946) diff --git a/changelog.d/maintenance/7615-readme-tier-cascade-svg.md b/changelog.d/maintenance/7615-readme-tier-cascade-svg.md new file mode 100644 index 0000000000..593427f876 --- /dev/null +++ b/changelog.d/maintenance/7615-readme-tier-cascade-svg.md @@ -0,0 +1 @@ +- **README tier-cascade diagram animated** (`docs/diagrams/tier-cascade.svg`): the ASCII 4-tier auto-fallback block in the README is now a self-contained animated SVG (SMIL-only, 16 KB, 16s loop in 4 acts — quota-out/budget-hit hand-offs down to the always-on free tier) that plays inside GitHub's `` sandbox; full flow preserved in the img alt text, hand-authored-diagrams section added to `docs/diagrams/README.md` (#7615). diff --git a/changelog.d/maintenance/7616-docs-provider-count-259.md b/changelog.d/maintenance/7616-docs-provider-count-259.md new file mode 100644 index 0000000000..8efbcae35f --- /dev/null +++ b/changelog.d/maintenance/7616-docs-provider-count-259.md @@ -0,0 +1 @@ +- **Docs provider-count sync** (`README.md`, `AGENTS.md`, `CLAUDE.md`): provider-count mentions bumped 253 → 259 to match the auto-generated catalog, un-blocking the strict `check:docs-counts` gate that had started failing on every PR targeting the release branch (#7616). diff --git a/changelog.d/maintenance/ci-pr-test-policy-shallow-base.md b/changelog.d/maintenance/ci-pr-test-policy-shallow-base.md new file mode 100644 index 0000000000..340b60a5ce --- /dev/null +++ b/changelog.d/maintenance/ci-pr-test-policy-shallow-base.md @@ -0,0 +1 @@ +- CI: `pr-test-policy` fetches the base branch with full history instead of `--depth=1` — the shallow graft made `merge-base` resolve wrong for PR branches that recently merged the release, so the three-dot diff blamed the PR for OTHER merged PRs' changes (false "deleted test"/"weakened asserts" reds; observed on #7329 being blamed for #7106's files). diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index dcea92f2a9..a808475e5c 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -169,11 +169,6 @@ "count": 2 } }, - "open-sse/translator/helpers/claudeHelper.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "open-sse/utils/setupPolyfill.ts": { "@typescript-eslint/no-explicit-any": { "count": 5 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 6cc3bba432..d77d9569f9 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,7 @@ { + "_rebaseline_2026_07_17_v3849_ownerprs_media_audio": "Own-growth from the v3.8.49 owner-PR merge campaign (fast-gates PR->release do not run check:file-size, so this surfaced only on re-sync): src/app/api/usage/analytics/route.ts 942->948 (+6 = the 180d/365d getRangeStartIso cases from #7213 usage-extended-periods) and tests/unit/audio-transcription-handler.test.ts new testFrozen 824 (Gladia async STT test cases added by #7603). Both irreducible additions covered by their PR tests; structural shrink tracked in #3501.", + "_rebaseline_2026_07_14_7034_goog_api_key": "Issue #7034 (gemini-cli x-goog-api-key client auth) own growth: src/sse/services/auth.ts 2458->2461 (+3 = import + the two-line extractGoogApiKeyHeader() call/return at the existing extractApiKey() chokepoint, plus a 1-line doc-comment mention offset by a 1-line net save elsewhere in the same edit). The actual header-read/trim logic was EXTRACTED into a new leaf module src/sse/services/googApiKeyAuth.ts (shared by both extractApiKey() here and extractBearer() in src/server/authz/policies/clientApi.ts, which is not frozen) to keep this frozen file's growth to the irreducible call-site wiring. Covered by tests/unit/auth-extract-api-key.test.ts and tests/unit/authz/client-api-policy.test.ts.", + "_rebaseline_2026_07_14_6928_comfyui_baseurl_override": "Issue #6928 own growth: open-sse/handlers/videoGeneration.ts 1265->1275 (+10 = resolveComfyUiBaseUrl import + expanding the comfyui dispatch call into a multi-line object literal so the per-connection providerSpecificData.baseUrl override — same storage convention self-hosted chat providers use — is threaded through to handleComfyUIVideoGeneration; Prettier's 100-char width forces the multi-line form), src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts 1053->1054 (+1 = comfyui added to CONFIGURABLE_BASE_URL_PROVIDERS/DEFAULT_PROVIDER_BASE_URLS/getProviderBaseUrlPlaceholder so the Add/Edit connection modals render an editable base-URL field for ComfyUI, mirroring self-hosted chat providers). The identical dispatch pattern was also applied to imageGeneration.ts and musicGeneration.ts, both well under their frozen caps. Covered by tests/unit/comfyui-baseurl-override-6928.test.ts (resolver unit tests + handler-level fetch-mock overrides for image/video/music) and the new provider-page-helpers-3501.test.ts assertion.", "_rebaseline_2026_07_07_v3846_proxy_insecure_random": "PR #6580 (v3.8.46 post-release closing fix): proxies.ts 1173->1177 (+4) — o fix de segurança CodeQL #698/#699 troca Math.random por crypto.randomInt no random rotation strategy (#6365) e adiciona 4 linhas de comentário explicando por que (a seleção flui para credenciais do proxy). Crescimento irreducivel do proprio fix; frozen so encolhe daqui.", "_rebaseline_2026_07_07_v3846_release_close": "Release v3.8.46 Phase 0 (generate-release) — drift de ciclo absorvido no fechamento (fast-gates PR->release nao rodam check:file-size). PROD god-files crescidos por merges do ciclo (nao meus; DECOMPOR idealmente, debt #3501): proxies.ts 1060->1173, chat.ts 1681->1751, ApiManagerPageClient.tsx 3058->3120, ProxyRegistryManager.tsx 1125->1437 (feature de proxy). TEST frozen: models-catalog-route.test.ts 1600->1605 (+5 do fix#2 do captain, #6408 catalogo cache), vscode-token-routes.test.ts 1212->1285 (cycle drift + os asserts effort_tiers/supportsThinking do #6241 alinhados no release-PR-CI base-red), que adiciona o import + 2 chamadas do hook __resetCatalogBuilderRunsForTest existente no setup (harness, sem asserts). Shrink estrutural rastreado no roadmap #3501.", "_rebaseline_2026_07_04_v3844_release_close": "Release v3.8.44 Phase 0 (generate-release): drift de ciclo absorvido no fechamento, medido no tip 415d159c8 (fast-gates PR->release nao rodam check:file-size). oauth/[provider]/[action]/route.ts 924->960 (#6054 zed keychain-import 400 gracioso; PR #6158 aberto extrai o guard e restaura o freeze — quando mergear, o frozen so encolhe), providerLimits.ts 982->998 (#6139 TOCTOU quota recovery + #6128), chat.ts 1647->1662 (#6057 per-request Auto-Combo X-OmniRoute-Mode/Budget + #6097), auth.ts 2405->2426 (#6139 + #6090 quota preflight lockouts + #5943 codex session affinity). Crescimento irreducivel em chokepoints existentes, coberto por testes por-PR; shrink estrutural rastreado no roadmap #3501.", @@ -162,7 +165,7 @@ "open-sse/handlers/responseSanitizer.ts": 1139, "open-sse/handlers/search.ts": 1546, "open-sse/handlers/sseParser.ts": 830, - "open-sse/handlers/videoGeneration.ts": 1265, + "open-sse/handlers/videoGeneration.ts": 1275, "open-sse/mcp-server/schemas/tools.ts": 1497, "open-sse/mcp-server/server.ts": 1555, "open-sse/mcp-server/tools/advancedTools.ts": 1120, @@ -213,7 +216,7 @@ "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264, - "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1053, + "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1054, "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 912, "src/app/(dashboard)/dashboard/providers/page.tsx": 1927, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201, @@ -232,7 +235,7 @@ "src/app/api/oauth/[provider]/[action]/route.ts": 960, "src/app/api/providers/[id]/models/route.ts": 2593, "src/app/api/providers/[id]/test/route.ts": 940, - "src/app/api/usage/analytics/route.ts": 942, + "src/app/api/usage/analytics/route.ts": 948, "src/app/api/v1/models/catalog.ts": 1615, "src/lib/cloudflaredTunnel.ts": 934, "src/lib/db/apiKeys.ts": 1662, @@ -267,7 +270,7 @@ "_rebaseline_2026_07_09_6678_routing_strategy_9router": "#6678 (SeaXen) — 9router-parity Routing Strategy settings card + per-provider/combo sticky-round-robin override. Own growth: ProviderDetailPageClient.tsx 784->786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.", "src/sse/handlers/chat.ts": 1796, "src/sse/handlers/chatHelpers.ts": 876, - "src/sse/services/auth.ts": 2458, + "src/sse/services/auth.ts": 2461, "open-sse/executors/default.ts": 877, "open-sse/translator/request/openai-responses.ts": 902, "open-sse/executors/kiro.ts": 944, @@ -335,7 +338,8 @@ "tests/unit/usage-service-hardening.test.ts": 1503, "tests/unit/vscode-token-routes.test.ts": 1285, "tests/unit/web-cookie-providers-new.test.ts": 890, - "_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail: translator-openai-responses-req.test.ts 1172->1195 (+23 = #6807 reasoning-summary-for-effort-only regression tests). Frozen only shrinks." + "_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail: translator-openai-responses-req.test.ts 1172->1195 (+23 = #6807 reasoning-summary-for-effort-only regression tests). Frozen only shrinks.", + "tests/unit/audio-transcription-handler.test.ts": 824 }, "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", "_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.", diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 3e8cd63fe6..ee2c81c6d3 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -164,7 +164,8 @@ "dedicatedGate": true }, "zizmorFindings": { - "value": 169, + "value": 175, + "_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", "dedicatedGate": true, "_rebaseline_2026_06_23_fastpath_gates": "155 -> 159 (+4). Two new jobs added to .github/workflows/quality.yml (fast-vitest, fast-unit) to run vitest + the full unit suite on the PR->release fast-path (release-acceleration plan, _tasks/release-bench/v3.8.35/PLANO-IMPLEMENTACAO.md). The +4 are unpinned-uses: actions/checkout@v7 + actions/setup-node@v6 in each of the 2 jobs — the SAME deliberate @vN convention as every other workflow (see _scanner_harden_workflows_2026_06_16). SHA-pinning only these would violate the convention. No new template-injection/artipacked/cache-poisoning. Measured locally via `npm run check:workflows -- --ratchet` = 159.", diff --git a/docs/compression/COMPRESSION_GUIDE.md b/docs/compression/COMPRESSION_GUIDE.md index 48bf546093..326a042a1c 100644 --- a/docs/compression/COMPRESSION_GUIDE.md +++ b/docs/compression/COMPRESSION_GUIDE.md @@ -188,6 +188,14 @@ Combo: "free-forever" This lets you use stacked compression on free/coding providers while keeping lite mode on paid subscriptions. +This "Per-Combo Override" assignment is a different control from the **routing-combo compression +mode** override (Default/Off/Lite/Standard/Aggressive/Ultra) — that override does not pick a named +compression-combo pipeline; it just sets the `compressionMode` field consulted by +`resolveCompressionPlan`. It can be set either on the combo card (`Dashboard → Combos`) or, since +#6760, per routing combo in the "Assign to routing" list on +`Dashboard → Context & Cache → Compression Combos`, right next to the pipeline-assignment checkbox +documented above. Both surfaces persist through the same `PUT /api/combos/{id}` endpoint. + ### Per-request override Send the `x-omniroute-compression` request header to override the compression plan for a single diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md index c13a411222..2de1805fe3 100644 --- a/docs/diagrams/README.md +++ b/docs/diagrams/README.md @@ -1,7 +1,7 @@ --- title: "Diagrams" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.49 +lastUpdated: 2026-07-17 --- # Diagrams @@ -21,6 +21,16 @@ Mermaid sources (`.mmd`) and exported SVGs for OmniRoute v3.8.0 architecture flo | [authz-pipeline.mmd](./authz-pipeline.mmd) | [SVG](./exported/authz-pipeline.svg) | docs/architecture/AUTHZ_GUIDE.md | | [db-schema-overview.mmd](./db-schema-overview.mmd) | [SVG](./exported/db-schema-overview.svg) | docs/architecture/CODEBASE_DOCUMENTATION.md | +## Hand-authored animated diagrams + +Not every diagram comes from a `.mmd` source. Hand-authored SVGs live at this +directory's root and animate with SMIL only (no JS, no external fonts), so they play +inside GitHub's `` sandbox: + +| File | Used in | Notes | +| ---------------------------------------- | ---------------- | ---------------------------------------------------------------------------- | +| [tier-cascade.svg](./tier-cascade.svg) | README.md (root) | Animated 4-tier auto-fallback cascade (16s loop, 4 acts). Edit the SVG directly — there is no `.mmd` source. | + ## How to update 1. Edit `*.mmd`. diff --git a/docs/diagrams/tier-cascade.svg b/docs/diagrams/tier-cascade.svg new file mode 100644 index 0000000000..4f819814fd --- /dev/null +++ b/docs/diagrams/tier-cascade.svg @@ -0,0 +1,269 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Your IDE / CLI + Claude Code · Cursor · Cline · every AI tool + + + + + + + http://localhost:20128/v1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OmniRoute — Smart Router + RTK + Caveman compression · 18 routing strategies + Circuit breakers · TLS stealth · MCP · A2A · Guardrails + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TIER 1 · SUBSCRIPTION + + + + + + + Claude Code + Codex + Copilot + + + + + + + + + + TIER 2 · API KEY + + + + + + + DeepSeek + Groq + xAI + + + + + + + + + + TIER 3 · CHEAP + + + + + + + GLM $0.5 + MiniMax $0.2 + + + + + + + + + + TIER 4 · FREE + + + + + + Kiro + Qoder + Pollinations + + + + + + + + + + + + + quota out? + + + + budget hit? + + + + budget hit? + + + + + + + + + + + + + + + + + + + + + + + + ALWAYS ON + + + + AUTO-FALLBACK CASCADE · NEVER STOP CODING + diff --git a/docs/getting-started/QUICK-START.md b/docs/getting-started/QUICK-START.md index 03f4263874..f9ebee9059 100644 --- a/docs/getting-started/QUICK-START.md +++ b/docs/getting-started/QUICK-START.md @@ -131,5 +131,5 @@ OmniRoute automatically skips failed providers and tries the next one. You don't ## Need Help? - **[Troubleshooting](./TROUBLESHOOTING.md)** — Common issues and fixes -- **[Discord](https://discord.gg/EkzRkpzKYt)** — Community support +- **[Discord](https://discord.gg/U47eFqAXCn)** — Community support - **[GitHub Issues](https://github.com/diegosouzapw/OmniRoute/issues)** — Report bugs diff --git a/docs/getting-started/TROUBLESHOOTING.md b/docs/getting-started/TROUBLESHOOTING.md index e2c8468db2..3f92ce8231 100644 --- a/docs/getting-started/TROUBLESHOOTING.md +++ b/docs/getting-started/TROUBLESHOOTING.md @@ -30,7 +30,7 @@ Common problems and solutions for OmniRoute. | "401 Unauthorized" | Your credentials are wrong | Check your API key or re-authenticate with OAuth | | "429 Too Many Requests" | Rate limited | Wait 1 minute, or connect more providers | -**Still stuck?** See the [Quick Fixes](#quick-fixes) below, or ask on [Discord](https://discord.gg/EkzRkpzKYt). +**Still stuck?** See the [Quick Fixes](#quick-fixes) below, or ask on [Discord](https://discord.gg/U47eFqAXCn). --- diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 59ef2b4fc6..5efe9e5be2 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -1,7 +1,7 @@ --- title: "Troubleshooting" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.49 +lastUpdated: 2026-07-15 --- # Troubleshooting @@ -30,7 +30,7 @@ Common problems and solutions for OmniRoute. | "401 Unauthorized" | Your credentials are wrong | Check your API key or re-authenticate with OAuth | | "429 Too Many Requests" | Rate limited | Wait 1 minute, or connect more providers | -**Still stuck?** See the [detailed troubleshooting](#detailed-troubleshooting) below, or ask on [Discord](https://discord.gg/EkzRkpzKYt). +**Still stuck?** See the [detailed troubleshooting](#detailed-troubleshooting) below, or ask on [Discord](https://discord.gg/U47eFqAXCn). --- @@ -50,6 +50,44 @@ Common problems and solutions for OmniRoute. | Login crash / blank page | Check Node.js version — see [Node.js Compatibility](#nodejs-compatibility) below | | `dlopen` / `slice is not valid mach-o file` (macOS) | Run `cd $(npm root -g)/omniroute/app && npm rebuild better-sqlite3 && omniroute` — see [macOS native module rebuild](#macos-native-module-rebuild) below | | Proxy "fetch failed" | Ensure proxy config is set at the correct level — see [Proxy Issues](#proxy-issues) below | +| Antivirus quarantines `README.md` | False positive — see [Antivirus false positives](#antivirus-false-positives) below | + +--- + +## Antivirus False Positives + + + +### Avast/AVG quarantine `README.md` with `MD:HttpRequest-inf[Susp]` + +**This is a false positive. Nothing is infected, and no action is required.** + +Avast and AVG run a heuristic that flags plain-text/Markdown files containing many +HTTP-request-looking links. OmniRoute's `README.md` ships inside the npm package (it is +listed in `package.json` → `files`), so it lands at `node_modules/omniroute/README.md` on +a global install — and it contains ~15 `http://localhost:20128/...` examples (the MCP +HTTP/SSE endpoints, the A2A `.well-known` URL, and `curl` snippets). That link density is +enough to trip the heuristic. + +If this started only recently: the file did not change in kind. The README grew its +endpoints table (MCP HTTP + SSE + A2A were added) and more `curl` examples, which pushed +it past the threshold. + +The file is inert documentation with zero executable content. You can safely restore it +from quarantine. + +**What to do:** + +1. **Stop the notifications** — exclude the install directory in your antivirus + (Avast: Settings → Exceptions), adding your global `node_modules` path and/or the + OmniRoute data dir (`~/.omniroute/`). +2. **Report the false positive** — , + attaching the quarantined `README.md`. This is the fix that helps everyone, since it is + the vendor's heuristic overreacting to a text file. + +**Why we do not "fix" this on our side:** the examples are all `http://localhost`, and +localhost cannot be `https` without self-signed-certificate friction. Mangling the docs to +dodge one vendor's heuristic would hurt every reader to satisfy a scanner bug. --- diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 3f9fdfc7f9..b35a8094a5 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -945,6 +945,7 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \ - `kie/` - `aws-polly/` - `xiaomi-mimo/` +- `edgetts/` (Microsoft Edge "Read Aloud" — free, no API key; unofficial/reverse-engineered endpoint) - `coqui/`, `tortoise/` - `qwen/` diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index 7b8cc1f556..ee3e9e500f 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -37,12 +37,12 @@ ### 💬 加入社区 -[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/EkzRkpzKYt) +[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/U47eFqAXCn) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/omnirouteOficial) [![WhatsApp Global](https://img.shields.io/badge/WhatsApp_Global-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) -[![WhatsApp Brasil](https://img.shields.io/badge/WhatsApp_Brasil-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI) +[![WhatsApp Brasil](https://img.shields.io/badge/WhatsApp_Brasil-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz) -**疑难解答、服务商攻略、路线图与支持 → [Discord](https://discord.gg/EkzRkpzKYt) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 全球](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 巴西](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)** +**疑难解答、服务商攻略、路线图与支持 → [Discord](https://discord.gg/U47eFqAXCn) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 全球](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 巴西](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)**
diff --git a/docs/i18n/zh-CN/docs/guides/TROUBLESHOOTING.md b/docs/i18n/zh-CN/docs/guides/TROUBLESHOOTING.md index f0cac7dbba..f5157951e5 100644 --- a/docs/i18n/zh-CN/docs/guides/TROUBLESHOOTING.md +++ b/docs/i18n/zh-CN/docs/guides/TROUBLESHOOTING.md @@ -30,7 +30,7 @@ OmniRoute 常见问题及解决方案。 | "401 Unauthorized" | 凭据有误 | 检查 API 密钥或通过 OAuth 重新认证 | | "429 Too Many Requests" | 触发速率限制 | 等待 1 分钟,或接入更多服务商 | -**还是不行?** 请参阅下方的[详细故障排除](#详细故障排除),或前往 [Discord](https://discord.gg/EkzRkpzKYt) 提问。 +**还是不行?** 请参阅下方的[详细故障排除](#详细故障排除),或前往 [Discord](https://discord.gg/U47eFqAXCn) 提问。 --- diff --git a/docs/i18n/zh-TW/README.md b/docs/i18n/zh-TW/README.md index 3dfdaaada2..5cee8b425f 100644 --- a/docs/i18n/zh-TW/README.md +++ b/docs/i18n/zh-TW/README.md @@ -37,12 +37,12 @@ ### 💬 加入社群 -[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/EkzRkpzKYt) +[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/U47eFqAXCn) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/omnirouteOficial) [![WhatsApp Global](https://img.shields.io/badge/WhatsApp_Global-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) -[![WhatsApp Brasil](https://img.shields.io/badge/WhatsApp_Brasil-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI) +[![WhatsApp Brasil](https://img.shields.io/badge/WhatsApp_Brasil-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz) -**問題、供應商技巧、路線圖與支援 → [Discord](https://discord.gg/EkzRkpzKYt) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 全球](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 巴西](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)** +**問題、供應商技巧、路線圖與支援 → [Discord](https://discord.gg/U47eFqAXCn) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 全球](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 巴西](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)**
diff --git a/docs/reference/CLI-TOOLS.md b/docs/reference/CLI-TOOLS.md index ef38bbeb82..6624d8ccff 100644 --- a/docs/reference/CLI-TOOLS.md +++ b/docs/reference/CLI-TOOLS.md @@ -114,6 +114,7 @@ Tools that support custom base URL and appear in `/dashboard/cli-code`: | cursor-cli | Cursor CLI | Anysphere | partial | guide | true | | smelt | Smelt | leonardcser (OSS) | full | custom | false | | pi | Pi (pi-coding-agent) | M. Zechner (OSS) | full | custom | false | +| grok-build | Grok Build | xAI | full | custom | false | | custom | Custom CLI | — | full | custom-builder | false | Tools with `baseUrlSupport: "partial"` show a badge "⚠ Base URL parcial" in the dashboard card. @@ -203,6 +204,7 @@ New tools with `configType: "custom"` have dedicated settings API routes: | `POST /api/cli-tools/codewhale-settings` | CodeWhale (OPENAI_BASE_URL, primary + legacy `~/.deepseek` sync) | | `POST /api/cli-tools/smelt-settings` | Smelt | | `POST /api/cli-tools/pi-settings` | Pi coding agent | +| `POST /api/cli-tools/grok-build-settings` | Grok Build (~/.grok/config.toml, `[model.omniroute]`) | All routes use `sanitizeErrorMessage()` for error responses (Hard Rule #12). diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 7e9b2b0773..8832e68d6a 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -801,6 +801,8 @@ Automatic model pricing data synchronization from external sources. | `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Include display-friendly `name` fields in `/v1/models` responses. Disable for clients that expect IDs only. | | `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. | | `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. | +| `DESIGNER_WEB_POLL_TIMEOUT_MS` | `60000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | Max wait for microsoft-designer-web image generation jobs. | +| `DESIGNER_WEB_POLL_INTERVAL_MS` | `2000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | microsoft-designer-web job polling frequency. | | `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). | | `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. | | `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. | @@ -1077,7 +1079,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When true, the Bifrost sidecar route streams responses back via SSE through the gateway rather than the TS streaming executor. Set to `0` to force non-streaming JSON responses through the gateway. | | `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Per-request timeout when proxying to the Bifrost gateway (ms). On timeout the route returns the TS relay path via the `X-Bifrost-Fallback` header. | | `OMNIROUTE_BIFROST_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Alias for `BIFROST_API_KEY` (used by scripts that read the env via `OMNIROUTE_*`). `BIFROST_API_KEY` takes precedence when both are set. | -| `OMNIROUTE_RELAY_BACKEND` | `ts` / `auto` | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | Relay backend for `/api/v1/relay/chat/completions`: `ts \| bifrost \| auto`. `ts` = TypeScript relay (default when Bifrost unconfigured); `auto` selects Bifrost when `BIFROST_BASE_URL` is set and `BIFROST_ENABLED` ≠ `0`, with automatic TS fallback if the sidecar is unreachable; `bifrost` forces Bifrost (strict, no fallback). Auth/rate-limit/injection-guard/allowlist always run in the Next route first. Responses carry `X-Routing-Backend` / `X-Routing-Fallback`. | +| `OMNIROUTE_RELAY_BACKEND` | `ts` / `auto` | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | Relay backend for `/api/v1/relay/chat/completions`: `ts \| bifrost \| auto`. `ts` = TypeScript relay (default when Bifrost unconfigured); `auto` selects Bifrost when `BIFROST_BASE_URL` is set and `BIFROST_ENABLED` ≠ `0`, with automatic TS fallback if the sidecar is unreachable; `bifrost` forces Bifrost (strict, no fallback). Auth/rate-limit/injection-guard/allowlist always run in the Next route first. Responses carry `X-Routing-Backend` / `X-Routing-Fallback` / `X-Routing-Fallback-Reason`. | | `RELAY_ROUTING_BACKEND` | _(unset)_ | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | Accepted alias for `OMNIROUTE_RELAY_BACKEND` (same `ts \| bifrost \| auto` values). `OMNIROUTE_RELAY_BACKEND` takes precedence when both are set. | | `OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS` | `5000` | `src/app/api/v1/relay/chat/completions/bifrostCooldown.ts` | Cooldown (ms) after a Bifrost sidecar hop fails in `auto` mode before the relay re-attempts the sidecar; it routes straight to the TS path while the cooldown lasts, then probes again. `0` disables. Only applies when `OMNIROUTE_RELAY_BACKEND=auto`. | | `OMNIROUTE_TLS_CERT` | _(unset)_ | `bin/cli/commands/serve.mjs` | Path to a PEM TLS certificate to serve `omniroute serve` over HTTPS (equivalent to `--tls-cert`). Must be paired with `OMNIROUTE_TLS_KEY`; the standalone server then terminates TLS on the same listener (`wss://` works unchanged). Unset → plain HTTP. Providing only one of cert/key, or an unreadable path, logs a warning and stays HTTP. | diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index f10d96ffd9..9eb0df2d0c 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" -version: 3.8.47 -lastUpdated: 2026-07-13 +version: 3.8.49 +lastUpdated: 2026-07-17 --- # 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-07-13 +> **Last generated:** 2026-07-17 -Total providers: **250**. See category breakdown below. +Total providers: **259**. See category breakdown below. ## Categories @@ -58,7 +58,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | | `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. | -## Web Cookie Providers (25) +## Web Cookie Providers (27) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -77,7 +77,9 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | | `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://www.kimi.com) | Paste your Cookie header from www.kimi.com (must contain kimi-auth=...). Find it via DevTools → Network → request → Cookie. | | `lmarena` | `lma` | Arena (Free) | Web cookie | [link](https://arena.ai) | Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session. | +| `microsoft-designer-web` | `msdesigner` | Microsoft Designer (Image Generation) | Web cookie | [link](https://designer.microsoft.com) | Sign in at designer.microsoft.com, then open DevTools → Network, generate an image, and find the request to DallE.ashx?action=GetDallEImagesCogSci. Copy the value of its Authorization: Bearer header (the access_token — no 'Bearer ' prefix). The token is short-lived; this is an unofficial, reverse-engineered integration. | | `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai | +| `notion-web` | `nw` | Notion AI Web (Unofficial/Experimental) | Web cookie | [link](https://www.notion.so) | Paste your token_v2 cookie value from notion.so (DevTools → Application → Cookies). Optionally append `; space_id=...` and/or `; notion_browser_id=...` if your workspace requires them. | | `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai | | `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | | `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | @@ -88,7 +90,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zai-web` | `zw` | Z.ai Web (Free) | Web cookie | [link](https://chat.z.ai) | Paste the full Cookie header from chat.z.ai (must include the token= cookie) | | `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) (167) +## API Key Providers (paid / paid-with-free-credits) (172) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -117,6 +119,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks | | `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. | | `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup | +| `chenzk` | `chenzk` | Chenzk API | API key | [link](https://chenzk.top) | — | | `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. | | `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key . | | `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | @@ -143,6 +146,8 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing | | `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — | | `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | +| `freepik` | `fpk` | Freepik (Mystic) | API key, image | [link](https://freepik.com) | Get API key at freepik.com/developers (Mystic image endpoint) | +| `freetheai` | `fta` | FreeTheAi | API key, aggregator | [link](https://freetheai.xyz) | Join the FreeTheAi Discord to get your free API key. | | `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | | `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | | `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com | @@ -184,6 +189,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `minimax` | `minimax` | Minimax Coding | API key, video | [link](https://www.minimax.io) | — | | `minimax-cn` | `minimax-cn` | Minimax (China) | API key | [link](https://www.minimaxi.com) | — | | `mistral` | `mistral` | Mistral | API key | [link](https://mistral.ai) | Free Experiment tier: rate-limited access to all models, no credit card required | +| `mixedbread` | `mxbai` | Mixedbread AI | API key | [link](https://www.mixedbread.com) | Bearer API key for the Mixedbread embeddings API. | | `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://--.modal.run/v1. | | `modelscope` | `ms` | ModelScope | API key | [link](https://modelscope.cn) | Free tier via ModelScope API-Inference — Alibaba account required. | | `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai | @@ -194,7 +200,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | | `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai | | `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. | -| `novita` | `novita` | Novita AI | API key, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | +| `novita` | `novita` | Novita AI | API key, video, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | | `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing | | `nube` | `nube` | Nube.sh | API key | [link](https://nube.sh) | — | | `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) | @@ -225,6 +231,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required | | `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. | | `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | +| `segmind` | `segmind` | Segmind | API key, image, video | [link](https://segmind.com) | Use your Segmind API key in the x-api-key header. OmniRoute targets https://api.segmind.com/v1/ and returns the generated image/video bytes directly. | | `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | | `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus permanently free models after identity verification | | `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — | @@ -293,7 +300,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) | | `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/business/api/) | X-API-Key from the You.com platform dashboard | -## Audio-only Providers (7) +## Audio-only Providers (9) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -302,8 +309,10 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `cartesia` | `cartesia` | Cartesia | Audio | [link](https://cartesia.ai) | — | | `deepgram` | `dg` | Deepgram | Audio | [link](https://deepgram.com) | — | | `elevenlabs` | `el` | ElevenLabs | Audio | [link](https://elevenlabs.io) | — | +| `gladia` | `gladia` | Gladia | Audio | [link](https://gladia.io) | — | | `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — | | `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — | +| `rev-ai` | `revai` | Rev AI | Audio | [link](https://www.rev.ai) | — | ## Upstream Proxy Providers (2) diff --git a/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index a4967b4a08..0130f5dd67 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -212,8 +212,15 @@ a single final answer from all panel responses. Ported from upstream `decolua/9r How it works: -1. **Fan-out** — the prompt is sent to every panel model at once, forced non-streaming - with tools stripped (the judge needs complete prose to synthesize). +0. **Tool-bearing bypass** — a request that carries a non-empty `tools` array with + `tool_choice` not explicitly `"none"` skips the panel entirely: it routes directly to + a single model (the configured judge, or `panel[0]`) with `tools`/`tool_choice` + passed through unmodified. Panel members have no tool access and the judge's + synthesis directive discourages tool-call emission, so agentic/tool-calling clients + get a real tool-call decision instead of synthesized prose (#6771). +1. **Fan-out** (non-tool-bearing requests only) — the prompt is sent to every panel + model at once, forced non-streaming with tools stripped (the judge needs complete + prose to synthesize). 2. **Quorum-grace collection** — as soon as `minPanel` answers arrive, a short grace timer starts for the stragglers, then fusion proceeds with whatever was collected. This caps the slowest model's penalty on wall time, bounded by a hard timeout. @@ -225,6 +232,11 @@ How it works: 4. **Graceful degradation** — 0 panel answers → `503`; exactly 1 survivor → that answer is returned directly (nothing to fuse); a single-model panel answers directly. +A panel member may also be a `combo-ref` step (`{kind: "combo-ref", comboName: "..."}`) referencing +another combo — it resolves as **one black-box panel voice** (a full recursive dispatch into the +referenced combo, not a fan-out of that combo's own targets), with the same depth/cycle protection +every other combo-ref-consuming strategy already uses (#6764). + ### Configuration Configured on the combo's `config` blob (no schema migration — it reuses the existing diff --git a/docs/security/CORS.md b/docs/security/CORS.md index 557f7fa610..51f60d76c6 100644 --- a/docs/security/CORS.md +++ b/docs/security/CORS.md @@ -23,7 +23,11 @@ in this order: 1. **`CORS_ALLOW_ALL=true`** (or the legacy `CORS_ORIGIN=*`) → echo the caller's `Origin` back (or `*` when there is no `Origin` header), with `Vary: Origin` - so caches stay correct. + so caches stay correct. The same `applyCorsHeaders()` chokepoint also appends + `Vary: Accept-Encoding` to every 2xx-with-body response on the token-authenticated + `/v1*`/`/v1beta*` surface (`relaxForTokenAuth`, RFC 9110 §12.5.5, issue #6737), so + downstream/shared caches can correctly distinguish compressed vs uncompressed + variants. 2. Otherwise, the request `Origin` is normalized (lower-cased, trailing slash stripped) and matched against the **merged allowlist**: - env **`CORS_ALLOWED_ORIGINS`** — comma-separated list, and diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 144d0f563f..117e9a4b52 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -159,6 +159,36 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { { id: "elevenlabs/audio-isolation", name: "ElevenLabs Audio Isolation" }, ], }, + + gladia: { + id: "gladia", + // POST https://api.gladia.io/v2/pre-recorded — async workflow: upload → submit → poll + // Auth: x-gladia-key: (custom header, not a standard Bearer/Token scheme) + // Free tier: 10 hours/month, no credit card required + baseUrl: "https://api.gladia.io/v2/pre-recorded", + authType: "apikey", + authHeader: "x-gladia-key", + async: true, + format: "gladia", + models: [ + { id: "solaria-1", name: "Solaria 1" }, + { id: "solaria-mini", name: "Solaria Mini" }, + ], + }, + + "rev-ai": { + id: "rev-ai", + baseUrl: "https://api.rev.ai/speechtotext/v1", + authType: "apikey", + authHeader: "bearer", + async: true, + format: "rev-ai", + models: [ + { id: "machine", name: "Reverb ASR" }, + { id: "low_cost", name: "Low-Cost ASR" }, + { id: "fusion", name: "Fusion ASR" }, + ], + }, }; /** @@ -405,6 +435,31 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { ], }, + edgetts: { + id: "edgetts", + // Microsoft Edge "Read Aloud" — reverse-engineered, no API key required. + // WebSocket transport (unlike every other entry here) — handled by + // open-sse/executors/edgeTts.ts, dispatched via the "edgetts" format. + baseUrl: "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1", + authType: "none", + authHeader: "none", + format: "edgetts", + supportedFormats: ["mp3"], + models: [ + { id: "en-US-AriaNeural", name: "Aria (EN-US, Female)" }, + { id: "en-US-GuyNeural", name: "Guy (EN-US, Male)" }, + { id: "en-GB-SoniaNeural", name: "Sonia (EN-GB, Female)" }, + { id: "en-GB-RyanNeural", name: "Ryan (EN-GB, Male)" }, + { id: "es-ES-ElviraNeural", name: "Elvira (ES-ES, Female)" }, + { id: "pt-BR-FranciscaNeural", name: "Francisca (PT-BR, Female)" }, + { id: "pt-BR-AntonioNeural", name: "Antonio (PT-BR, Male)" }, + { id: "fr-FR-DeniseNeural", name: "Denise (FR-FR, Female)" }, + { id: "de-DE-KatjaNeural", name: "Katja (DE-DE, Female)" }, + { id: "ja-JP-NanamiNeural", name: "Nanami (JA-JP, Female)" }, + { id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao (ZH-CN, Female)" }, + ], + }, + "xiaomi-mimo": { id: "xiaomi-mimo", baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index 16ffcd68ab..64c2d57b28 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -187,6 +187,12 @@ export const EMBEDDING_PROVIDERS: Record = { ], }, + // #6976 — OpenRouter serves embeddings via a dedicated OpenAI-compatible + // /api/v1/embeddings endpoint (omitted from /v1/models, so this catalog is + // curated rather than live-discovered). Ids verified against the API + // reference (not the display-name collections page) at refresh time: + // https://openrouter.ai/docs/api/reference/embeddings and + // https://openrouter.ai/collections/embedding-models openrouter: { id: "openrouter", baseUrl: "https://openrouter.ai/api/v1/embeddings", @@ -204,9 +210,29 @@ export const EMBEDDING_PROVIDERS: Record = { dimensions: 3072, }, { - id: "openai/text-embedding-ada-002", - name: "Text Embedding Ada 002 (OpenRouter)", - dimensions: 1536, + id: "qwen/qwen3-embedding-8b", + name: "Qwen3 Embedding 8B (OpenRouter)", + dimensions: 4096, + }, + { + id: "qwen/qwen3-embedding-4b", + name: "Qwen3 Embedding 4B (OpenRouter)", + dimensions: 2560, + }, + { + id: "baai/bge-m3", + name: "BGE-M3 (OpenRouter)", + dimensions: 1024, + }, + { + id: "mistralai/mistral-embed-2312", + name: "Mistral Embed (OpenRouter)", + dimensions: 1024, + }, + { + id: "google/gemini-embedding-001", + name: "Gemini Embedding 001 (OpenRouter)", + dimensions: 768, }, ], }, @@ -270,6 +296,29 @@ export const EMBEDDING_PROVIDERS: Record = { { id: "jina-colbert-v2", name: "Jina ColBERT v2", dimensions: 128 }, ], }, + + // Issue #6660: Mixedbread AI — OpenAI-compatible /v1/embeddings, free tier + // available (API key via signup, no card required). Model ids are the + // upstream-qualified "mixedbread-ai/" form, mirroring how `together`/ + // `fireworks` register fully-qualified upstream model ids above. + mixedbread: { + id: "mixedbread", + baseUrl: "https://api.mixedbread.com/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + models: [ + { + id: "mixedbread-ai/mxbai-embed-large-v1", + name: "Mixedbread Embed Large v1", + dimensions: 1024, + }, + { + id: "mixedbread-ai/mxbai-embed-2d-large-v1", + name: "Mixedbread Embed 2D Large v1", + dimensions: 1024, + }, + ], + }, }; const EMBEDDING_PROVIDER_ALIASES: Record = { diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index a9accafb60..6b807affb1 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -148,6 +148,11 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "duckduckgo-web", modelId: "llama-4-scout", displayName: "Llama 4 Scout", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" }, { provider: "duckduckgo-web", modelId: "mistral-small-2501", displayName: "Mistral Small", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" }, { provider: "duckduckgo-web", modelId: "o3-mini", displayName: "O3 Mini", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" }, + { provider: "felo-web", modelId: "felo-chat", displayName: "Felo Chat", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" }, + { provider: "felo-web", modelId: "felo-search", displayName: "Felo Search", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" }, + { provider: "felo-web", modelId: "felo-scholar", displayName: "Felo Scholar", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" }, + { provider: "felo-web", modelId: "felo-social", displayName: "Felo Social", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" }, + { provider: "felo-web", modelId: "felo-document", displayName: "Felo Document", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" }, { provider: "fireworks", modelId: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "fireworks", tos: "avoid" }, { provider: "fireworks", modelId: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "fireworks", tos: "avoid" }, { provider: "fireworks", modelId: "glm-5p1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "fireworks", tos: "avoid" }, diff --git a/open-sse/config/freeTierCatalog.ts b/open-sse/config/freeTierCatalog.ts index 5262697625..c6e17698e1 100644 --- a/open-sse/config/freeTierCatalog.ts +++ b/open-sse/config/freeTierCatalog.ts @@ -41,6 +41,7 @@ export const FREE_TIER_BUDGETS: Record = { export const FREE_TIER_TOS: Record = { opencode: "avoid", "duckduckgo-web": "avoid", + "felo-web": "avoid", agy: "avoid", kiro: "avoid", "amazon-q": "avoid", diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 41850fe2cf..109d494b03 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -6,6 +6,9 @@ */ 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"; interface ImageModelEntry { id: string; @@ -168,6 +171,17 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], }, + "microsoft-designer-web": { + id: "microsoft-designer-web", + alias: "msdesigner", + baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx?action=GetDallEImagesCogSci", + authType: "apikey", + authHeader: "bearer", + format: "designer-web", + models: [{ id: "dall-e-3", name: "DALL-E 3 (Microsoft Designer Web)" }], + supportedSizes: ["1024x1024", "1792x1024", "1024x1792"], + }, + xai: { id: "xai", baseUrl: "https://api.x.ai/v1/images/generations", @@ -311,44 +325,7 @@ export const IMAGE_PROVIDERS: Record = { authType: "apikey", authHeader: "bearer", format: "kie-image", - models: [ - { id: "gpt4o-image", name: "KIE 4o Image" }, - { id: "seedream/4.5-text-to-image", name: "Seedream 4.5", isMarket: true }, - { id: "seedream/4.5-edit", name: "Seedream 4.5 Edit", isMarket: true }, - { id: "seedream/5.0-lite-text-to-image", name: "Seedream 5.0 Lite", isMarket: true }, - { id: "seedream/5.0-lite-image-to-image", name: "Seedream 5.0 Lite I2I", isMarket: true }, - { id: "z-image/4.0-text-to-image", name: "Z-Image v4.0", isMarket: true }, - { id: "z-image/4.5-text-to-image", name: "Z-Image v4.5", isMarket: true }, - { id: "google-imagen/imagen4-fast", name: "Imagen 4 Fast", isMarket: true }, - { id: "google-imagen/imagen4-ultra", name: "Imagen 4 Ultra", isMarket: true }, - { id: "google-imagen/imagen4", name: "Imagen 4", isMarket: true }, - { id: "google-imagen/nano-banana-2", name: "Nano Banana 2", isMarket: true }, - { id: "google-imagen/nano-banana", name: "Nano Banana", isMarket: true }, - { id: "google-imagen/nano-banana-pro", name: "Nano Banana Pro", isMarket: true }, - { id: "google-imagen/nano-banana-edit", name: "Nano Banana Edit", isMarket: true }, - { id: "flux/2-pro-image-to-image", name: "Flux 2 Pro I2I", isMarket: true }, - { id: "flux/2-pro-text-to-image", name: "Flux 2 Pro T2I", isMarket: true }, - { id: "flux/2-image-to-image", name: "Flux 2 I2I", isMarket: true }, - { id: "flux/2-text-to-image", name: "Flux 2 T2I", isMarket: true }, - { id: "flux/kontext", name: "Flux Kontext", isMarket: true }, - { id: "grok-imagine/text-to-image", name: "Grok Imagine T2I", isMarket: true }, - { id: "grok-imagine/image-to-image", name: "Grok Imagine I2I", isMarket: true }, - { id: "gpt/gpt-image-1.5-text-to-image", name: "GPT Image 1.5 T2I", isMarket: true }, - { id: "gpt/gpt-image-1.5-image-to-image", name: "GPT Image 1.5 I2I", isMarket: true }, - { id: "gpt/gpt-image-2-text-to-image", name: "GPT Image 2 T2I", isMarket: true }, - { id: "gpt/gpt-image-2-image-to-image", name: "GPT Image 2 I2I", isMarket: true }, - { id: "ideogram/v3-text-to-image", name: "Ideogram v3", isMarket: true }, - { id: "ideogram/v3-edit", name: "Ideogram v3 Edit", isMarket: true }, - { id: "ideogram/v3-remix", name: "Ideogram v3 Remix", isMarket: true }, - { id: "ideogram/v3-reframe", name: "Ideogram v3 Reframe", isMarket: true }, - { id: "qwen/text-to-image", name: "Qwen T2I", isMarket: true }, - { id: "qwen/image-to-image", name: "Qwen I2I", isMarket: true }, - { id: "qwen/image-edit", name: "Qwen Edit", isMarket: true }, - { id: "qwen2/image-edit", name: "Qwen2 Edit", isMarket: true }, - { id: "qwen2/text-to-image", name: "Qwen2 T2I", isMarket: true }, - { id: "wan/2.7-image", name: "Wan 2.7 Image", isMarket: true }, - { id: "wan/2.7-image-pro", name: "Wan 2.7 Image Pro", isMarket: true }, - ], + models: KIE_IMAGE_MODELS, supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4"], }, @@ -362,6 +339,21 @@ export const IMAGE_PROVIDERS: Record = { models: [{ id: "gen2", name: "Gen 2 Image" }], supportedSizes: ["16:9", "9:16", "1:1", "4:3", "3:4"], }, + // #2482: MiniMax already has entries in musicRegistry/audioRegistry/videoRegistry, + // but was missing an image provider entirely, so MiniMax image-model requests + // fell through the format dispatch below to a 400/unmatched-format response. + minimax: { + id: "minimax", + baseUrl: "https://api.minimax.io/v1/image_generation", + authType: "apikey", + authHeader: "bearer", + format: "minimax-image", + models: [ + { id: "image-01", name: "MiniMax Image-01" }, + { id: "image-01-live", name: "MiniMax Image-01 Live" }, + ], + supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "1024x1024"], + }, leonardo: { id: "leonardo", baseUrl: "https://cloud.leonardo.ai/api/rest/v1/generations", @@ -386,6 +378,7 @@ export const IMAGE_PROVIDERS: Record = { ], supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], }, + freepik: FREEPIK_IMAGE_PROVIDER, sdwebui: { id: "sdwebui", baseUrl: "http://localhost:7860/sdapi/v1/txt2img", @@ -554,6 +547,9 @@ export const IMAGE_PROVIDERS: Record = { models: [{ id: "topaz-enhance", name: "topaz-enhance", inputModalities: ["image"] }], supportedSizes: ["1024x1024"], }, + + // Segmind (#6656): 200+ models, `POST /v1/{model}`, x-api-key, raw image bytes. + segmind: SEGMIND_IMAGE_PROVIDER, nanogpt: { id: "nanogpt", baseUrl: "https://nano-gpt.com/api/v1/images/generations", diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 307afe2a18..051783f6c9 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -39,6 +39,9 @@ export function generateLegacyProviders(): Record { if (entry.responsesBaseUrl) { p.responsesBaseUrl = entry.responsesBaseUrl; } + if (entry.messagesUrl) { + p.messagesUrl = entry.messagesUrl; + } if (entry.requestDefaults) { p.requestDefaults = entry.requestDefaults; } diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index ae083ecf34..c3943a95e6 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -9,6 +9,7 @@ import { ideogramProvider } from "./registry/ideogram/index.ts"; import { friendliaiProvider } from "./registry/friendliai/index.ts"; import { sunoProvider } from "./registry/suno/index.ts"; import { adapta_webProvider } from "./registry/adapta-web/index.ts"; +import { notion_webProvider } from "./registry/notion-web/index.ts"; import { anthropicProvider } from "./registry/anthropic/index.ts"; import { sambanovaProvider } from "./registry/sambanova/index.ts"; import { puterProvider } from "./registry/puter/index.ts"; @@ -64,6 +65,7 @@ import { cohereProvider } from "./registry/cohere/index.ts"; import { cursorProvider } 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"; import { tencentProvider } from "./registry/tencent/index.ts"; import { cozeProvider } from "./registry/coze/index.ts"; import { ai21Provider } from "./registry/ai21/index.ts"; @@ -121,6 +123,7 @@ import { longcatProvider } from "./registry/longcat/index.ts"; import { vertex_partnerProvider } from "./registry/vertex/partner/index.ts"; import { vertexProvider } from "./registry/vertex/index.ts"; import { duckduckgo_webProvider } from "./registry/duckduckgo-web/index.ts"; +import { felo_webProvider } from "./registry/felo-web/index.ts"; import { xaiProvider } from "./registry/xai/index.ts"; import { morphProvider } from "./registry/morph/index.ts"; import { siliconflowProvider } from "./registry/siliconflow/index.ts"; @@ -151,6 +154,7 @@ import { gigachatProvider } from "./registry/gigachat/index.ts"; import { devin_cliProvider } from "./registry/devin-cli/index.ts"; import { auggieProvider } from "./registry/auggie/index.ts"; import { chutesProvider } from "./registry/chutes/index.ts"; +import { chenzkProvider } from "./registry/chenzk/index.ts"; import { factoryProvider } from "./registry/factory/index.ts"; import { databricksProvider } from "./registry/databricks/index.ts"; import { rekaProvider } from "./registry/reka/index.ts"; @@ -195,6 +199,7 @@ export const REGISTRY: Record = { friendliai: friendliaiProvider, suno: sunoProvider, "adapta-web": adapta_webProvider, + "notion-web": notion_webProvider, anthropic: anthropicProvider, sambanova: sambanovaProvider, puter: puterProvider, @@ -250,6 +255,7 @@ export const REGISTRY: Record = { cursor: cursorProvider, volcengine: volcengineProvider, hackclub: hackclubProvider, + freetheai: freetheaiProvider, tencent: tencentProvider, coze: cozeProvider, ai21: ai21Provider, @@ -307,6 +313,7 @@ export const REGISTRY: Record = { "vertex-partner": vertex_partnerProvider, vertex: vertexProvider, "duckduckgo-web": duckduckgo_webProvider, + "felo-web": felo_webProvider, xai: xaiProvider, morph: morphProvider, siliconflow: siliconflowProvider, @@ -336,6 +343,7 @@ export const REGISTRY: Record = { "devin-cli": devin_cliProvider, auggie: auggieProvider, chutes: chutesProvider, + chenzk: chenzkProvider, factory: factoryProvider, databricks: databricksProvider, reka: rekaProvider, diff --git a/open-sse/config/providers/registry/chenzk/index.ts b/open-sse/config/providers/registry/chenzk/index.ts new file mode 100644 index 0000000000..ee74a4d46f --- /dev/null +++ b/open-sse/config/providers/registry/chenzk/index.ts @@ -0,0 +1,15 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const chenzkProvider: RegistryEntry = { + id: "chenzk", + alias: "chenzk", + format: "openai", + executor: "default", + baseUrl: "https://chenzk.top/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + modelsUrl: "https://chenzk.top/v1/models", + defaultContextLength: 128000, + models: [], + passthroughModels: true, +}; diff --git a/open-sse/config/providers/registry/felo-web/index.ts b/open-sse/config/providers/registry/felo-web/index.ts new file mode 100644 index 0000000000..4cf8fcae25 --- /dev/null +++ b/open-sse/config/providers/registry/felo-web/index.ts @@ -0,0 +1,18 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const felo_webProvider: RegistryEntry = { + id: "felo-web", + alias: "felo", + format: "openai", + executor: "felo-web", + baseUrl: "https://felo.ai/api-proxy/main/search/threads", + authType: "none", + authHeader: "none", + models: [ + { id: "felo-chat", name: "Felo Chat" }, + { id: "felo-search", name: "Felo Search" }, + { id: "felo-scholar", name: "Felo Scholar" }, + { id: "felo-social", name: "Felo Social" }, + { id: "felo-document", name: "Felo Document" }, + ], +}; diff --git a/open-sse/config/providers/registry/freepik/index.ts b/open-sse/config/providers/registry/freepik/index.ts new file mode 100644 index 0000000000..7b99c71168 --- /dev/null +++ b/open-sse/config/providers/registry/freepik/index.ts @@ -0,0 +1,26 @@ +/** + * 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/freetheai/index.ts b/open-sse/config/providers/registry/freetheai/index.ts new file mode 100644 index 0000000000..10c9033dcd --- /dev/null +++ b/open-sse/config/providers/registry/freetheai/index.ts @@ -0,0 +1,23 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// FreeTheAi — OpenAI-compatible gateway with a Discord-signup free tier +// (issue #6670). Same shape as the hackclub/chutes aggregator entries: +// standard OpenAI chat/completions + /v1/models discovery, so no custom +// executor/translator is needed. +export const freetheaiProvider: RegistryEntry = { + id: "freetheai", + alias: "fta", + format: "openai", + executor: "default", + baseUrl: "https://api.freetheai.xyz/v1/chat/completions", + modelsUrl: "https://api.freetheai.xyz/v1/models", + authType: "apikey", + authHeader: "bearer", + passthroughModels: true, + defaultContextLength: 128000, + models: [ + { id: "gpt-4o-mini", name: "GPT-4o Mini" }, + { id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B" }, + { id: "deepseek-chat", name: "DeepSeek Chat" }, + ], +}; diff --git a/open-sse/config/providers/registry/github/index.ts b/open-sse/config/providers/registry/github/index.ts index 41673dcf7d..e4f6f7e563 100644 --- a/open-sse/config/providers/registry/github/index.ts +++ b/open-sse/config/providers/registry/github/index.ts @@ -12,6 +12,12 @@ export const githubProvider: RegistryEntry = { executor: "github", baseUrl: "https://api.githubcopilot.com/chat/completions", responsesBaseUrl: "https://api.githubcopilot.com/responses", + // Anthropic-native shim: the only Copilot endpoint that surfaces prompt-cache + // token counts (cached_tokens) for Claude models, and avoids round-tripping + // tool_use/tool_result/thinking content blocks through the OpenAI shape. + // Routed via each claude-* model's targetFormat: "claude" below (see + // executors/github.ts buildUrl/buildHeaders). Port of decolua/9router#2608. + messagesUrl: "https://api.githubcopilot.com/v1/messages", authType: "oauth", authHeader: "bearer", // GitHub Copilot is a public device-flow OAuth client: it has a public client_id but @@ -24,16 +30,23 @@ export const githubProvider: RegistryEntry = { }, defaultContextLength: 128000, headers: getGitHubCopilotChatHeaders(), + // All claude-* entries below carry targetFormat: "claude" so chatCore.ts + // translates the request to Anthropic-native shape before the executor ever + // sees it, and the github executor's buildUrl()/buildHeaders() route them at + // messagesUrl (/v1/messages) instead of /chat/completions. Port of + // decolua/9router#2608 (author: yidecode) — see executors/github.ts. models: [ { id: "claude-fable-5", name: "Claude Fable 5", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-opus-4.8-fast", name: "Claude Opus 4.8 (fast mode)", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, unsupportedParams: ["temperature", "top_p", "top_k"], @@ -41,6 +54,7 @@ export const githubProvider: RegistryEntry = { { id: "claude-opus-4.8", name: "Claude Opus 4.8", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, unsupportedParams: ["temperature", "top_p", "top_k"], @@ -48,36 +62,42 @@ export const githubProvider: RegistryEntry = { { id: "claude-opus-4.7", name: "Claude Opus 4.7", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-opus-4.5", name: "Claude Opus 4.5", + targetFormat: "claude", contextLength: 200000, maxOutputTokens: 32000, }, { id: "claude-sonnet-5", name: "Claude Sonnet 5", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", + targetFormat: "claude", contextLength: 200000, maxOutputTokens: 32000, }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", + targetFormat: "claude", contextLength: 200000, maxOutputTokens: 32000, }, diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts index 463e103ba6..dd9c21150c 100644 --- a/open-sse/config/providers/registry/grok-cli/index.ts +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -15,13 +15,31 @@ export const grok_cliProvider: RegistryEntry = { id: "grok-build", name: "Grok Build", contextLength: 256000, - unsupportedParams: ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"], + // cli-chat-proxy rejects reasoning_effort/reasoning outright (see grok-cli.ts + // executor's transformRequest, which strips them unconditionally for this model). + supportsReasoning: false, + unsupportedParams: [ + "presencePenalty", + "frequencyPenalty", + "logprobs", + "topLogprobs", + "reasoningEffort", + ], }, { id: "grok-composer-2.5-fast", name: "Grok Composer 2.5 Fast", contextLength: 200000, - unsupportedParams: ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"], + // cli-chat-proxy rejects reasoning_effort/reasoning outright (see grok-cli.ts + // executor's transformRequest, which strips them unconditionally for this model). + supportsReasoning: false, + unsupportedParams: [ + "presencePenalty", + "frequencyPenalty", + "logprobs", + "topLogprobs", + "reasoningEffort", + ], }, ], oauth: { diff --git a/open-sse/config/providers/registry/kie/imageModels.ts b/open-sse/config/providers/registry/kie/imageModels.ts new file mode 100644 index 0000000000..5fbcd7b18d --- /dev/null +++ b/open-sse/config/providers/registry/kie/imageModels.ts @@ -0,0 +1,55 @@ +/** + * KIE image-generation model catalog. + * + * Extracted out of imageRegistry.ts (which hit the 800-line file-size cap) so the + * catalog lives in its own semantic family module, following the same pattern as + * `providers/registry/lmarena/directModels.ts`. KIE aggregates many third-party + * image models (Seedream, Z-Image, Imagen, Flux, Grok Imagine, GPT Image, Ideogram, + * Qwen, Wan) behind a single `kie-image` format/handler — see `imageRegistry.ts`'s + * `kie` entry for baseUrl/auth/format wiring. + */ + +export interface KieImageModelEntry { + id: string; + name: string; + isMarket?: boolean; +} + +export const KIE_IMAGE_MODELS: KieImageModelEntry[] = [ + { id: "gpt4o-image", name: "KIE 4o Image" }, + { id: "seedream/4.5-text-to-image", name: "Seedream 4.5", isMarket: true }, + { id: "seedream/4.5-edit", name: "Seedream 4.5 Edit", isMarket: true }, + { id: "seedream/5.0-lite-text-to-image", name: "Seedream 5.0 Lite", isMarket: true }, + { id: "seedream/5.0-lite-image-to-image", name: "Seedream 5.0 Lite I2I", isMarket: true }, + { id: "z-image/4.0-text-to-image", name: "Z-Image v4.0", isMarket: true }, + { id: "z-image/4.5-text-to-image", name: "Z-Image v4.5", isMarket: true }, + { id: "google-imagen/imagen4-fast", name: "Imagen 4 Fast", isMarket: true }, + { id: "google-imagen/imagen4-ultra", name: "Imagen 4 Ultra", isMarket: true }, + { id: "google-imagen/imagen4", name: "Imagen 4", isMarket: true }, + { id: "google-imagen/nano-banana-2", name: "Nano Banana 2", isMarket: true }, + { id: "google-imagen/nano-banana", name: "Nano Banana", isMarket: true }, + { id: "google-imagen/nano-banana-pro", name: "Nano Banana Pro", isMarket: true }, + { id: "google-imagen/nano-banana-edit", name: "Nano Banana Edit", isMarket: true }, + { id: "flux/2-pro-image-to-image", name: "Flux 2 Pro I2I", isMarket: true }, + { id: "flux/2-pro-text-to-image", name: "Flux 2 Pro T2I", isMarket: true }, + { id: "flux/2-image-to-image", name: "Flux 2 I2I", isMarket: true }, + { id: "flux/2-text-to-image", name: "Flux 2 T2I", isMarket: true }, + { id: "flux/kontext", name: "Flux Kontext", isMarket: true }, + { id: "grok-imagine/text-to-image", name: "Grok Imagine T2I", isMarket: true }, + { id: "grok-imagine/image-to-image", name: "Grok Imagine I2I", isMarket: true }, + { id: "gpt/gpt-image-1.5-text-to-image", name: "GPT Image 1.5 T2I", isMarket: true }, + { id: "gpt/gpt-image-1.5-image-to-image", name: "GPT Image 1.5 I2I", isMarket: true }, + { id: "gpt/gpt-image-2-text-to-image", name: "GPT Image 2 T2I", isMarket: true }, + { id: "gpt/gpt-image-2-image-to-image", name: "GPT Image 2 I2I", isMarket: true }, + { id: "ideogram/v3-text-to-image", name: "Ideogram v3", isMarket: true }, + { id: "ideogram/v3-edit", name: "Ideogram v3 Edit", isMarket: true }, + { id: "ideogram/v3-remix", name: "Ideogram v3 Remix", isMarket: true }, + { id: "ideogram/v3-reframe", name: "Ideogram v3 Reframe", isMarket: true }, + { id: "qwen/text-to-image", name: "Qwen T2I", isMarket: true }, + { id: "qwen/image-to-image", name: "Qwen I2I", isMarket: true }, + { id: "qwen/image-edit", name: "Qwen Edit", isMarket: true }, + { id: "qwen2/image-edit", name: "Qwen2 Edit", isMarket: true }, + { id: "qwen2/text-to-image", name: "Qwen2 T2I", isMarket: true }, + { id: "wan/2.7-image", name: "Wan 2.7 Image", isMarket: true }, + { id: "wan/2.7-image-pro", name: "Wan 2.7 Image Pro", isMarket: true }, +]; diff --git a/open-sse/config/providers/registry/kie/models.ts b/open-sse/config/providers/registry/kie/models.ts new file mode 100644 index 0000000000..78cf81658b --- /dev/null +++ b/open-sse/config/providers/registry/kie/models.ts @@ -0,0 +1,43 @@ +/** + * KIE image-provider model catalog — extracted from imageRegistry.ts + * (god-file decomposition, mirrors the lmarena/directModels.ts pattern). + * Pure data literal; imported by imageRegistry.ts. No behavior change. + */ +export const KIE_IMAGE_MODELS = [ + { id: "gpt4o-image", name: "KIE 4o Image" }, + { id: "seedream/4.5-text-to-image", name: "Seedream 4.5", isMarket: true }, + { id: "seedream/4.5-edit", name: "Seedream 4.5 Edit", isMarket: true }, + { id: "seedream/5.0-lite-text-to-image", name: "Seedream 5.0 Lite", isMarket: true }, + { id: "seedream/5.0-lite-image-to-image", name: "Seedream 5.0 Lite I2I", isMarket: true }, + { id: "z-image/4.0-text-to-image", name: "Z-Image v4.0", isMarket: true }, + { id: "z-image/4.5-text-to-image", name: "Z-Image v4.5", isMarket: true }, + { id: "google-imagen/imagen4-fast", name: "Imagen 4 Fast", isMarket: true }, + { id: "google-imagen/imagen4-ultra", name: "Imagen 4 Ultra", isMarket: true }, + { id: "google-imagen/imagen4", name: "Imagen 4", isMarket: true }, + { id: "google-imagen/nano-banana-2", name: "Nano Banana 2", isMarket: true }, + { id: "google-imagen/nano-banana", name: "Nano Banana", isMarket: true }, + { id: "google-imagen/nano-banana-pro", name: "Nano Banana Pro", isMarket: true }, + { id: "google-imagen/nano-banana-edit", name: "Nano Banana Edit", isMarket: true }, + { id: "flux/2-pro-image-to-image", name: "Flux 2 Pro I2I", isMarket: true }, + { id: "flux/2-pro-text-to-image", name: "Flux 2 Pro T2I", isMarket: true }, + { id: "flux/2-image-to-image", name: "Flux 2 I2I", isMarket: true }, + { id: "flux/2-text-to-image", name: "Flux 2 T2I", isMarket: true }, + { id: "flux/kontext", name: "Flux Kontext", isMarket: true }, + { id: "grok-imagine/text-to-image", name: "Grok Imagine T2I", isMarket: true }, + { id: "grok-imagine/image-to-image", name: "Grok Imagine I2I", isMarket: true }, + { id: "gpt/gpt-image-1.5-text-to-image", name: "GPT Image 1.5 T2I", isMarket: true }, + { id: "gpt/gpt-image-1.5-image-to-image", name: "GPT Image 1.5 I2I", isMarket: true }, + { id: "gpt/gpt-image-2-text-to-image", name: "GPT Image 2 T2I", isMarket: true }, + { id: "gpt/gpt-image-2-image-to-image", name: "GPT Image 2 I2I", isMarket: true }, + { id: "ideogram/v3-text-to-image", name: "Ideogram v3", isMarket: true }, + { id: "ideogram/v3-edit", name: "Ideogram v3 Edit", isMarket: true }, + { id: "ideogram/v3-remix", name: "Ideogram v3 Remix", isMarket: true }, + { id: "ideogram/v3-reframe", name: "Ideogram v3 Reframe", isMarket: true }, + { id: "qwen/text-to-image", name: "Qwen T2I", isMarket: true }, + { id: "qwen/image-to-image", name: "Qwen I2I", isMarket: true }, + { id: "qwen/image-edit", name: "Qwen Edit", isMarket: true }, + { id: "qwen2/image-edit", name: "Qwen2 Edit", isMarket: true }, + { id: "qwen2/text-to-image", name: "Qwen2 T2I", isMarket: true }, + { id: "wan/2.7-image", name: "Wan 2.7 Image", isMarket: true }, + { id: "wan/2.7-image-pro", name: "Wan 2.7 Image Pro", isMarket: true }, +]; diff --git a/open-sse/config/providers/registry/kiro/index.ts b/open-sse/config/providers/registry/kiro/index.ts index 71262a4f09..cda51e77c8 100644 --- a/open-sse/config/providers/registry/kiro/index.ts +++ b/open-sse/config/providers/registry/kiro/index.ts @@ -46,5 +46,26 @@ export const kiroProvider: RegistryEntry = { { id: "minimax-m2.1", name: "MiniMax M2.1" }, { id: "glm-5", name: "GLM-5" }, { id: "qwen3-coder-next", name: "Qwen3 Coder Next" }, + // Kiro's first OpenAI-family models (kiro.dev/changelog/models, 2026-07-14): + // three tiers — Sol (flagship), Terra (balanced mid-tier), Luna (fastest/ + // cheapest) — all sharing the announced 272k context window. + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + contextLength: 272000, + maxOutputTokens: 128000, + }, + { + id: "gpt-5.6-terra", + name: "GPT-5.6 Terra", + contextLength: 272000, + maxOutputTokens: 128000, + }, + { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + contextLength: 272000, + maxOutputTokens: 128000, + }, ], }; diff --git a/open-sse/config/providers/registry/notion-web/index.ts b/open-sse/config/providers/registry/notion-web/index.ts new file mode 100644 index 0000000000..cca2160a4c --- /dev/null +++ b/open-sse/config/providers/registry/notion-web/index.ts @@ -0,0 +1,20 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// Notion AI Web (Unofficial/Experimental) — see open-sse/executors/notion-web.ts +// for the reverse-engineering rationale (issue #6758, closed native-provider +// request #3272). Notion AI does not expose a documented, selectable model +// catalog through its internal endpoint — the assistant response is server-side +// routed. `passthroughModels: true` lets an operator pass any model id the +// endpoint may honor in the future without a registry change; the single +// `notion-ai` entry is the default/safe fallback shown in the picker. +export const notion_webProvider: RegistryEntry = { + id: "notion-web", + alias: "nw", + format: "openai", + executor: "notion-web", + baseUrl: "https://www.notion.so/api/v3/runInferenceTranscript", + authType: "apikey", + authHeader: "cookie", + passthroughModels: true, + models: [{ id: "notion-ai", name: "Notion AI (Unofficial/Experimental)" }], +}; diff --git a/open-sse/config/providers/registry/nvidia/index.ts b/open-sse/config/providers/registry/nvidia/index.ts index dd2516a4a1..64871e95ba 100644 --- a/open-sse/config/providers/registry/nvidia/index.ts +++ b/open-sse/config/providers/registry/nvidia/index.ts @@ -39,5 +39,93 @@ export const nvidiaProvider: RegistryEntry = { { id: "openai/gpt-oss-20b", name: "GPT OSS 20B", toolCalling: false }, { id: "nvidia/nemotron-3-super-120b-a12b", name: "Nemotron 3 Super 120B A12B" }, { id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra 550B" }, + // Port of decolua/9router#2373 ("fix(nvidia): expand NIM chat model catalog"): + // additional live-catalog models observed to serve /v1/chat/completions. + // `minimaxai/minimax-m3` from that PR is intentionally NOT re-added — it stays + // excluded per the #3329 guard (nvidia-minimax-m3-removed-3329.test.ts). + // Non-chat entries from the same PR (nvidia/gliner-pii — NER tagger, not a chat + // model; google/diffusiongemma-26b-a4b-it — diffusion model) are dropped for the + // same reason: this registry only models the /v1/chat/completions surface. + { id: "abacusai/dracarys-llama-3.1-70b-instruct", name: "Dracarys Llama 3.1 70B Instruct" }, + { id: "google/gemma-2-2b-it", name: "Gemma 2 2B IT" }, + { id: "google/gemma-3n-e2b-it", name: "Gemma 3n E2B IT" }, + { id: "meta/llama-3.1-8b-instruct", name: "Llama 3.1 8B Instruct" }, + { + id: "meta/llama-3.2-11b-vision-instruct", + name: "Llama 3.2 11B Vision Instruct", + supportsVision: true, + }, + { id: "meta/llama-3.2-1b-instruct", name: "Llama 3.2 1B Instruct" }, + { id: "meta/llama-3.2-3b-instruct", name: "Llama 3.2 3B Instruct" }, + { + id: "meta/llama-3.2-90b-vision-instruct", + name: "Llama 3.2 90B Vision Instruct", + supportsVision: true, + }, + { id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick 17B 128E Instruct" }, + { id: "meta/llama-guard-4-12b", name: "Llama Guard 4 12B" }, + { id: "mistralai/ministral-14b-instruct-2512", name: "Ministral 14B Instruct 2512" }, + { id: "mistralai/mistral-medium-3.5-128b", name: "Mistral Medium 3.5 128B" }, + { id: "mistralai/mistral-nemotron", name: "Mistral Nemotron" }, + { id: "mistralai/mixtral-8x7b-instruct-v0.1", name: "Mixtral 8x7B Instruct v0.1" }, + { + id: "nvidia/ising-calibration-1-35b-a3b", + name: "Ising Calibration 1 35B A3B", + supportsReasoning: true, + }, + { + id: "nvidia/llama-3.1-nemoguard-8b-content-safety", + name: "Llama 3.1 Nemoguard 8B Content Safety", + }, + { + id: "nvidia/llama-3.1-nemoguard-8b-topic-control", + name: "Llama 3.1 Nemoguard 8B Topic Control", + }, + { id: "nvidia/llama-3.1-nemotron-nano-8b-v1", name: "Llama 3.1 Nemotron Nano 8B v1" }, + { + id: "nvidia/llama-3.1-nemotron-nano-vl-8b-v1", + name: "Llama 3.1 Nemotron Nano VL 8B v1", + supportsVision: true, + }, + { + id: "nvidia/llama-3.1-nemotron-safety-guard-8b-v3", + name: "Llama 3.1 Nemotron Safety Guard 8B v3", + }, + { id: "nvidia/llama-3.3-nemotron-super-49b-v1", name: "Llama 3.3 Nemotron Super 49B v1" }, + { id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", name: "Llama 3.3 Nemotron Super 49B v1.5" }, + { id: "nvidia/nemotron-3-content-safety", name: "Nemotron 3 Content Safety" }, + { + id: "nvidia/nemotron-3-nano-30b-a3b", + name: "Nemotron 3 Nano 30B A3B", + supportsReasoning: true, + }, + { + id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + name: "Nemotron 3 Nano Omni 30B A3B Reasoning", + supportsReasoning: true, + supportsVision: true, + }, + { id: "nvidia/nemotron-3.5-content-safety", name: "Nemotron 3.5 Content Safety" }, + { id: "nvidia/nemotron-mini-4b-instruct", name: "Nemotron Mini 4B Instruct" }, + { + id: "nvidia/nemotron-nano-12b-v2-vl", + name: "Nemotron Nano 12B v2 VL", + supportsReasoning: true, + supportsVision: true, + }, + { + id: "nvidia/nvidia-nemotron-nano-9b-v2", + name: "NVIDIA Nemotron Nano 9B v2", + supportsReasoning: true, + }, + { id: "nvidia/riva-translate-4b-instruct-v1.1", name: "Riva Translate 4B Instruct v1.1" }, + { + id: "qwen/qwen3-next-80b-a3b-instruct", + name: "Qwen3 Next 80B A3B Instruct", + supportsReasoning: true, + }, + { id: "sarvamai/sarvam-m", name: "Sarvam M" }, + { id: "stockmark/stockmark-2-100b-instruct", name: "Stockmark 2 100B Instruct" }, + { id: "upstage/solar-10.7b-instruct", name: "Solar 10.7B Instruct" }, ], }; diff --git a/open-sse/config/providers/registry/segmind/imageModels.ts b/open-sse/config/providers/registry/segmind/imageModels.ts new file mode 100644 index 0000000000..f1b4b87043 --- /dev/null +++ b/open-sse/config/providers/registry/segmind/imageModels.ts @@ -0,0 +1,32 @@ +/** + * Segmind image-generation provider entry (#6656). + * + * Segmind exposes 200+ hosted models via a single `POST /v1/{model}` REST + * shape (https://docs.segmind.com/, confirmed live 2026-07-09): x-api-key + * auth, JSON request body, raw image bytes response (no JSON envelope). + * Kept as a full config object (not just the model list) so imageRegistry.ts + * only needs a single-line reference — that file sits right at its size cap. + * + * Model slugs below are a curated starter subset. flux-schnell and + * sdxl1.0-txt2img are verified against + * https://www.segmind.com/models//api; the remaining Flux/SD3/Kandinsky + * slugs follow the same documented naming convention. + */ +export const SEGMIND_IMAGE_MODELS = [ + { id: "flux-schnell", name: "FLUX.1 Schnell" }, + { id: "flux-dev", name: "FLUX.1 Dev" }, + { id: "flux-1.1-pro", name: "FLUX 1.1 Pro" }, + { id: "sdxl1.0-txt2img", name: "Stable Diffusion XL 1.0" }, + { id: "sd3.5-large-txt2img", name: "Stable Diffusion 3.5 Large" }, + { id: "kandinsky2.2-txt2img", name: "Kandinsky 2.2" }, +]; + +export const SEGMIND_IMAGE_PROVIDER = { + id: "segmind", + baseUrl: "https://api.segmind.com/v1", + authType: "apikey", + authHeader: "x-api-key", + format: "segmind", + models: SEGMIND_IMAGE_MODELS, + supportedSizes: ["512x512", "1024x1024", "1024x1792", "1792x1024"], +}; diff --git a/open-sse/config/providers/registry/segmind/videoModels.ts b/open-sse/config/providers/registry/segmind/videoModels.ts new file mode 100644 index 0000000000..ec04d9a13c --- /dev/null +++ b/open-sse/config/providers/registry/segmind/videoModels.ts @@ -0,0 +1,17 @@ +/** + * Segmind video-generation starter model list (#6656). + * + * Same `POST /v1/{model}` REST shape as the image models (imageModels.ts) — + * Segmind's video-capable models (Wan, Hunyuan, LTX, Kling) live under the + * same host/auth. Slugs verified against + * https://www.segmind.com/models/wan2.1-t2v/api and + * https://www.segmind.com/models/wan2.7-i2v/api (2026-07-09); the remaining + * Hunyuan/LTX/Kling slugs follow the same documented naming convention. + */ +export const SEGMIND_VIDEO_MODELS = [ + { id: "wan2.1-t2v", name: "Wan 2.1 Text-to-Video" }, + { id: "wan2.7-i2v", name: "Wan 2.7 Image-to-Video" }, + { id: "hunyuan-video-t2v", name: "Hunyuan Video Text-to-Video" }, + { id: "ltx-video-t2v", name: "LTX Video Text-to-Video" }, + { id: "kling-video-t2v", name: "Kling Video Text-to-Video" }, +]; diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index acf510ecf9..d022b86169 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -107,6 +107,10 @@ export interface RegistryEntry { /** Override base URL used only for API key validation (e.g., opencode-go validates on zen/v1) */ testKeyBaseUrl?: string; responsesBaseUrl?: string; + /** 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. */ + messagesUrl?: string; urlSuffix?: string; urlBuilder?: (base: string, model: string, stream: boolean) => string; authType: string; @@ -174,6 +178,7 @@ export interface LegacyProvider { baseUrl?: string; baseUrls?: string[]; responsesBaseUrl?: string; + messagesUrl?: string; headers?: Record; requestDefaults?: ProviderRequestDefaults; clientId?: string; @@ -285,7 +290,16 @@ export const GPT_5_5_CODEX_CAPABILITIES = { } as const; // Public OpenAI API limits. These differ from the Codex OAuth catalog limits below. +// Upstream port (decolua/9router#2547, closes #2540): OpenAI's Chat Completions +// endpoint rejects GPT-5.6 requests that combine function tools with an active +// reasoning_effort ("Function tools with reasoning_effort are not supported for +// in /v1/chat/completions. Please use /v1/responses instead."). Tag the +// whole public GPT-5.6 family with the existing generic targetFormat override +// (the same mechanism already routes gpt-5.5-pro / gpt-5.4-pro, #5842) so both +// the outbound URL (DefaultExecutor.buildUrl) and the body translation +// (chatCore's resolveChatCoreTargetFormat) go through api.openai.com/v1/responses. export const GPT_5_6_API_CAPABILITIES = { + targetFormat: "openai-responses", toolCalling: true, supportsReasoning: true, supportsVision: true, diff --git a/open-sse/config/registryUtils.ts b/open-sse/config/registryUtils.ts index 9bec2635d0..00041604c5 100644 --- a/open-sse/config/registryUtils.ts +++ b/open-sse/config/registryUtils.ts @@ -127,6 +127,8 @@ export function buildAuthHeaders( return { "xi-api-key": token }; case "x-api-key": return { "x-api-key": token }; + case "x-gladia-key": + return { "x-gladia-key": token }; case "bearer": default: return { Authorization: `Bearer ${token}` }; diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 442699b047..1eb75f7f65 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -7,6 +7,7 @@ import { parseModelFromRegistry, getAllModelsFromRegistry } from "./registryUtils.ts"; import { RUNWAYML_SUPPORTED_VIDEO_MODELS } from "./runway.ts"; +import { SEGMIND_VIDEO_MODELS } from "./providers/registry/segmind/videoModels.ts"; interface VideoModel { id: string; @@ -193,6 +194,23 @@ export const VIDEO_PROVIDERS: Record = { models: RUNWAYML_SUPPORTED_VIDEO_MODELS, }, + deepinfra: { + id: "deepinfra", + // Native DeepInfra inference endpoint — same host/auth already proven for reranking + // (open-sse/config/rerankRegistry.ts). Reuses the stored deepinfra provider Bearer + // apiKey (already registered for chat) — no separate credential flow. + baseUrl: "https://api.deepinfra.com/v1/inference", + authType: "apikey", + authHeader: "bearer", + format: "deepinfra-video", + models: [ + { id: "Wan-AI/Wan2.2-T2V-A14B", name: "Wan 2.2 T2V A14B" }, + { id: "Wan-AI/Wan2.2-TI2V-5B", name: "Wan 2.2 TI2V 5B" }, + { id: "Wan-AI/Wan2.7-T2V", name: "Wan 2.7 T2V" }, + { id: "Lightricks/LTX-2.3-Distilled", name: "LTX 2.3 Distilled" }, + ], + }, + alibaba: { id: "alibaba", alias: "ali", @@ -205,6 +223,47 @@ export const VIDEO_PROVIDERS: Record = { format: "dashscope-video", models: [{ id: "wan2.7-t2v", name: "Wan 2.7 T2V" }], }, + + // Segmind video generation (#6656). Same `POST /v1/{model}` REST shape as + // the image registry entry (imageRegistry.ts) — x-api-key auth, raw video + // bytes response — routed through the dedicated "segmind" format handler. + segmind: { + id: "segmind", + baseUrl: "https://api.segmind.com/v1", + authType: "apikey", + authHeader: "x-api-key", + format: "segmind", + models: SEGMIND_VIDEO_MODELS, + }, + + novita: { + id: "novita", + // Novita's async video APIs are per-model: the model id IS the submit path + // segment (`/v3/async/`), all sharing one task-result poll endpoint. + // Reuses the stored novita provider Bearer apiKey — no separate credential flow. + baseUrl: "https://api.novita.ai/v3/async", + statusUrl: "https://api.novita.ai/v3/async/task-result", + authType: "apikey", + authHeader: "bearer", + format: "novita-video", + models: [ + { id: "wan-t2v", name: "Wan 2.1 Text-to-Video" }, + { id: "kling-v1.6-t2v", name: "Kling V1.6 Text-to-Video" }, + ], + }, + + xai: { + id: "xai", + // xAI Grok Imagine async video-generation API. Reuses the stored xai + // provider Bearer apiKey (same credential the image-generation "xai" + // entry in imageRegistry.ts already uses) — no separate credential flow. + baseUrl: "https://api.x.ai/v1/videos", + statusUrl: "https://api.x.ai/v1/videos", + authType: "apikey", + authHeader: "bearer", + format: "xai-video", + models: [{ id: "grok-imagine-video", name: "Grok Imagine Video" }], + }, }; /** diff --git a/open-sse/executors/awsPollyTts.ts b/open-sse/executors/awsPollyTts.ts new file mode 100644 index 0000000000..311b2148dd --- /dev/null +++ b/open-sse/executors/awsPollyTts.ts @@ -0,0 +1,162 @@ +/** + * AWS Polly TTS handler. + * + * Extracted out of `open-sse/handlers/audioSpeech.ts` (frozen at its + * file-size ratchet baseline — config/quality/file-size-baseline.json) to + * make room for the new EdgeTTS WebSocket branch (#6668). Pure provider + * adapter, no behavior change vs. the original inline implementation. + * + * POST /v1/speech signed with AWS SigV4. The configured apiKey stores AWS + * Secret Access Key; providerSpecificData.accessKeyId stores AWS Access Key + * ID, with optional region/baseUrl/defaultVoice/sessionToken. + */ +import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; +import { signAwsRequest } from "../utils/awsSigV4.ts"; +import { errorResponse } from "../utils/error.ts"; +import { audioStreamResponse, upstreamErrorResponse } from "../utils/audioResponse.ts"; + +function getStringValue(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function getAwsPollyProviderData(credentials) { + return credentials?.providerSpecificData && + typeof credentials.providerSpecificData === "object" && + !Array.isArray(credentials.providerSpecificData) + ? credentials.providerSpecificData + : {}; +} + +function resolveAwsPollyRegion(providerSpecificData) { + return ( + getStringValue(providerSpecificData.region) || + getStringValue(providerSpecificData.awsRegion) || + process.env.AWS_REGION || + process.env.AWS_DEFAULT_REGION || + "us-east-1" + ); +} + +function resolveAwsPollyBaseUrl(providerSpecificData, region) { + const configuredBaseUrl = getStringValue(providerSpecificData.baseUrl); + const baseUrl = configuredBaseUrl || `https://polly.${region}.amazonaws.com`; + return stripTrailingSlashes(baseUrl.replace(/\/v1\/speech\/?$/i, "")); +} + +function normalizeAwsPollyEngine(modelId) { + const engine = getStringValue(modelId) || "standard"; + return ["standard", "neural", "long-form", "generative"].includes(engine) ? engine : "standard"; +} + +function normalizeAwsPollyOutputFormat(responseFormat) { + const format = getStringValue(responseFormat)?.toLowerCase(); + switch (format) { + case "pcm": + case "wav": + return "pcm"; + case "opus": + case "ogg_opus": + return "ogg_opus"; + case "ogg": + case "ogg_vorbis": + return "ogg_vorbis"; + case "json": + return "json"; + case "mp3": + default: + return "mp3"; + } +} + +function normalizeAwsPollyTextType(body) { + const explicitTextType = getStringValue(body.text_type || body.textType)?.toLowerCase(); + if (explicitTextType === "ssml") return "ssml"; + if (explicitTextType === "text") return "text"; + + const input = getStringValue(body.input) || ""; + return input.trim().startsWith(" { + const providerSpecificData = getAwsPollyProviderData(credentials); + const accessKeyId = + getStringValue(providerSpecificData.accessKeyId) || + getStringValue(providerSpecificData.awsAccessKeyId); + const secretAccessKey = getStringValue(token); + + if (!accessKeyId) { + return errorResponse(400, "AWS Polly requires providerSpecificData.accessKeyId"); + } + if (!secretAccessKey) { + return errorResponse(401, "No AWS Secret Access Key for AWS Polly"); + } + + const region = resolveAwsPollyRegion(providerSpecificData); + const baseUrl = resolveAwsPollyBaseUrl(providerSpecificData, region); + const url = `${baseUrl}/v1/speech`; + const outputFormat = normalizeAwsPollyOutputFormat(body.response_format); + const sampleRate = getAwsPollySampleRate( + body.response_format, + body.sample_rate || body.sampleRate + ); + + const requestBody = { + Engine: normalizeAwsPollyEngine(modelId), + OutputFormat: outputFormat, + Text: body.input, + TextType: normalizeAwsPollyTextType(body), + VoiceId: + getStringValue(body.voice) || getStringValue(providerSpecificData.defaultVoice) || "Joanna", + ...(getStringValue(body.language_code || body.languageCode) + ? { LanguageCode: getStringValue(body.language_code || body.languageCode) } + : {}), + ...(sampleRate ? { SampleRate: sampleRate } : {}), + }; + const serializedBody = JSON.stringify(requestBody); + + const signedHeaders = signAwsRequest({ + method: "POST", + url, + region, + service: "polly", + headers: { + "content-type": "application/json", + }, + body: serializedBody, + credentials: { + accessKeyId, + secretAccessKey, + sessionToken: + getStringValue(providerSpecificData.sessionToken) || + getStringValue(providerSpecificData.awsSessionToken), + }, + }); + + const res = await fetch(url, { + method: "POST", + headers: signedHeaders, + body: serializedBody, + }); + + if (!res.ok) { + return upstreamErrorResponse(res, await res.text()); + } + + return audioStreamResponse(res, outputFormat === "pcm" ? "audio/pcm" : "audio/mpeg"); +} diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 0058affe32..3d82de7680 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -117,6 +117,7 @@ export type ProviderConfig = { baseUrl?: string; baseUrls?: string[]; responsesBaseUrl?: string; + messagesUrl?: string; chatPath?: string; clientVersion?: string; clientId?: string; diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 8be020cf8a..d5075e2761 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -2565,6 +2565,17 @@ async function waitForImageViaWebSocket( conversation_id: innerPayload?.conversation_id as string | undefined, }); } + // #7357: some deployments deliver the completion via update_content.messages[] + // (plural array of { message: {...} } wrappers), not the singular field above. + for (const entry of Array.isArray(updateContent?.messages) ? updateContent.messages : []) { + const wrapped = (entry as { message?: unknown } | undefined)?.message; + if (wrapped) { + candidates.push({ + message: wrapped as ChatGptStreamEvent["message"], + conversation_id: innerPayload?.conversation_id as string | undefined, + }); + } + } if (innerPayload?.message) { candidates.push({ message: innerPayload.message as ChatGptStreamEvent["message"], diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index dd91bb64bb..50bc53c305 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -627,7 +627,15 @@ export async function peekCodexSseTransientError( response: Response ): Promise { const contentType = response.headers.get("content-type") || ""; - if (!response.ok || !response.body || !contentType.includes("text/event-stream")) { + // #7536: check content-type BEFORE touching `response.body`. On the wreq-js + // TLS-fingerprint transport (used by Codex), the Response is backed by a native + // body handle and merely accessing `.body` disturbs it, so a downstream + // `.text()` throws "Response body is already used". The Codex non-stream + // upstream response has an empty content-type, so it must short-circuit here + // WITHOUT reading `.body` — otherwise chatCore's readNonStreamingResponseBody + // 502s. Only genuine SSE responses (which this peek intends to buffer) reach + // the `.body` access below. + if (!response.ok || !contentType.includes("text/event-stream") || !response.body) { return { matched: null, message: null, replacementBody: null }; } @@ -675,11 +683,13 @@ export async function peekCodexSseTransientError( return { matched, message: extractCodexSseErrorMessage(text, matched), replacementBody: null }; } - reader.releaseLock(); - // Re-assemble the stream: peeked prefix chunks, then continue draining the - // same underlying body so bytes downstream of the peek window are untouched. - const upstreamReader = response.body.getReader(); + // SAME reader we already hold. The previous code called reader.releaseLock() + // and then response.body.getReader() a second time — but re-acquiring a reader + // on an already-disturbed body throws "Response body is already used" on + // undici (every non-stream Codex request 502'd, then got mis-classified as a + // 60s rate limit). Keep the original reader; never touch response.body again. + const upstreamReader = reader; const replacementBody = new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(chunk); diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index f3bd3930c8..9fad2305a4 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -52,6 +52,7 @@ import { normalizeGigachatChatUrl, } from "@/lib/providers/validation/urlHelpers"; import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; +import { resolveZaiUrl } from "./default/zaiFormatOverride.ts"; import type { PoolConfig } from "../services/sessionPool/types.ts"; @@ -242,10 +243,9 @@ export class DefaultExecutor extends BaseExecutor { return normalizeOpenAIChatUrl(baseUrl); } case "zai": - case "glm-coding-apikey": { - const zaiBaseUrl = this.resolveBaseUrl(credentials); - return `${zaiBaseUrl}?beta=true`; - } + case "glm-coding-apikey": + // #7364: format override extracted to zaiFormatOverride.ts (file-size ratchet). + return resolveZaiUrl(credentials, (fallback) => this.resolveBaseUrl(credentials, fallback)); case "claude": case "glm": case "glmt": diff --git a/open-sse/executors/default/zaiFormatOverride.ts b/open-sse/executors/default/zaiFormatOverride.ts new file mode 100644 index 0000000000..535e3cf34b --- /dev/null +++ b/open-sse/executors/default/zaiFormatOverride.ts @@ -0,0 +1,25 @@ +import { GLM_DEFAULT_BASE_URLS } from "../../config/glmProvider.ts"; + +type ZaiCredentialsLike = { + providerSpecificData?: { targetFormat?: unknown } | null; +} | null; + +/** + * #7364: "zai"/"glm-coding-apikey" default to the Anthropic Messages wire format + * (registry format:"claude"), but a per-model `targetFormat` override (custom-model + * dropdown, #2905) can resolve to "openai" — e.g. for a vision model like glm-4.6v + * that the operator wants routed through the OpenAI-compatible endpoint instead. + * chatCore/executionCredentials.ts threads that resolved override onto + * `providerSpecificData.targetFormat`; DefaultExecutor.buildUrl() has no other way + * to see it, so without this check every zai/glm-coding-apikey request silently hit + * the Claude-format endpoint regardless of the override. + */ +export function resolveZaiUrl( + credentials: ZaiCredentialsLike, + resolveBaseUrl: (fallback?: string) => string +): string { + if (credentials?.providerSpecificData?.targetFormat === "openai") { + return resolveBaseUrl(GLM_DEFAULT_BASE_URLS.international); + } + return `${resolveBaseUrl()}?beta=true`; +} diff --git a/open-sse/executors/edgeTts.ts b/open-sse/executors/edgeTts.ts new file mode 100644 index 0000000000..74a9c7f333 --- /dev/null +++ b/open-sse/executors/edgeTts.ts @@ -0,0 +1,354 @@ +/** + * EdgeTTS — Microsoft Edge "Read Aloud" text-to-speech (#6668). + * + * Reverse-engineered, unofficial, undocumented endpoint (not a published + * Microsoft public API) — the same class of integration this codebase + * already accepts for other "-web" style providers (chatgpt-web.ts, + * copilot-web.ts). No user account/API key is required; Microsoft gates + * abuse with a `Sec-MS-GEC` header computed from a public "trusted client + * token" (see `open-sse/utils/publicCreds.ts::edgetts_token` — Hard Rule + * #11, this is a constant hardcoded in every Edge browser build and every + * open-source edge-tts reimplementation, not a per-user secret). + * + * Protocol (verified against rany2/edge-tts + msedge-tts + edge-tts-universal): + * 1. WS connect to + * wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1 + * with `TrustedClientToken`, `Sec-MS-GEC`, `Sec-MS-GEC-Version` query params. + * 2. Send a `speech.config` text frame (output format, metadata options). + * 3. Send an `ssml` text frame carrying the SSML payload to synthesize. + * 4. Receive interleaved text frames (turn.start / audio.metadata / turn.end) + * and binary frames — each binary frame is a 2-byte big-endian header + * length, followed by ASCII headers, followed by raw audio bytes. + * 5. `turn.end` (or WS close) marks the end of the stream; concatenated + * audio chunks are the final MP3. + * + * All parsing above (Sec-MS-GEC HMAC input, message framing, binary chunk + * demux) is implemented as pure functions so it can be unit-tested without a + * live upstream connection — only `synthesizeEdgeTts()` itself touches the + * network, and it accepts an injectable WebSocket constructor for tests. + */ +import { createHash, randomBytes } from "node:crypto"; +import { resolvePublicCred } from "../utils/publicCreds.ts"; +import { errorResponse } from "../utils/error.ts"; +import { SlidingWindowLimiter } from "../services/slidingWindowLimiter.ts"; + +const EDGE_TTS_WS_URL = + "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"; +const EDGE_TTS_GEC_VERSION = "1-138.0.0.0"; +const WIN_EPOCH_OFFSET_SECONDS = 11644473600; +const SEC_MS_GEC_ROUND_SECONDS = 300; // 5 minutes +const DEFAULT_VOICE = "en-US-AriaNeural"; +const DEFAULT_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3"; +const CONNECT_TIMEOUT_MS = 10_000; +const SYNTH_TIMEOUT_MS = 30_000; + +// Per-client-IP throttle — EdgeTTS has no per-user key, so every OmniRoute +// deployment shares the same trusted-token identity upstream. A single +// abusive caller could get the shared token rate-limited/blocked for +// everyone, so we cap requests per source IP before we ever open a socket. +const EDGE_TTS_RATE_WINDOW = { requests: 20, windowMs: 60_000 }; +const edgeTtsLimiter = new SlidingWindowLimiter(); + +export interface EdgeTtsSynthInput { + text: string; + voice?: string; + rate?: string; + pitch?: string; + volume?: string; +} + +export interface EdgeTtsSynthResult { + audio: Buffer; + contentType: string; +} + +/** + * A minimal shape of the subset of the `ws`/DOM WebSocket API this module + * needs — lets tests inject a fake implementation without touching the real + * network or the `ws` package. + */ +export interface MinimalWebSocket { + on(event: "open" | "message" | "close" | "error", listener: (...args: unknown[]) => void): void; + send(data: string): void; + close(): void; +} + +export type WebSocketCtor = new (url: string, opts?: unknown) => MinimalWebSocket; + +// ─── Pure helpers (unit-testable, no I/O) ────────────────────────────────── + +/** + * Compute the `Sec-MS-GEC` anti-abuse token Microsoft's Read Aloud endpoint + * requires. `nowMs` is injectable so the function is deterministic in tests. + * Algorithm ported from rany2/edge-tts `drm.py::generate_sec_ms_gec()`. + */ +export function computeSecMsGec(nowMs: number = Date.now()): string { + let ticks = nowMs / 1000 + WIN_EPOCH_OFFSET_SECONDS; + ticks -= ticks % SEC_MS_GEC_ROUND_SECONDS; + ticks *= 1e7; // seconds -> 100-nanosecond Windows file-time ticks + const strToHash = `${Math.floor(ticks)}${resolvePublicCred("edgetts_token")}`; + return createHash("sha256").update(strToHash, "ascii").digest("hex").toUpperCase(); +} + +/** Random 32-hex-char connection id (no dashes), as the protocol expects. */ +export function buildConnectionId(): string { + return randomBytes(16).toString("hex"); +} + +function toIsoTimestamp(): string { + // Edge's protocol wants a JS-Date-toString-like timestamp; ISO is accepted + // by every reference implementation and is trivially deterministic/testable. + return new Date().toUTCString(); +} + +/** Build the `speech.config` WS text frame sent right after connecting. */ +export function buildSpeechConfigMessage(timestamp: string = toIsoTimestamp()): string { + const config = { + context: { + synthesis: { + audio: { + metadataoptions: { + sentenceBoundaryEnabled: "false", + wordBoundaryEnabled: "false", + }, + outputFormat: DEFAULT_OUTPUT_FORMAT, + }, + }, + }, + }; + return ( + `X-Timestamp:${timestamp}\r\n` + + `Content-Type:application/json; charset=utf-8\r\n` + + `Path:speech.config\r\n\r\n` + + `${JSON.stringify(config)}` + ); +} + +/** Escape user text for safe embedding inside an SSML `` element. */ +export function escapeSsmlText(text: string): string { + return String(text ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Normalize a caller-supplied voice name, falling back to the default voice. */ +export function normalizeEdgeVoice(voice: unknown): string { + const value = typeof voice === "string" ? voice.trim() : ""; + // Edge voice names are e.g. "en-US-AriaNeural" — locale-Name-Neural. + return /^[A-Za-z]{2,3}-[A-Za-z]{2,3}-[A-Za-z0-9]+Neural$/.test(value) ? value : DEFAULT_VOICE; +} + +function clampProsodyValue(value: unknown, fallback: string): string { + const str = typeof value === "string" ? value.trim() : ""; + // Accept "+10%", "-20%", "default", or a bare number — reject anything else + // to keep this untrusted-input path from injecting SSML markup. + return /^(default|[+-]?\d{1,3}%|[+-]?\d{1,3}(\.\d+)?)$/.test(str) ? str : fallback; +} + +/** Build the full SSML payload for one synthesis request. */ +export function buildSsml(input: EdgeTtsSynthInput): string { + const voice = normalizeEdgeVoice(input.voice); + const rate = clampProsodyValue(input.rate, "default"); + const pitch = clampProsodyValue(input.pitch, "default"); + const volume = clampProsodyValue(input.volume, "default"); + const text = escapeSsmlText(input.text); + return ( + `` + + `` + + `${text}` + + `` + ); +} + +/** Build the `ssml` WS text frame carrying the synthesis payload. */ +export function buildSsmlMessage( + requestId: string, + ssml: string, + timestamp: string = toIsoTimestamp() +): string { + return ( + `X-RequestId:${requestId}\r\n` + + `Content-Type:application/ssml+xml\r\n` + + `X-Timestamp:${timestamp}\r\n` + + `Path:ssml\r\n\r\n` + + `${ssml}` + ); +} + +/** True when a received text frame marks the end of the synthesis turn. */ +export function isTurnEndMessage(message: string): boolean { + return typeof message === "string" && message.includes("Path:turn.end"); +} + +/** + * Demux one binary WS frame into its header block and raw audio payload. + * Frame shape: 2-byte big-endian header length, then that many bytes of + * ASCII headers, then the remaining bytes are audio data. Returns `null` + * for a frame too short to contain a valid header-length prefix. + */ +export function demuxAudioChunk( + frame: Buffer +): { headers: string; audio: Buffer } | null { + if (!Buffer.isBuffer(frame) || frame.length < 2) return null; + const headerLength = frame.readUInt16BE(0); + if (2 + headerLength > frame.length) return null; + const headers = frame.subarray(2, 2 + headerLength).toString("ascii"); + const audio = frame.subarray(2 + headerLength); + return { headers, audio }; +} + +/** Build the WS connection URL, including the freshly-computed Sec-MS-GEC token. */ +export function buildEdgeTtsWsUrl(nowMs: number = Date.now()): string { + const params = new URLSearchParams({ + TrustedClientToken: resolvePublicCred("edgetts_token"), + "Sec-MS-GEC": computeSecMsGec(nowMs), + "Sec-MS-GEC-Version": EDGE_TTS_GEC_VERSION, + ConnectionId: buildConnectionId(), + }); + return `${EDGE_TTS_WS_URL}?${params.toString()}`; +} + +// ─── Network I/O ──────────────────────────────────────────────────────────── + +/** + * Open a WS connection to Edge's Read Aloud service and synthesize `input`. + * `WebSocketCtor` is injectable for tests; production callers omit it and + * this lazily imports the `ws` package (mirrors the pattern used in + * copilot-web.ts / chipotle.ts — keeps `ws` out of the esbuild CJS bundle's + * top-level graph). + */ +export async function synthesizeEdgeTts( + input: EdgeTtsSynthInput, + WebSocketCtor?: WebSocketCtor +): Promise { + const Ctor = WebSocketCtor ?? ((await import("ws")).default as unknown as WebSocketCtor); + const url = buildEdgeTtsWsUrl(); + const ssml = buildSsml(input); + const requestId = buildConnectionId(); + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let settled = false; + let contentType = "audio/mpeg"; + + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + + const timer = setTimeout(() => { + finish(() => { + try { + ws.close(); + } catch { + // best-effort close on timeout + } + reject(new Error("EdgeTTS synthesis timed out")); + }); + }, SYNTH_TIMEOUT_MS); + + let ws: MinimalWebSocket; + try { + ws = new Ctor(url, { handshakeTimeout: CONNECT_TIMEOUT_MS }); + } catch (err) { + clearTimeout(timer); + reject(err instanceof Error ? err : new Error(String(err))); + return; + } + + ws.on("open", () => { + ws.send(buildSpeechConfigMessage()); + ws.send(buildSsmlMessage(requestId, ssml)); + }); + + ws.on("message", (data: unknown, isBinary?: unknown) => { + const binary = isBinary === true || Buffer.isBuffer(data); + if (binary) { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer); + const demuxed = demuxAudioChunk(buf); + if (demuxed) { + const typeMatch = /Content-Type:\s*([^\r\n]+)/i.exec(demuxed.headers); + if (typeMatch) contentType = typeMatch[1].trim(); + if (demuxed.audio.length > 0) chunks.push(demuxed.audio); + } + return; + } + const text = String(data); + if (isTurnEndMessage(text)) { + finish(() => { + try { + ws.close(); + } catch { + // best-effort close + } + resolve({ audio: Buffer.concat(chunks), contentType }); + }); + } + }); + + ws.on("error", (err: unknown) => { + finish(() => reject(err instanceof Error ? err : new Error(String(err)))); + }); + + ws.on("close", () => { + finish(() => { + if (chunks.length > 0) { + resolve({ audio: Buffer.concat(chunks), contentType }); + } else { + reject(new Error("EdgeTTS connection closed before receiving audio")); + } + }); + }); + }); +} + +// ─── Handler entrypoint (called from audioSpeech.ts) ─────────────────────── + +/** + * Handle an EdgeTTS `/v1/audio/speech` request. `clientIp` is optional — when + * provided, this enforces the per-IP sliding-window throttle described above. + */ +export async function handleEdgeTtsSpeech( + body: { input?: unknown; voice?: unknown }, + clientIp?: string | null, + WebSocketCtor?: WebSocketCtor +): Promise { + if (clientIp) { + const { allowed, retryAfterMs } = edgeTtsLimiter.tryAcquire(clientIp, EDGE_TTS_RATE_WINDOW); + if (!allowed) { + return errorResponse( + 429, + `EdgeTTS rate limit exceeded, retry after ${Math.ceil(retryAfterMs / 1000)}s` + ); + } + } + + const text = typeof body?.input === "string" ? body.input : ""; + if (!text.trim()) { + return errorResponse(400, "input is required"); + } + + try { + const { audio, contentType } = await synthesizeEdgeTts( + { + text, + voice: typeof body.voice === "string" ? body.voice : undefined, + }, + WebSocketCtor + ); + return new Response(audio, { + status: 200, + headers: { "Content-Type": contentType }, + }); + } catch (err) { + return errorResponse( + 502, + `EdgeTTS request failed: ${err instanceof Error ? err.message : String(err)}` + ); + } +} diff --git a/open-sse/executors/felo-web.ts b/open-sse/executors/felo-web.ts new file mode 100644 index 0000000000..847b8d3f77 --- /dev/null +++ b/open-sse/executors/felo-web.ts @@ -0,0 +1,362 @@ +import { randomUUID } from "node:crypto"; +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +/** + * FeloWebExecutor — anonymous, free access to Felo (felo.ai), a chat/search-agent + * aggregator. No API key or session cookie required (`needs_auth = False` in the + * g4f reference implementation, `g4f/Provider/Felo.py`, fetched 2026-07-17). + * + * Flow: + * 1. POST /api-proxy/main/search/threads — opens a search thread, returns `stream_key`. + * 2. GET /api/message/v1/stream/{stream_key}?offset=0 — SSE-shaped stream. Each line is + * `data:{...}` (no space after the colon, unlike most SSE producers). The JSON payload + * carries a double-encoded `content` string; parsing that yields `{ data: { type, data } }` + * where `type` is `"answer"` (incremental/snapshot text) or `"final_contexts"` (sources, + * dropped here — no OpenAI-compatible slot for citations on this translation path). + * + * Felo has no published API; this is a reverse-engineered, scrape-style integration in the + * same family as `duckduckgo-web.ts` / `blackbox-web.ts` (see #6666 plan). It may break + * without notice if Felo changes its frontend contract. + */ + +export const FELO_BASE = "https://felo.ai"; +export const FELO_THREADS_URL = `${FELO_BASE}/api-proxy/main/search/threads`; +export const FELO_PROVIDER_PREFIX = "felo-web/"; + +export function feloStreamUrl(streamKey: string): string { + return `${FELO_BASE}/api/message/v1/stream/${encodeURIComponent(streamKey)}?offset=0`; +} + +const FELO_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"; + +export const FELO_HEADERS: Record = { + Accept: "*/*", + "Content-Type": "application/json", + Origin: FELO_BASE, + Referer: `${FELO_BASE}/search?q=hello`, + "User-Agent": FELO_USER_AGENT, +}; + +const FELO_STREAM_REQUEST_HEADERS: Record = { + Accept: "*/*", + Origin: FELO_BASE, + Referer: FELO_HEADERS.Referer, + "User-Agent": FELO_USER_AGENT, +}; + +// Mirrors g4f's `Felo.model_aliases` — Felo has no published model list; this +// reverse-engineered mapping is the only reference (category drives which +// search/answer pipeline Felo routes the query through). +const FELO_MODEL_CATEGORIES: Record = { + "felo-chat": "chat", + "felo-search": "google", + "felo-scholar": "scholar", + "felo-social": "social", + "felo-document": "document", +}; + +export const FELO_DEFAULT_MODEL = "felo-chat"; + +export function normalizeFeloModel(model: string | undefined | null): string { + if (!model) return FELO_DEFAULT_MODEL; + const clean = model.startsWith(FELO_PROVIDER_PREFIX) + ? model.slice(FELO_PROVIDER_PREFIX.length) + : model; + return Object.prototype.hasOwnProperty.call(FELO_MODEL_CATEGORIES, clean) + ? clean + : FELO_DEFAULT_MODEL; +} + +export function resolveFeloCategory(model: string | undefined | null): string { + return FELO_MODEL_CATEGORIES[normalizeFeloModel(model)]; +} + +export function extractFeloLastUserPrompt(messages: Array>): string { + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + if (!lastUser) return ""; + const content = lastUser.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => { + if (part && typeof part === "object" && typeof (part as Record).text === "string") { + return (part as Record).text as string; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +export function buildFeloThreadPayload( + model: string | undefined | null, + prompt: string +): Record { + const searchUuid = randomUUID(); + return { + query: prompt, + search_uuid: searchUuid, + lang: "", + agent_lang: "en", + search_options: { langcode: "en-US" }, + search_video: true, + query_from: "default", + category: resolveFeloCategory(model), + model: "", + auto_routing: true, + mode: "concise", + device_id: randomUUID().replaceAll("-", ""), + source_message_rid: "", + documents: [], + document_action: "", + slides_source: { type: "ask_question", files: {} }, + slide_template_uid: "", + selected_resource_ids: [], + process_id: searchUuid, + stream_protocol: "message_center_v1", + enable_task_state: true, + }; +} + +function extractFeloAnswerText(contentJson: unknown): string | null { + if (!contentJson || typeof contentJson !== "object") return null; + const data = (contentJson as Record).data; + if (!data || typeof data !== "object") return null; + const dataRecord = data as Record; + if (dataRecord.type !== "answer") return null; + const inner = dataRecord.data; + if (!inner || typeof inner !== "object") return null; + const text = (inner as Record).text; + return typeof text === "string" ? text : null; +} + +export interface FeloParsedLine { + /** New text to emit for this line, or null when the line carried nothing new. */ + newText: string | null; + /** Running "previous text" snapshot to pass into the next call. */ + nextPreviousText: string; +} + +/** + * Parse a single line of Felo's SSE-shaped stream, diffing against the running + * snapshot the same way the g4f reference implementation does: each `answer` + * event carries the full text-so-far, and only the new suffix is new content. + */ +export function parseFeloStreamLine(line: string, previousText: string): FeloParsedLine { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:{")) { + return { newText: null, nextPreviousText: previousText }; + } + + let outer: unknown; + try { + outer = JSON.parse(trimmed.slice(5)); + } catch { + return { newText: null, nextPreviousText: previousText }; + } + + const content = (outer as Record | null)?.content; + if (typeof content !== "string") { + return { newText: null, nextPreviousText: previousText }; + } + + let contentJson: unknown; + try { + contentJson = JSON.parse(content); + } catch { + return { newText: null, nextPreviousText: previousText }; + } + + const text = extractFeloAnswerText(contentJson); + if (text === null) { + return { newText: null, nextPreviousText: previousText }; + } + + if (text.startsWith(previousText)) { + const newPart = text.slice(previousText.length); + return newPart + ? { newText: newPart, nextPreviousText: text } + : { newText: null, nextPreviousText: previousText }; + } + + return { newText: text, nextPreviousText: text }; +} + +/** Replay a full raw stream body through `parseFeloStreamLine`, returning the final text. */ +export function accumulateFeloStreamText(rawText: string): string { + let previousText = ""; + for (const line of rawText.split("\n")) { + previousText = parseFeloStreamLine(line, previousText).nextPreviousText; + } + return previousText; +} + +export class FeloWebExecutor extends BaseExecutor { + constructor() { + super("felo-web", { baseUrl: FELO_BASE }); + } + + async testConnection( + _credentials: Record, + signal?: AbortSignal + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.getTimeoutMs()); + try { + const mergedSignal = signal + ? AbortSignal.any([signal, controller.signal]) + : controller.signal; + + const response = await fetch(FELO_THREADS_URL, { + method: "POST", + headers: FELO_HEADERS, + body: JSON.stringify(buildFeloThreadPayload(FELO_DEFAULT_MODEL, "hi")), + signal: mergedSignal, + }); + if (!response.ok) return false; + const data = await response.json().catch(() => null); + return typeof (data as Record | null)?.stream_key === "string"; + } catch { + return false; + } finally { + clearTimeout(timeout); + } + } + + async execute(input: ExecuteInput): Promise { + const { model, body, stream, signal } = input; + const bodyObj = (body || {}) as Record; + const messages = Array.isArray(bodyObj.messages) + ? (bodyObj.messages as Array>) + : []; + const isStreaming = stream !== false; + + if (messages.length === 0) { + return feloErrorResponse(400, "No messages provided"); + } + const prompt = extractFeloLastUserPrompt(messages); + if (!prompt) { + return feloErrorResponse(400, "No user message content found"); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.getTimeoutMs()); + const mergedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + + try { + const streamKey = await this.createFeloThread(model, prompt, mergedSignal); + if (streamKey instanceof Response) { + clearTimeout(timeout); + return streamKey; + } + + const streamResponse = await fetch(feloStreamUrl(streamKey), { + method: "GET", + headers: FELO_STREAM_REQUEST_HEADERS, + signal: mergedSignal, + }); + clearTimeout(timeout); + + if (!streamResponse.ok || !streamResponse.body) { + const status = !streamResponse.ok && streamResponse.status >= 500 ? 502 : streamResponse.status || 502; + return feloErrorResponse(status, `Felo stream request failed with HTTP ${streamResponse.status}`); + } + + return await processFeloResponse(streamResponse, isStreaming); + } catch (error) { + clearTimeout(timeout); + if (error instanceof DOMException && error.name === "AbortError") { + return feloErrorResponse(499, "Request cancelled"); + } + return feloErrorResponse(500, error instanceof Error ? error.message : "Unknown error"); + } + } + + /** Returns the resolved `stream_key`, or an error Response to propagate as-is. */ + private async createFeloThread( + model: string | undefined, + prompt: string, + signal: AbortSignal + ): Promise { + const threadResponse = await fetch(FELO_THREADS_URL, { + method: "POST", + headers: FELO_HEADERS, + body: JSON.stringify(buildFeloThreadPayload(model, prompt)), + signal, + }); + + if (!threadResponse.ok) { + const status = threadResponse.status >= 500 ? 502 : threadResponse.status; + return feloErrorResponse(status, `Felo thread creation failed with HTTP ${threadResponse.status}`); + } + + const threadJson = await threadResponse.json().catch(() => null); + const streamKey = (threadJson as Record | null)?.stream_key; + if (typeof streamKey !== "string" || !streamKey) { + return feloErrorResponse(502, "Felo did not return a stream_key"); + } + return streamKey; + } +} + +function feloErrorResponse(status: number, message: string): Response { + return new Response(JSON.stringify({ error: { message: sanitizeErrorMessage(message) } }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function buildFeloStreamTransform(): TransformStream { + let previousText = ""; + let buffer = ""; + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + return new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const parsed = parseFeloStreamLine(line, previousText); + previousText = parsed.nextPreviousText; + if (!parsed.newText) continue; + const openaiChunk = { choices: [{ delta: { content: parsed.newText }, index: 0 }] }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(openaiChunk)}\n\n`)); + } + }, + flush(controller) { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + }, + }); +} + +async function processFeloResponse(response: Response, streaming: boolean): Promise { + if (streaming) { + if (!response.body) { + return feloErrorResponse(500, "No response body"); + } + const transformed = response.body.pipeThrough(buildFeloStreamTransform()); + return new Response(transformed, { headers: { "Content-Type": "text/event-stream" } }); + } + + const rawText = await response.text(); + const fullText = accumulateFeloStreamText(rawText); + return new Response( + JSON.stringify({ + choices: [ + { + message: { role: "assistant", content: fullText }, + index: 0, + finish_reason: "stop", + }, + ], + }), + { headers: { "Content-Type": "application/json" } } + ); +} + +export const feloWebExecutor = new FeloWebExecutor(); diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 5271500d31..bbb6bb3641 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -41,6 +41,16 @@ export class GithubExecutor extends BaseExecutor { buildUrl(model: string, _stream: boolean, _urlIndex = 0) { const targetFormat = getModelTargetFormat("gh", model); + // Claude models: route to Copilot's Anthropic-native /v1/messages shim — the + // only Copilot endpoint that surfaces prompt-cache token counts for Claude and + // avoids a lossy round-trip of tool_use/tool_result/thinking content blocks + // through the OpenAI shape. Driven by the registry's per-model targetFormat + // (see registry/github/index.ts), which chatCore.ts also uses to translate the + // request to Claude shape before the executor ever sees it. + // Port of decolua/9router#2608 (author: yidecode). + if (targetFormat === "claude" && this.config.messagesUrl) { + return this.config.messagesUrl; + } // 9router#102: Copilot Codex models advertise supported_endpoints: ["/responses"] // and 400 on /chat/completions. Route any *-codex id to /responses even when it // isn't in the curated registry, so newly-shipped Codex models work out of the box. @@ -93,6 +103,15 @@ export class GithubExecutor extends BaseExecutor { const sourceBody = body && typeof body === "object" ? body : {}; const modifiedBody = { ...sourceBody }; + // Claude models arrive here already translated to Anthropic-native shape by + // chatCore.ts (registry targetFormat: "claude" — see registry/github/index.ts) + // and are dispatched at /v1/messages (buildUrl above), which behaves like the + // real Anthropic API. None of the /chat/completions-only quirks below apply — + // content-part flattening would destroy native tool_use/tool_result/thinking + // blocks, and the native endpoint (unlike Copilot's /chat/completions) honors + // assistant-message prefill. Port of decolua/9router#2608 (author: yidecode). + const isClaudeNative = getModelTargetFormat("gh", model) === "claude"; + if (Array.isArray(sourceBody.input)) { modifiedBody.input = sanitizeResponsesInputItems(sourceBody.input, false); } @@ -110,14 +129,6 @@ export class GithubExecutor extends BaseExecutor { }); } - if (modifiedBody.response_format && model.toLowerCase().includes("claude")) { - modifiedBody.messages = this.injectResponseFormat( - Array.isArray(modifiedBody.messages) ? modifiedBody.messages : [], - modifiedBody.response_format - ); - delete modifiedBody.response_format; - } - if (Array.isArray(modifiedBody.tools) && modifiedBody.tools.length > 128) { modifiedBody.tools = modifiedBody.tools.slice(0, 128); } @@ -136,29 +147,13 @@ export class GithubExecutor extends BaseExecutor { delete modifiedBody.temperature; } - // GitHub Copilot /chat/completions only accepts {type:'text'} or {type:'image_url'} - // content parts. Clients like Cursor IDE pass through Anthropic-shape parts - // (tool_use, tool_result, thinking) untouched when using Claude models, which makes - // the endpoint return: "type has to be either 'image_url' or 'text'" (HTTP 400). - // Serialize unknown part types as text, drop empty parts, and collapse to null when - // every part is stripped (assistant messages whose only content was tool_calls). - // Port from 9router#220 (fixes 9router#219). - if (Array.isArray(modifiedBody.messages)) { - modifiedBody.messages = modifiedBody.messages.map((msg: any) => - this.sanitizeChatCompletionsMessage(msg) - ); - } - - // GitHub Copilot's /chat/completions endpoint rejects a conversation that ends - // with an assistant message: "This model does not support assistant message - // prefill. The conversation must end with a user message." (HTTP 400). Anthropic - // clients such as newest Claude Desktop send a trailing assistant turn as a - // prefill seed — the Anthropic API honors it, but Copilot does not. Drop it here, - // scoped to the GitHub executor only (the shared translator/contextManager and - // other providers that DO honor prefill are untouched). - // Port of 9router#2143 (author: Manuel ). - if (Array.isArray(modifiedBody.messages)) { - modifiedBody.messages = this.dropTrailingAssistantPrefill(modifiedBody.messages); + // The quirks below (response_format-as-system-prompt, content-part flattening, + // trailing-assistant-prefill drop) are all workarounds for /chat/completions-only + // limitations. They either don't apply to Claude-shape bodies or actively corrupt + // them, so they are skipped entirely for the native /v1/messages path. Port of + // decolua/9router#2608 (author: yidecode) — see class doc comment above. + if (!isClaudeNative) { + this.applyChatCompletionsOnlyQuirks(model, modifiedBody); } // Config-driven strip of params unsupported by the target provider/model. @@ -171,6 +166,46 @@ export class GithubExecutor extends BaseExecutor { return modifiedBody; } + // GitHub Copilot's /chat/completions endpoint has several quirks that the native + // /v1/messages shim doesn't share — extracted from transformRequest so the native + // path (the common case for Claude models going forward) doesn't pay their branch + // cost. Mutates modifiedBody in place. + private applyChatCompletionsOnlyQuirks(model: string, modifiedBody): void { + // Claude models on /chat/completions don't support response_format — inject the + // instruction as a system message instead. Port from 9router (see + // injectResponseFormat above). + if (modifiedBody.response_format && model.toLowerCase().includes("claude")) { + modifiedBody.messages = this.injectResponseFormat( + Array.isArray(modifiedBody.messages) ? modifiedBody.messages : [], + modifiedBody.response_format + ); + delete modifiedBody.response_format; + } + + if (!Array.isArray(modifiedBody.messages)) return; + + // GitHub Copilot /chat/completions only accepts {type:'text'} or {type:'image_url'} + // content parts. Clients like Cursor IDE pass through Anthropic-shape parts + // (tool_use, tool_result, thinking) untouched when using Claude models, which makes + // the endpoint return: "type has to be either 'image_url' or 'text'" (HTTP 400). + // Serialize unknown part types as text, drop empty parts, and collapse to null when + // every part is stripped (assistant messages whose only content was tool_calls). + // Port from 9router#220 (fixes 9router#219). + modifiedBody.messages = modifiedBody.messages.map((msg: any) => + this.sanitizeChatCompletionsMessage(msg) + ); + + // GitHub Copilot's /chat/completions endpoint rejects a conversation that ends + // with an assistant message: "This model does not support assistant message + // prefill. The conversation must end with a user message." (HTTP 400). Anthropic + // clients such as newest Claude Desktop send a trailing assistant turn as a + // prefill seed — the Anthropic API honors it, but Copilot does not. Drop it here, + // scoped to the GitHub executor only (the shared translator/contextManager and + // other providers that DO honor prefill are untouched). + // Port of 9router#2143 (author: Manuel ). + modifiedBody.messages = this.dropTrailingAssistantPrefill(modifiedBody.messages); + } + private sanitizeChatCompletionsMessage(msg: any): any { if (!msg || typeof msg !== "object") return msg; // String content and missing content (e.g. assistant w/ only tool_calls) pass through. @@ -235,17 +270,38 @@ export class GithubExecutor extends BaseExecutor { buildHeaders( credentials: ProviderCredentials, stream = true, - clientHeaders?: Record | null + clientHeaders?: Record | null, + model?: string ): Record { const token = this.getCopilotToken(credentials) || credentials.accessToken; + const initiator = this.resolveInitiatorHeader(clientHeaders); - // Forward the client's x-initiator header when present. OpenCode and other - // Copilot-aware clients use this to distinguish user-initiated turns - // (x-initiator: user) from autonomous tool-call continuations - // (x-initiator: agent). GitHub Copilot's billing treats "agent" turns as - // free, so forwarding the value avoids burning a premium request on every - // tool-call round-trip. Fall back to "user" when the header is absent to - // preserve the existing default behaviour. + const headers: Record = { + ...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator), + Authorization: `Bearer ${token}`, + "x-request-id": + crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`, + }; + + // Claude models routed to the Anthropic-native /v1/messages shim require the + // anthropic-version header (harmless no-op on /chat/completions and /responses, + // but /v1/messages rejects the request without it). Port of decolua/9router#2608. + if (model && getModelTargetFormat("gh", model) === "claude") { + headers["anthropic-version"] = "2023-06-01"; + } + + return headers; + } + + // Forward the client's x-initiator header when present. OpenCode and other + // Copilot-aware clients use this to distinguish user-initiated turns + // (x-initiator: user) from autonomous tool-call continuations + // (x-initiator: agent). GitHub Copilot's billing treats "agent" turns as + // free, so forwarding the value avoids burning a premium request on every + // tool-call round-trip. Falls back to "user" when the header is absent to + // preserve the existing default behaviour. Extracted from buildHeaders so + // header assembly stays the one place that reads it. + private resolveInitiatorHeader(clientHeaders?: Record | null): string { let clientInitiator = clientHeaders?.["x-initiator"] || clientHeaders?.["X-Initiator"]; if (!clientInitiator && clientHeaders) { for (const key in clientHeaders) { @@ -255,15 +311,7 @@ export class GithubExecutor extends BaseExecutor { } } } - const initiator = - clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user"; - - return { - ...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator), - Authorization: `Bearer ${token}`, - "x-request-id": - crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`, - }; + return clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user"; } async refreshCopilotToken(githubAccessToken, log) { diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index 368f156688..1fb438aa82 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -19,6 +19,7 @@ import { getGlmTransport, } from "../config/glmProvider.ts"; import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; +import { stripUnsupportedParams } from "../translator/paramSupport.ts"; import { getRotatingApiKey } from "../services/apiKeyRotator.ts"; import { CLAUDE_CLI_STAINLESS_PACKAGE_VERSION } from "../config/anthropicHeaders.ts"; import { @@ -283,6 +284,14 @@ export class GlmExecutor extends DefaultExecutor { const transformed = this.transformRequest(effectiveModel, body, stream, credentials); const record = asRecord(transformed); + // #7364: unlike DefaultExecutor.execute() (default.ts), GlmExecutor.execute() + // never calls the base execute() loop — it drives its own fetch via + // executeTransport()/transformForTransport() — so stripUnsupportedParams() + // (normally applied at default.ts's execute() call site) never ran for GLM + // requests. Without this call, a STRIP_RULES clamp entry for provider "glm" + // (e.g. the glm-4.6v max_tokens ceiling) would be silently dead code. + if (record) stripUnsupportedParams(this.provider, effectiveModel, record); + // Ensure upstream receives the base model ID, not the effort-suffixed alias if (record && effortTier) { record.model = effectiveModel; diff --git a/open-sse/executors/grok-cli.ts b/open-sse/executors/grok-cli.ts index ced0ac1465..15f2e6e920 100644 --- a/open-sse/executors/grok-cli.ts +++ b/open-sse/executors/grok-cli.ts @@ -2,7 +2,8 @@ * GrokCliExecutor — Grok Build Provider * * Routes requests through Grok's chat proxy endpoint using OAuth authentication. - * Uses Node.js https module directly with IPv4 forced to bypass Cloudflare blocking. + * Uses Node.js https module directly with IPv4 forced to bypass Cloudflare blocking + * (only for the no-proxy direct path — see resolveGrokRequestDispatch below). * Supports automatic token refresh via refresh_token. */ @@ -14,10 +15,60 @@ import { } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { resolvePublicCred } from "../utils/publicCreds.ts"; +import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; import https from "node:https"; +import { HttpsProxyAgent } from "https-proxy-agent"; const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token"; const REQUEST_TIMEOUT_MS = 60_000; +// xAI cli-chat-proxy hard limit on tools per request. +const MAX_TOOLS = 200; + +type ProxyResolution = { source: string; proxyUrl: string | null }; +type GrokRequestDispatch = { agent?: https.Agent; family?: 4 }; + +/** + * Resolve how a Grok Build request to `targetUrl` should egress: through the + * operator's configured proxy (connection/provider/global — whatever the caller + * already pinned via `runWithProxyContext` upstream in chatHelpers.ts) when one + * is set, or direct with the existing forced-IPv4 workaround when none is. + * + * This executor talks to Grok via raw `https.request()` instead of the global + * patched `fetch()` (every other executor's path), so it never consulted the + * proxy context at all — a configured proxy was silently ignored and the + * request always egressed on the host's real IP. Only HTTP/HTTPS (CONNECT) + * proxies are supported here; an explicitly configured proxy of another kind + * (e.g. SOCKS5) fails closed rather than silently falling back to direct, + * matching the "fail closed for OAuth usage account proxies" convention (#3051). + * + * `resolveProxy` is injectable for tests; defaults to the shared + * `resolveProxyForRequest` used by the patched global fetch. + */ +export function resolveGrokRequestDispatch( + targetUrl: string, + resolveProxy: (url: string) => ProxyResolution = resolveProxyForRequest +): GrokRequestDispatch { + const { proxyUrl } = resolveProxy(targetUrl); + + if (!proxyUrl) { + return { family: 4 }; + } + + let protocol: string; + try { + protocol = new URL(proxyUrl).protocol; + } catch { + throw new Error("Grok Build: configured proxy URL could not be parsed"); + } + + if (protocol === "http:" || protocol === "https:") { + return { agent: new HttpsProxyAgent(proxyUrl) as unknown as https.Agent }; + } + + throw new Error( + "Grok Build: configured proxy protocol is not supported for this provider (HTTP/HTTPS proxies only)" + ); +} export class GrokCliExecutor extends BaseExecutor { constructor() { @@ -100,6 +151,7 @@ export class GrokCliExecutor extends BaseExecutor { timeoutMs = 10_000 ): Promise<{ status: number; body: string }> { const urlObj = new URL(url); + const dispatch = resolveGrokRequestDispatch(url); return new Promise((resolve, reject) => { const timer = setTimeout(() => req.destroy(new Error("Timeout")), timeoutMs); @@ -110,7 +162,8 @@ export class GrokCliExecutor extends BaseExecutor { port: 443, path: urlObj.pathname + urlObj.search, method: "POST", - family: 4, + ...(dispatch.family ? { family: dispatch.family } : {}), + ...(dispatch.agent ? { agent: dispatch.agent } : {}), headers: { ...headers, "Content-Length": Buffer.byteLength(bodyStr), @@ -145,6 +198,7 @@ export class GrokCliExecutor extends BaseExecutor { signal?: AbortSignal | null ): Promise { const urlObj = new URL(url); + const dispatch = resolveGrokRequestDispatch(url); if (signal?.aborted) { return Promise.reject(new Error("Aborted")); @@ -167,7 +221,8 @@ export class GrokCliExecutor extends BaseExecutor { port: 443, path: urlObj.pathname + urlObj.search, method: "POST", - family: 4, + ...(dispatch.family ? { family: dispatch.family } : {}), + ...(dispatch.agent ? { agent: dispatch.agent } : {}), headers: { ...headers, "Content-Length": Buffer.byteLength(bodyStr), @@ -257,6 +312,14 @@ export class GrokCliExecutor extends BaseExecutor { } } + // xAI's cli-chat-proxy enforces a maximum of 200 tools per request and + // 400s above that ceiling. Clients that fan a large MCP toolset through + // Grok Build/Composer (e.g. Claude Code with many registered tools) can + // exceed it — cap defensively rather than let the request fail upstream. + if (Array.isArray(transformed.tools) && transformed.tools.length > MAX_TOOLS) { + transformed.tools = transformed.tools.slice(0, MAX_TOOLS); + } + return transformed; } } diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 68168fd8cf..e9f2d76a17 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -36,8 +36,10 @@ import { AdaptaWebExecutor } from "./adapta-web.ts"; import { ClaudeWebWithAutoRefresh } from "./claude-web-with-auto-refresh.ts"; import { CopilotWebExecutor } from "./copilot-web.ts"; import { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; +import { MicrosoftDesignerWebExecutor } from "./microsoft-designer-web.ts"; import { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; import { DuckDuckGoWebExecutor } from "./duckduckgo-web.ts"; +import { FeloWebExecutor } from "./felo-web.ts"; import { T3ChatWebExecutor } from "./t3-chat-web.ts"; import { ClaudeWebExecutor } from "./claude-web.ts"; import { InnerAiExecutor } from "./inner-ai.ts"; @@ -45,6 +47,7 @@ import { HuggingChatExecutor } from "./huggingchat.ts"; import { YuanbaoWebExecutor } from "./yuanbao-web.ts"; import { PoeWebExecutor } from "./poe-web.ts"; import { VeniceWebExecutor } from "./venice-web.ts"; +import { NotionWebExecutor } from "./notion-web.ts"; import { V0VercelWebExecutor } from "./v0-vercel-web.ts"; import { KimiWebExecutor } from "./kimi-web.ts"; import { DoubaoWebExecutor } from "./doubao-web.ts"; @@ -123,10 +126,14 @@ const executors = { "copilot-web": new CopilotWebExecutor(), "copilot-m365-web": new CopilotM365WebExecutor(), copilot: new CopilotWebExecutor(), // Alias + "microsoft-designer-web": new MicrosoftDesignerWebExecutor(), + msdesigner: new MicrosoftDesignerWebExecutor(), // Alias "veoaifree-web": new VeoAIFreeWebExecutor(), "veo-free": new VeoAIFreeWebExecutor(), // Alias "duckduckgo-web": new DuckDuckGoWebExecutor(), ddgw: new DuckDuckGoWebExecutor(), // Alias + "felo-web": new FeloWebExecutor(), + felo: new FeloWebExecutor(), // Alias "t3-web": new T3ChatWebExecutor(), t3chat: new T3ChatWebExecutor(), // Alias "inner-ai": new InnerAiExecutor(), @@ -139,6 +146,8 @@ const executors = { poe: new PoeWebExecutor(), // Alias "venice-web": new VeniceWebExecutor(), ven: new VeniceWebExecutor(), // Alias + "notion-web": new NotionWebExecutor(), + nw: new NotionWebExecutor(), // Alias "v0-vercel-web": new V0VercelWebExecutor(), v0: new V0VercelWebExecutor(), // Alias "kimi-web": new KimiWebExecutor(), @@ -233,8 +242,10 @@ export { DevinCliExecutor } from "./devin-cli.ts"; export { AuggieExecutor } from "./auggie.ts"; export { CopilotWebExecutor } from "./copilot-web.ts"; export { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; +export { MicrosoftDesignerWebExecutor } from "./microsoft-designer-web.ts"; export { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; export { DuckDuckGoWebExecutor } from "./duckduckgo-web.ts"; +export { FeloWebExecutor } from "./felo-web.ts"; export { ClaudeWebExecutor } from "./claude-web.ts"; export { DeepSeekWebExecutor } from "./deepseek-web.ts"; export { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts"; diff --git a/open-sse/executors/microsoft-designer-web.ts b/open-sse/executors/microsoft-designer-web.ts new file mode 100644 index 0000000000..d2ffab604d --- /dev/null +++ b/open-sse/executors/microsoft-designer-web.ts @@ -0,0 +1,51 @@ +// MicrosoftDesignerWebExecutor — chat-completions guard for the +// microsoft-designer-web web-cookie provider (#6672). +// +// Microsoft Designer (designerapp.officeapps.live.com/DallE.ashx) is an +// image-generation-only upstream: it has no chat/completions surface at all. +// The real request/response flow lives entirely in the image-generation +// handler (open-sse/handlers/imageGeneration/providers/designerWeb.ts), +// dispatched from open-sse/handlers/imageGeneration.ts by +// providerConfig.format === "designer-web" — NOT through getExecutor(). +// +// microsoft-designer-web is still listed in WEB_COOKIE_PROVIDERS (it uses +// the same unofficial, DevTools-sourced bearer-token credential UX and +// subscription-risk notice as the other web-cookie providers — see +// tests/unit/microsoft-designer-web-6672.test.ts). Without a registered +// executor here, getExecutor("microsoft-designer-web") silently falls +// through to DefaultExecutor's `PROVIDERS[provider] || PROVIDERS.openai` +// fallback (open-sse/executors/index.ts:176 comment, #6699) — which would +// send the user's real Designer bearer token to api.openai.com, mislabeled +// as an OpenAI request, if anything ever mis-routes a chat/completions call +// to this provider. +// +// This executor closes that gap cheaply: it never calls the network. Any +// chat/completions attempt against microsoft-designer-web is rejected +// immediately with a clean, sanitized 400 telling the caller to use +// /v1/images/generations instead — satisfying the executor wrapper +// contract (tests/unit/executor-web-cookie-sweep.test.ts) without ever +// forwarding credentials anywhere. +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult } from "../utils/error.ts"; + +const DESIGNER_WEB_BASE_URL = + "https://designerapp.officeapps.live.com/designerapp/DallE.ashx?action=GetDallEImagesCogSci"; + +export class MicrosoftDesignerWebExecutor extends BaseExecutor { + constructor() { + super("microsoft-designer-web", { id: "microsoft-designer-web", baseUrl: DESIGNER_WEB_BASE_URL }); + } + + async execute(_input: ExecuteInput) { + return makeExecutorErrorResult( + 400, + "microsoft-designer-web is an image-generation-only provider and does not support " + + "chat completions. Use POST /v1/images/generations with model " + + '"microsoft-designer-web/dall-e-3" instead.', + _input.body, + DESIGNER_WEB_BASE_URL + ); + } +} + +export default MicrosoftDesignerWebExecutor; diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts new file mode 100644 index 0000000000..b018b40302 --- /dev/null +++ b/open-sse/executors/notion-web.ts @@ -0,0 +1,310 @@ +/** + * NotionWebExecutor — Notion AI Web Session Provider (Unofficial/Experimental) + * + * Notion AI has no public, documented inference API (see issue #3272, closed + * by the owner for that reason). This executor instead reverse-engineers the + * same cookie-authenticated internal endpoint two independent open-source + * projects already ship (`notion2api`, `Notion-AI-to-OpenAI-Compatible`, both + * cited in issue #6758): a `token_v2` session cookie posted to + * `POST /api/v3/runInferenceTranscript`, whose response is a newline-delimited + * JSON (NDJSON) stream of transcript-patch records. Each record's `value` + * field carries Notion's standard rich-text tuple shape (`[[text, marks?]]`, + * the same shape used by Notion's public page-property API) holding the + * *current* (cumulative, not delta) assistant text — mirroring the snapshot + * semantics `gemini-web.ts` already handles, so only the last non-empty frame + * is kept rather than concatenating every frame (see #7163 for why + * concatenating cumulative snapshots duplicates text). + * + * Because the endpoint is undocumented and can change without notice + * (acknowledged risk in issue #6758), streaming here is pseudo-streaming — + * the full response is read, parsed, then sent as a single SSE chunk. This is + * the same conservative tradeoff `gemini-web.ts` makes and is safer than + * assuming unverified incremental-delta semantics on a live, undocumented API. + * + * Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id]) + * Method: Direct fetch — no browser automation required. + */ +import { randomUUID } from "node:crypto"; +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +const BASE_URL = "https://www.notion.so"; +const NOTION_URL = `${BASE_URL}/api/v3/runInferenceTranscript`; +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface NotionMessage { + role: string; + content: string; +} + +interface NotionRequestBody { + messages?: NotionMessage[]; + model?: string; +} + +// ─── Helpers — credential resolution ─────────────────────────────────────── + +function readCredentialString(value: unknown): string { + if (typeof value !== "string") return ""; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : ""; +} + +function readProviderSpecificString( + providerSpecificData: unknown, + keys: readonly string[] +): string { + if ( + !providerSpecificData || + typeof providerSpecificData !== "object" || + Array.isArray(providerSpecificData) + ) { + return ""; + } + const data = providerSpecificData as Record; + for (const key of keys) { + const value = readCredentialString(data[key]); + if (value) return value; + } + return ""; +} + +/** Normalize a pasted credential to a `name=value` cookie pair. Accepts a bare + * token or an already-prefixed `token_v2=...` value. */ +export function normalizeNotionCookieInput(raw: string, cookieName = "token_v2"): string { + const trimmed = raw.trim(); + if (!trimmed) return ""; + return trimmed.includes("=") ? trimmed : `${cookieName}=${trimmed}`; +} + +/** + * Resolve the Cookie header to send upstream. Accepts, in priority order: + * 1. A full cookie header pasted as `apiKey` or `credentials.cookie`. + * 2. `providerSpecificData.cookie` (full header). + * 3. Structured `providerSpecificData.token_v2` (+ optional `space_id`, + * `notion_browser_id`), assembled into a cookie header. + */ +export function resolveNotionWebCookie(credentials: ExecuteInput["credentials"]): string { + const directCookie = + readCredentialString(credentials?.apiKey) || + readCredentialString((credentials as Record | undefined)?.cookie); + if (directCookie) return normalizeNotionCookieInput(directCookie); + + const providerSpecificData = credentials?.providerSpecificData; + const cookie = readProviderSpecificString(providerSpecificData, ["cookie"]); + if (cookie) return normalizeNotionCookieInput(cookie); + + const tokenV2 = readProviderSpecificString(providerSpecificData, ["token_v2", "tokenV2"]); + const spaceId = readProviderSpecificString(providerSpecificData, ["space_id", "spaceId"]); + const browserId = readProviderSpecificString(providerSpecificData, [ + "notion_browser_id", + "notionBrowserId", + ]); + return [ + tokenV2 ? normalizeNotionCookieInput(tokenV2) : "", + spaceId ? `space_id=${spaceId}` : "", + browserId ? `notion_browser_id=${browserId}` : "", + ] + .filter(Boolean) + .join("; "); +} + +/** Pull `space_id` out of an assembled cookie header, if present. Notion's + * transcript endpoint accepts an explicit `spaceId` field in the body; when + * the operator supplied it via cookie we forward it rather than relying on + * Notion to infer it from the session alone. */ +export function extractSpaceIdFromCookie(cookie: string): string { + const match = cookie.match(/(?:^|;\s*)space_id=([^;]+)/i); + return match ? match[1].trim() : ""; +} + +// ─── Helpers — request/response translation ──────────────────────────────── + +/** + * Build a Notion `runInferenceTranscript` transcript array from OpenAI-style + * chat messages. Each entry uses Notion's rich-text tuple value shape + * (`[[text]]`) and a role tag Notion's own web client sends. + */ +export function buildNotionTranscript( + messages: NotionMessage[] +): Array> { + return messages + .filter((m) => typeof m?.content === "string" && m.content.length > 0) + .map((m) => ({ + id: randomUUID(), + type: m.role === "assistant" ? "ai" : m.role === "system" ? "context" : "human", + value: [[m.content]], + })); +} + +/** Extract plain text from Notion's rich-text tuple value: `[[text, marks?]]`. */ +function extractRichText(value: unknown): string { + if (!Array.isArray(value)) return ""; + return value + .map((segment) => (Array.isArray(segment) && typeof segment[0] === "string" ? segment[0] : "")) + .join(""); +} + +/** + * Parse Notion's NDJSON `runInferenceTranscript` response body. Each line is + * an independent JSON record; the assistant text lives under a `value` field + * using the rich-text tuple shape. Frames are cumulative snapshots (mirroring + * `gemini-web.ts`'s `parseStreamResponse`), so only the last non-empty frame + * is kept — never concatenated. + */ +export function parseNotionInferenceStream(raw: string): string { + if (!raw) return ""; + const lines = raw.split("\n"); + let lastText = ""; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + let record: unknown; + try { + record = JSON.parse(line); + } catch { + continue; // Skip unparseable lines (keep-alive pings, partial frames) + } + if (!record || typeof record !== "object" || Array.isArray(record)) continue; + const text = extractRichText((record as Record).value); + if (text) lastText = text; + } + return lastText; +} + +function chatCompletionResponse(content: string, model: string) { + return new Response( + JSON.stringify({ + id: `chatcmpl-notion-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { index: 0, message: { role: "assistant", content }, finish_reason: "stop" }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} + +function pseudoStreamResponse(content: string, model: string) { + const encoder = new TextEncoder(); + const chunk = (delta: string, finishReason: string | null) => ({ + id: `chatcmpl-notion-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: delta ? { content: delta } : {}, finish_reason: finishReason }], + }); + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk(content, null))}\n\n`)); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk("", "stop"))}\n\n`)); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(readable, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} + +// ─── Executor ─────────────────────────────────────────────────────────────── + +export class NotionWebExecutor extends BaseExecutor { + constructor() { + super("notion-web", { id: "notion-web", baseUrl: NOTION_URL }); + } + + async execute(input: ExecuteInput) { + const { model, body, stream: wantStream, credentials, signal } = input; + const requestBody = (body || {}) as NotionRequestBody; + + const cookie = resolveNotionWebCookie(credentials); + if (!cookie) { + return makeErrorResult( + 401, + "Missing Notion token_v2 cookie — paste it from notion.so DevTools → Application → Cookies", + body, + NOTION_URL + ); + } + + const messages = requestBody.messages || []; + if (!messages.some((m) => m.role === "user")) { + return makeErrorResult(400, "No user message found", body, NOTION_URL); + } + + const spaceId = extractSpaceIdFromCookie(cookie); + const modelId = model || "notion-ai"; + const reqBody: Record = { + traceId: randomUUID(), + transcript: buildNotionTranscript(messages), + createThread: false, + }; + if (spaceId) reqBody.spaceId = spaceId; + + const reqHeaders: Record = { + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + Accept: "application/x-ndjson", + Cookie: cookie, + Origin: BASE_URL, + Referer: `${BASE_URL}/`, + }; + + let upstream: Response; + try { + upstream = await fetch(NOTION_URL, { + method: "POST", + headers: reqHeaders, + body: JSON.stringify(reqBody), + signal: signal ?? undefined, + }); + } catch (err) { + return makeErrorResult( + 502, + `Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`, + reqBody, + NOTION_URL + ); + } + + if (upstream.status === 401 || upstream.status === 403) { + return makeErrorResult( + upstream.status, + "Notion session expired or invalid — re-paste token_v2 from notion.so", + reqBody, + NOTION_URL + ); + } + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + return makeErrorResult(upstream.status, `Notion error: ${errText}`, reqBody, NOTION_URL); + } + + const rawText = await upstream.text(); + const finalText = parseNotionInferenceStream(rawText); + if (!finalText) { + return makeErrorResult(502, "No response from Notion AI", reqBody, NOTION_URL); + } + + const response = wantStream + ? pseudoStreamResponse(finalText, modelId) + : chatCompletionResponse(finalText, modelId); + + return { response, url: NOTION_URL, headers: reqHeaders, transformedBody: reqBody }; + } +} diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 8885d4edba..d7053a1b09 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -21,7 +21,10 @@ import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.ts" import { buildAuthHeaders } from "../config/registryUtils.ts"; import { kieExecutor } from "../executors/kie.ts"; import { vertexGenerateSpeech } from "../executors/vertexMedia.ts"; +import { handleAwsPollySpeech } from "../executors/awsPollyTts.ts"; +import { handleEdgeTtsSpeech } from "../executors/edgeTts.ts"; import { errorResponse } from "../utils/error.ts"; +import { audioStreamResponse, upstreamErrorResponse } from "../utils/audioResponse.ts"; import { getKieCallbackUrl, getKieErrorMessage, @@ -29,59 +32,6 @@ import { isJsonObject, parseKieResultJson, } from "../utils/kieTask.ts"; -import { signAwsRequest } from "../utils/awsSigV4.ts"; - -/** - * Return a CORS error response from an upstream fetch failure - */ -function extractUpstreamErrorMessage(parsed) { - const detail = parsed?.detail; - const candidates = [ - parsed?.err_msg, - parsed?.error?.message, - typeof parsed?.error === "string" ? parsed.error : null, - parsed?.message, - typeof detail === "string" ? detail : detail?.message, - ]; - - const raw = candidates.find(Boolean); - return raw ? String(raw) : null; -} - -function upstreamErrorResponse(res, errText) { - // Always return JSON so the client can detect 401/credential errors reliably - let errorMessage: string; - try { - const parsed = JSON.parse(errText); - errorMessage = - extractUpstreamErrorMessage(parsed) || errText || `Upstream error (${res.status})`; - } catch { - errorMessage = errText || `Upstream error (${res.status})`; - } - - return Response.json( - { error: { message: errorMessage, code: res.status } }, - { - status: res.status, - headers: { ...CORS_HEADERS }, - } - ); -} - -/** - * Return a CORS audio stream response - */ -function audioStreamResponse(res, defaultContentType = "audio/mpeg") { - const contentType = res.headers.get("content-type") || defaultContentType; - return new Response(res.body, { - status: 200, - headers: { - ...CORS_HEADERS, - "Content-Type": contentType, - "Transfer-Encoding": "chunked", - }, - }); -} function normalizeKieElevenLabsVoice(voice: unknown): string { const value = typeof voice === "string" ? voice.trim() : ""; @@ -178,30 +128,6 @@ function getStringValue(value): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function getAwsPollyProviderData(credentials) { - return credentials?.providerSpecificData && - typeof credentials.providerSpecificData === "object" && - !Array.isArray(credentials.providerSpecificData) - ? credentials.providerSpecificData - : {}; -} - -function resolveAwsPollyRegion(providerSpecificData) { - return ( - getStringValue(providerSpecificData.region) || - getStringValue(providerSpecificData.awsRegion) || - process.env.AWS_REGION || - process.env.AWS_DEFAULT_REGION || - "us-east-1" - ); -} - -function resolveAwsPollyBaseUrl(providerSpecificData, region) { - const configuredBaseUrl = getStringValue(providerSpecificData.baseUrl); - const baseUrl = configuredBaseUrl || `https://polly.${region}.amazonaws.com`; - return stripTrailingSlashes(baseUrl.replace(/\/v1\/speech\/?$/i, "")); -} - function getProviderSpecificData(credentials) { return credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" && @@ -249,50 +175,6 @@ function getXiaomiMimoAudioData(data) { ); } -function normalizeAwsPollyEngine(modelId) { - const engine = getStringValue(modelId) || "standard"; - return ["standard", "neural", "long-form", "generative"].includes(engine) ? engine : "standard"; -} - -function normalizeAwsPollyOutputFormat(responseFormat) { - const format = getStringValue(responseFormat)?.toLowerCase(); - switch (format) { - case "pcm": - case "wav": - return "pcm"; - case "opus": - case "ogg_opus": - return "ogg_opus"; - case "ogg": - case "ogg_vorbis": - return "ogg_vorbis"; - case "json": - return "json"; - case "mp3": - default: - return "mp3"; - } -} - -function normalizeAwsPollyTextType(body) { - const explicitTextType = getStringValue(body.text_type || body.textType)?.toLowerCase(); - if (explicitTextType === "ssml") return "ssml"; - if (explicitTextType === "text") return "text"; - - const input = getStringValue(body.input) || ""; - return input.trim().startsWith(" + fields: Record, + fileFieldName = "file" ): Promise<{ body: Uint8Array; contentType: string }> { const boundary = "----OmniRouteAudioBoundary" + Date.now().toString(36); const parts: Uint8Array[] = []; @@ -92,7 +94,7 @@ export async function buildMultipartBody( const fileBytes = new Uint8Array(await file.arrayBuffer()); parts.push( encoder.encode( - `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: ${file.type || "application/octet-stream"}\r\n\r\n` + `--${boundary}\r\nContent-Disposition: form-data; name="${fileFieldName}"; filename="${fileName}"\r\nContent-Type: ${file.type || "application/octet-stream"}\r\n\r\n` ) ); parts.push(fileBytes); @@ -266,6 +268,67 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke return errorResponse(504, "AssemblyAI transcription timed out after 120s"); } +/** + * Handle Gladia transcription (async: upload file → submit pre-recorded job → poll result_url) + */ +async function handleGladiaTranscription(providerConfig, file, modelId, token) { + const authHeaders = buildAuthHeaders(providerConfig, token); + + // Step 1: Upload the audio file (multipart/form-data) + const { body: uploadBody, contentType: uploadCT } = await buildMultipartBody(file, {}); + const uploadRes = await fetch("https://api.gladia.io/v2/upload", { + method: "POST", + headers: { ...authHeaders, "Content-Type": uploadCT }, + body: uploadBody, + }); + + if (!uploadRes.ok) { + return upstreamErrorResponse(uploadRes, await uploadRes.text()); + } + + const { audio_url } = await uploadRes.json(); + + // Step 2: Submit the pre-recorded transcription job + const submitRes = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ audio_url, model: modelId }), + }); + + if (!submitRes.ok) { + return upstreamErrorResponse(submitRes, await submitRes.text()); + } + + const { result_url: resultUrl } = await submitRes.json(); + if (!resultUrl) { + return errorResponse(502, "Gladia did not return a result_url"); + } + + // Step 3: Poll for completion (max 120s) + const maxWait = 120_000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + await new Promise((r) => setTimeout(r, 2000)); + + const pollRes = await fetch(resultUrl, { headers: authHeaders }); + if (!pollRes.ok) continue; + + const result = await pollRes.json(); + + if (result.status === "done") { + const text = result.result?.transcription?.full_transcript || ""; + return Response.json({ text }, { headers: { ...CORS_HEADERS } }); + } + + if (result.status === "error") { + return errorResponse(500, result.error_code || result.error || "Gladia transcription failed"); + } + } + + return errorResponse(504, "Gladia transcription timed out after 120s"); +} + /** * Handle Nvidia NIM transcription * Multipart POST, transform response to { text } @@ -409,6 +472,65 @@ async function pollKieTranscriptionResult(baseUrl, modelId, taskId, token) { return errorResponse(504, "Kie transcription generation timed out or failed"); } +/** + * Handle Rev AI transcription (async: submit job with media upload → poll → fetch transcript) + * + * Rev AI accepts the audio file directly in the job-submission multipart body + * (field "media"), avoiding AssemblyAI's separate upload step. Once the job + * reaches a terminal state we fetch the plain-text transcript. + */ +async function handleRevAiTranscription(providerConfig, file, modelId, token) { + const authHeaders = buildAuthHeaders(providerConfig, token); + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + + // Step 1: submit the job — multipart body with "media" (file) + "options" (JSON) + const options = JSON.stringify({ transcriber: modelId }); + const { body, contentType } = await buildMultipartBody(file, { options }, "media"); + + const submitRes = await fetch(`${baseUrl}/jobs`, { + method: "POST", + headers: { ...authHeaders, "Content-Type": contentType }, + body, + }); + + if (!submitRes.ok) { + return upstreamErrorResponse(submitRes, await submitRes.text()); + } + + const { id: jobId } = await submitRes.json(); + + // Step 2: poll for completion (max 120s) + const jobUrl = `${baseUrl}/jobs/${jobId}`; + const maxWait = 120_000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + await new Promise((r) => setTimeout(r, 2000)); + + const pollRes = await fetch(jobUrl, { headers: authHeaders }); + if (!pollRes.ok) continue; + + const result = await pollRes.json(); + + if (result.status === "transcribed") { + const transcriptRes = await fetch(`${jobUrl}/transcript`, { + headers: { ...authHeaders, Accept: "text/plain" }, + }); + if (!transcriptRes.ok) { + return upstreamErrorResponse(transcriptRes, await transcriptRes.text()); + } + const text = await transcriptRes.text(); + return Response.json({ text: text || "" }, { headers: { ...CORS_HEADERS } }); + } + + if (result.status === "failed") { + return errorResponse(500, result.failure_detail || "Rev AI transcription failed"); + } + } + + return errorResponse(504, "Rev AI transcription timed out after 120s"); +} + /** * Handle audio transcription request * @@ -451,7 +573,7 @@ export async function handleAudioTranscription({ if (!providerConfig) { return errorResponse( 400, - `No transcription provider found for model "${model}". Available: openai, groq, deepgram, assemblyai, nvidia, huggingface, qwen` + `No transcription provider found for model "${model}". Available: openai, groq, deepgram, assemblyai, nvidia, huggingface, qwen, gladia, rev-ai` ); } @@ -497,6 +619,10 @@ export async function handleAudioTranscription({ return handleAssemblyAITranscription(providerConfig, file, modelId, token); } + if (providerConfig.format === "gladia") { + return handleGladiaTranscription(providerConfig, file, modelId, token); + } + if (providerConfig.format === "nvidia-asr") { return handleNvidiaTranscription(providerConfig, file, modelId, token); } @@ -509,6 +635,10 @@ export async function handleAudioTranscription({ return handleKieAudioTranscription(providerConfig, file, modelId, token); } + if (providerConfig.format === "rev-ai") { + return handleRevAiTranscription(providerConfig, file, modelId, token); + } + // Default: OpenAI/Groq/Qwen3-compatible multipart proxy const extraFields: Record = {}; for (const key of [ diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index e134a744f8..53c4218cf6 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -110,11 +110,13 @@ import { normalizeMimoThinking } from "../services/mimoThinking.ts"; import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts"; import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts"; import { echoModelInObject } from "../services/responseModelEcho.ts"; -import { stripGpt5SamplingWhenReasoning } from "../services/gpt5SamplingGuard.ts"; +import { + stripGpt5SamplingWhenReasoning, + stripGpt5ReasoningWhenTools, +} from "../services/gpt5SamplingGuard.ts"; import { getUnsupportedParams, REGISTRY } from "../config/providerRegistry.ts"; -import { supportsMaxTokens } from "@/lib/modelCapabilities.ts"; +import { supportsMaxTokens, getResolvedModelCapabilities } from "@/lib/modelCapabilities.ts"; import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts"; -import { isVisionModelId } from "@/shared/constants/visionModels.ts"; import { buildErrorBody, createErrorResult, @@ -233,7 +235,10 @@ import { getProviderCredentials, extractSessionAffinityKey } from "@/sse/service import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; import { guardrailRegistry } from "@/lib/guardrails"; -import { shouldPreserveCacheControl } from "../utils/cacheControlPolicy.ts"; +import { + shouldPreserveCacheControl, + resolveConnectionCacheOverride, +} from "../utils/cacheControlPolicy.ts"; import { getCachedSettings } from "@/lib/db/readCache"; import { applyCodexGlobalFastServiceTier } from "@/lib/providers/codexFastTier"; import { buildUpstreamHeadersForExecute as buildUpstreamHeadersForExecuteFor } from "./chatCore/upstreamExecuteHeaders.ts"; @@ -1180,12 +1185,15 @@ export async function handleChatCore({ if (compressionHeader) { log?.debug?.("COMPRESSION", `x-omniroute-compression header: ${compressionHeader}`); } + const connectionCacheOverride = resolveConnectionCacheOverride( + credentials?.providerSpecificData + ); const modeBeforeOutputTransform = selectCompressionStrategy( config, compressionComboKey, estimatedTokens, body as Record, - { provider, targetFormat, model: effectiveModel }, + { provider, targetFormat, model: effectiveModel, connectionCacheOverride }, namedCombos, compressionHeader ); @@ -1284,7 +1292,7 @@ export async function handleChatCore({ compressionComboKey, estimatedTokens, compressionInputBody, - { provider, targetFormat, model: effectiveModel }, + { provider, targetFormat, model: effectiveModel, connectionCacheOverride }, namedCombos, compressionHeader, { @@ -1323,11 +1331,20 @@ export async function handleChatCore({ // #3890: in a caching context, never compress the system prompt (cacheable prefix) // even if the operator disabled preserveSystemPrompt — honors the cache-aware flag // that selectCompressionStrategy can only partially apply via the mode string. - const cacheCtx = { provider, targetFormat, model: effectiveModel }; + const cacheCtx = { provider, targetFormat, model: effectiveModel, connectionCacheOverride }; const compressionConfig = resolveCacheAwareConfig(config, compressionInputBody, cacheCtx); const result = await applyCompressionAsync(compressionInputBody, mode, { model: effectiveModel, - supportsVision: isVisionModelId(effectiveModel), + // #7237: feed the AUTHORITATIVE capability (model spec / models.dev sync / DB + // override, with the conservative model-id fragment heuristic only as its + // last-resort fallback) instead of calling the heuristic directly here. The + // heuristic alone wrongly returned false for e.g. gpt-5.5 (registered + // supportsVision:true in modelSpecs but absent from the deliberately-conservative + // fragment list), and lite.ts's gate (`supportsVision !== false`) treated that + // false as "strip every image_url block". Resolves to `null` for genuinely unknown + // models, which is intentionally NOT `false` so the gate still preserves images. + supportsVision: getResolvedModelCapabilities({ provider, model: effectiveModel }) + .supportsVision, // Rota direta oficial ('anthropic') vs agregadores: o engine omniglyph // exige 'direct' — agregadores redimensionam imagens (medido 2026-07-06). providerTransport: provider === "anthropic" ? "direct" : "aggregator", @@ -1455,6 +1472,7 @@ export async function handleChatCore({ effectiveModel, mode, stats: result.stats, + connectionCacheOverride, log, }); log?.info?.( @@ -1650,6 +1668,7 @@ export async function handleChatCore({ // Determine if we should preserve client-side cache_control headers // Fetch settings from DB to get user preference const cacheControlMode = await getCacheControlSettings().catch(() => "auto" as const); + const connectionCacheOverride = resolveConnectionCacheOverride(credentials?.providerSpecificData); const preserveCacheControl = shouldPreserveCacheControl({ userAgent, isCombo, @@ -1657,6 +1676,7 @@ export async function handleChatCore({ targetProvider: provider, targetFormat, settings: { alwaysPreserveClientCache: cacheControlMode }, + connectionCacheOverride, }); if (preserveCacheControl) { @@ -2090,6 +2110,22 @@ export async function handleChatCore({ log ); + // GPT-5.x reasoning models on the raw openai Chat Completions surface reject function + // `tools` combined with an active `reasoning_effort`: HTTP 400 "Function tools with + // reasoning_effort are not supported ... Please use /v1/responses instead." This used to + // be true for every GPT-5.x model on the plain `openai` provider, but #7242 (targetFormat + // "openai-responses" on GPT_5_6_API_CAPABILITIES) now routes the GPT-5.6 family to + // /v1/responses instead, which accepts tools + reasoning natively — so the strip must not + // fire there. Pass the already-resolved `targetFormat` so the guard gates on the actual + // upstream surface for this request instead of a model-name list. Port of 9router#2540. + translatedBody = stripGpt5ReasoningWhenTools( + translatedBody, + provider, + finalModelToUpstream, + targetFormat, + log + ); + // Rename max_tokens to max_completion_tokens if not supported (#1961) if (!supportsMaxTokens({ provider, model })) { if (translatedBody.max_tokens !== undefined) { diff --git a/open-sse/handlers/chatCore/compressionCacheStats.ts b/open-sse/handlers/chatCore/compressionCacheStats.ts index 06bca10837..445f7f9c7b 100644 --- a/open-sse/handlers/chatCore/compressionCacheStats.ts +++ b/open-sse/handlers/chatCore/compressionCacheStats.ts @@ -8,6 +8,8 @@ * affects the request. Behaviour is byte-identical to the previous inline block. */ +import type { ConnectionCacheOverride } from "../../utils/cacheControlPolicy.ts"; + type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; export function recordCompressionCacheStats(args: { @@ -17,6 +19,7 @@ export function recordCompressionCacheStats(args: { effectiveModel: string | null | undefined; mode: string; stats: { originalTokens: number; compressedTokens: number }; + connectionCacheOverride?: ConnectionCacheOverride | null; log?: LoggerLike; }): void { void (async () => { @@ -27,6 +30,7 @@ export function recordCompressionCacheStats(args: { provider: args.provider, targetFormat: args.targetFormat, model: args.effectiveModel, + connectionCacheOverride: args.connectionCacheOverride ?? null, }); const tokensSavedCompression = Math.max( 0, diff --git a/open-sse/handlers/chatCore/executionCredentials.ts b/open-sse/handlers/chatCore/executionCredentials.ts index 3d0d7dcf93..573411e9b7 100644 --- a/open-sse/handlers/chatCore/executionCredentials.ts +++ b/open-sse/handlers/chatCore/executionCredentials.ts @@ -55,6 +55,16 @@ export function resolveExecutionCredentials(opts: { providerSpecificData._omnirouteForceResponsesUpstream = true; } + // #7364: "zai"/"glm-coding-apikey" default to the Anthropic Messages wire format + // (registry format:"claude"), but a per-model targetFormat override (custom-model + // dropdown, #2905) can resolve targetFormat to "openai" — e.g. for a vision model + // like glm-4.6v that the operator wants routed through the OpenAI-compatible + // endpoint. DefaultExecutor.buildUrl()'s "zai" branch has no other way to see that + // override, so surface it on providerSpecificData for buildUrl to read. + if (targetFormat === FORMATS.OPENAI && (provider === "zai" || provider === "glm-coding-apikey")) { + providerSpecificData.targetFormat = targetFormat; + } + const withApiType = { ...nextCredentials, providerSpecificData, diff --git a/open-sse/handlers/chatCore/upstreamBody.ts b/open-sse/handlers/chatCore/upstreamBody.ts index c426b240a7..d108e5c2bf 100644 --- a/open-sse/handlers/chatCore/upstreamBody.ts +++ b/open-sse/handlers/chatCore/upstreamBody.ts @@ -15,12 +15,19 @@ import { resolvePayloadRuleProtocols, } from "../../services/payloadRules.ts"; import { getEffectiveToolLimit, getKnownToolLimit } from "../../services/toolLimitDetector.ts"; -import { providerSupportsCaching } from "../../utils/cacheControlPolicy.ts"; +import { + providerSupportsCaching, + resolveConnectionCacheOverride, + type ConnectionCacheOverride, +} from "../../utils/cacheControlPolicy.ts"; import { FORMATS } from "../../translator/formats.ts"; type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; type Body = Record; -type CredentialsLike = { apiKey?: unknown; accessToken?: unknown } | null | undefined; +type CredentialsLike = + | { apiKey?: unknown; accessToken?: unknown; providerSpecificData?: Record | null } + | null + | undefined; function buildAppliedRulesSummary( applied: Array<{ type: string; path: string; value?: unknown }> @@ -100,11 +107,12 @@ function backfillQwenOAuthUser( async function injectPromptCacheKey( bodyToSend: Body, provider: string | null | undefined, - targetFormat: string + targetFormat: string, + connectionCacheOverride: ConnectionCacheOverride | null ): Promise { if ( targetFormat === FORMATS.OPENAI && - providerSupportsCaching(provider) && + providerSupportsCaching(provider, undefined, connectionCacheOverride) && !bodyToSend.prompt_cache_key && Array.isArray(bodyToSend.messages) && !["nvidia", "codex", "xai"].includes(provider) @@ -162,7 +170,8 @@ export async function prepareUpstreamBody(opts: { bodyToSend = truncateToolList(bodyToSend, provider, bypassDefaultToolLimit ?? false, log); bodyToSend = backfillQwenOAuthUser(bodyToSend, provider, credentials, log); - bodyToSend = await injectPromptCacheKey(bodyToSend, provider, targetFormat); + const connectionCacheOverride = resolveConnectionCacheOverride(credentials?.providerSpecificData); + bodyToSend = await injectPromptCacheKey(bodyToSend, provider, targetFormat, connectionCacheOverride); return bodyToSend; } diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 2bfc452a0a..9e0adbca12 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -39,6 +39,7 @@ import { pollComfyResult, fetchComfyOutput, extractComfyOutputFiles, + resolveComfyUiBaseUrl, } from "../utils/comfyuiClient.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { FetchTimeoutError, fetchWithTimeout, getConfiguredTimeout } from "@/shared/utils/fetchTimeout"; @@ -56,12 +57,16 @@ 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 { handleChatGptWebImageGeneration, extractMarkdownImageUrls, CHATGPT_WEB_IMAGE_ID_RE, } from "./imageGeneration/providers/chatgptWeb.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; +import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; +import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts"; +import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; interface KieImageOptions { @@ -447,6 +452,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "segmind") { + return handleSegmindImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "chatgpt-web") { return handleChatGptWebImageGeneration({ model, @@ -459,6 +475,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "designer-web") { + return handleDesignerWebImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "nanobanana") { return handleNanoBananaImageGeneration({ model, @@ -486,7 +513,16 @@ export async function handleImageGeneration({ } if (providerConfig.format === "comfyui") { - return handleComfyUIImageGeneration({ model, provider, providerConfig, body, log }); + return handleComfyUIImageGeneration({ + model, + provider, + providerConfig: { + ...providerConfig, + baseUrl: resolveComfyUiBaseUrl(credentials, providerConfig.baseUrl), + }, + body, + log, + }); } if (providerConfig.format === "codex-responses") { @@ -523,6 +559,16 @@ export async function handleImageGeneration({ log, }); } + if (providerConfig.format === "freepik-image") { + return handleFreepikImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } if (providerConfig.format === "nvidia-nim") { return handleNvidiaNimImageGeneration({ @@ -535,6 +581,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "minimax-image") { + return handleMinimaxImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log }); } diff --git a/open-sse/handlers/imageGeneration/providers/designerWeb.ts b/open-sse/handlers/imageGeneration/providers/designerWeb.ts new file mode 100644 index 0000000000..f32d478d36 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/designerWeb.ts @@ -0,0 +1,265 @@ +// Microsoft Designer (unofficial, reverse-engineered web API) image handler. +// Family: designer-web | Provider: microsoft-designer-web +// Reference: g4f/Provider/needs_auth/MicrosoftDesigner.py (fetched + verified +// during triage of #6672) — Bearer access_token auth against +// designerapp.officeapps.live.com/designerapp/DallE.ashx, submit-then-poll +// for image_urls_thumbnail[].ImageUrl. +// +// The upstream ClientId header is a fixed, publicly-shared value the +// designer.microsoft.com frontend sends on every session (not a secret) — +// routed through resolvePublicCred() per Hard Rule #11 / docs/security/PUBLIC_CREDS.md. + +import { randomUUID, randomBytes } from "node:crypto"; +import { resolvePublicCred } from "../../../utils/publicCreds.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; + +const DESIGNER_WEB_POLL_TIMEOUT_MS_DEFAULT = 60000; +const DESIGNER_WEB_POLL_INTERVAL_MS_DEFAULT = 2000; +const DESIGNER_WEB_BATCH_SIZE = "4"; + +/** Maps an OpenAI-style "WxH" size string to the closest Designer aspect ratio bucket. */ +export function mapDesignerWebImageSize(size: unknown): "1_1" | "16_9" | "9_16" { + if (typeof size !== "string" || !size.includes("x")) return "1_1"; + const [wRaw, hRaw] = size.split("x"); + const w = Number(wRaw); + const h = Number(hRaw); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return "1_1"; + if (w > h * 1.2) return "16_9"; + if (h > w * 1.2) return "9_16"; + return "1_1"; +} + +/** Builds the fixed + per-request headers Microsoft Designer expects on every call. */ +export function buildDesignerWebHeaders({ + accessToken, + sessionId = randomUUID(), + userId = randomBytes(16).toString("hex"), +}: { + accessToken: string; + sessionId?: string; + userId?: string; +}): Record { + return { + Authorization: `Bearer ${accessToken}`, + ClientId: resolvePublicCred("microsoft_designer_client_id"), + SessionId: sessionId, + UserId: userId, + "Content-Type": "application/x-www-form-urlencoded", + }; +} + +/** Builds the DallE.ashx form body from an OpenAI-shaped image-generation request. */ +export function buildDesignerWebFormBody(prompt: string, size: unknown): URLSearchParams { + const params = new URLSearchParams(); + params.set("dalle-caption", prompt); + params.set("dalle-image-size", mapDesignerWebImageSize(size)); + params.set("dalle-batch-size", DESIGNER_WEB_BATCH_SIZE); + params.set("dalle-seed", String(Math.floor(Math.random() * 1_000_000_000))); + return params; +} + +interface DesignerWebParsedResponse { + status: "ready" | "pending" | "empty"; + imageUrls: string[]; + pollIntervalMs: number | null; +} + +/** Parses a DallE.ashx JSON body into a ready/pending/empty verdict. */ +export function parseDesignerWebResponse(json: unknown): DesignerWebParsedResponse { + const body = (json ?? {}) as Record; + const thumbs = Array.isArray(body.image_urls_thumbnail) ? body.image_urls_thumbnail : []; + const imageUrls = thumbs + .map((t) => (t && typeof t === "object" ? (t as Record).ImageUrl : null)) + .filter((u): u is string => typeof u === "string" && u.length > 0); + + if (imageUrls.length > 0) { + return { status: "ready", imageUrls, pollIntervalMs: null }; + } + + const pollingMeta = (body.polling_response as Record | undefined) + ?.polling_meta_data as Record | undefined; + const pollIntervalMs = Number.isFinite(pollingMeta?.poll_interval) + ? Number(pollingMeta?.poll_interval) + : null; + + if (pollIntervalMs !== null) { + return { status: "pending", imageUrls: [], pollIntervalMs }; + } + + return { status: "empty", imageUrls: [], pollIntervalMs: null }; +} + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +interface DesignerWebRequestConfig { + prompt: string; + accessToken: string; + headers: Record; + formBody: URLSearchParams; + timeoutMs: number; + pollIntervalMs: number; +} + +/** Validates the request and resolves auth + poll timing. Returns an error status/message on failure. */ +function resolveDesignerWebRequest( + body: { prompt?: unknown; size?: unknown; timeout_ms?: unknown; poll_interval_ms?: unknown }, + credentials: { apiKey?: string; accessToken?: string } +): { ok: true; config: DesignerWebRequestConfig } | { ok: false; status: number; error: string } { + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return { + ok: false, + status: 400, + error: "Prompt is required for Microsoft Designer image generation", + }; + } + + const accessToken = credentials?.apiKey || credentials?.accessToken; + if (!accessToken) { + return { ok: false, status: 401, error: "Microsoft Designer credentials missing access_token" }; + } + + const timeoutMs = normalizePositiveNumber( + body.timeout_ms, + normalizePositiveNumber( + process.env.DESIGNER_WEB_POLL_TIMEOUT_MS, + DESIGNER_WEB_POLL_TIMEOUT_MS_DEFAULT + ) + ); + const pollIntervalMs = normalizePositiveNumber( + body.poll_interval_ms, + normalizePositiveNumber( + process.env.DESIGNER_WEB_POLL_INTERVAL_MS, + DESIGNER_WEB_POLL_INTERVAL_MS_DEFAULT + ) + ); + + return { + ok: true, + config: { + prompt, + accessToken, + headers: buildDesignerWebHeaders({ accessToken }), + formBody: buildDesignerWebFormBody(prompt, body.size), + timeoutMs, + pollIntervalMs, + }, + }; +} + +type DesignerWebStepResult = + | { done: false; waitMs: number } + | { done: true; success: true; imageUrls: string[] } + | { done: true; success: false; status: number; error: string }; + +/** Runs one submit/poll fetch cycle and classifies the outcome. */ +async function stepDesignerWebPoll( + baseUrl: string, + headers: Record, + formBody: URLSearchParams, + pollIntervalMs: number, + fetchImpl: typeof fetch +): Promise { + const resp = await fetchImpl(baseUrl, { method: "POST", headers, body: formBody }); + + if (!resp.ok) { + return { done: true, success: false, status: resp.status, error: sanitizeErrorMessage(await resp.text()) }; + } + + const parsed = parseDesignerWebResponse(await resp.json()); + + if (parsed.status === "ready") { + return { done: true, success: true, imageUrls: parsed.imageUrls }; + } + if (parsed.status === "empty") { + return { + done: true, + success: false, + status: 502, + error: "Microsoft Designer response did not contain image data or polling metadata", + }; + } + return { done: false, waitMs: Math.min(parsed.pollIntervalMs ?? pollIntervalMs, pollIntervalMs) }; +} + +/** Drives the submit-then-poll loop to completion, timeout, or a terminal error. */ +async function runDesignerWebPollLoop( + baseUrl: string, + config: DesignerWebRequestConfig, + fetchImpl: typeof fetch, + log?: { info?: (...args: unknown[]) => void } +): Promise { + const deadline = Date.now() + config.timeoutMs; + let attempt = 0; + + while (Date.now() < deadline) { + attempt += 1; + const step = await stepDesignerWebPoll( + baseUrl, + config.headers, + config.formBody, + config.pollIntervalMs, + fetchImpl + ); + if (step.done) return step; + log?.info?.("IMAGE", `designer-web pending, poll #${attempt} in ${step.waitMs}ms`); + await new Promise((resolve) => setTimeout(resolve, step.waitMs)); + } + + return { + done: true, + success: false, + status: 504, + error: "Microsoft Designer image generation timed out waiting for a result", + }; +} + +export async function handleDesignerWebImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + fetchImpl = fetch, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: { prompt?: unknown; size?: unknown; timeout_ms?: unknown; poll_interval_ms?: unknown }; + credentials: { apiKey?: string; accessToken?: string }; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + fetchImpl?: typeof fetch; +}) { + const startTime = Date.now(); + const resolved = resolveDesignerWebRequest(body, credentials); + if (!resolved.ok) { + return saveImageErrorResult({ provider, model, status: resolved.status, startTime, error: resolved.error }); + } + + try { + const outcome = await runDesignerWebPollLoop(providerConfig.baseUrl, resolved.config, fetchImpl, log); + if (outcome.success) { + return saveImageSuccessResult({ + provider, + model, + startTime, + images: outcome.imageUrls.map((url) => ({ url })), + }); + } + if (log?.error) { + log.error("IMAGE", `${provider} designer-web error ${outcome.status}: ${outcome.error}`); + } + return saveImageErrorResult({ provider, model, status: outcome.status, startTime, error: outcome.error }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + if (log?.error) { + log.error("IMAGE", `${provider} designer-web exception: ${errorText}`); + } + return saveImageErrorResult({ provider, model, status: 500, startTime, error: errorText }); + } +} diff --git a/open-sse/handlers/imageGeneration/providers/freepik.ts b/open-sse/handlers/imageGeneration/providers/freepik.ts new file mode 100644 index 0000000000..750ca2f664 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/freepik.ts @@ -0,0 +1,284 @@ +// Freepik (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). + +import { saveCallLog } from "@/lib/usageDb"; +import { sleep } from "../../../utils/sleep.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +const DEFAULT_POLL_INTERVAL_MS = 4000; +const DEFAULT_POLL_TIMEOUT_MS = 180000; + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.floor(n); +} + +interface FreepikProviderConfig { + baseUrl: string; + statusUrl?: string; + authHeader?: string; +} + +interface FreepikCredentials { + apiKey?: string; +} + +interface FreepikGenerationParams { + model: string; + provider: string; + providerConfig: FreepikProviderConfig; + body: Record; + credentials: FreepikCredentials; + log?: { info: (tag: string, msg: string) => void; error: (tag: string, msg: string) => void }; +} + +interface FreepikImageResult { + 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"; + return { [headerName]: token }; +} + +async function logAndFail(params: { + provider: string; + model: string; + startTime: number; + status: number; + error: string; +}): Promise { + const { provider, model, startTime, status, error } = params; + const sanitized = sanitizeErrorMessage(error); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: sanitized.slice(0, 500), + }).catch(() => {}); + return { success: false, status, error: sanitized }; +} + +async function submitMysticTask(params: { + providerConfig: FreepikProviderConfig; + token: string; + model: string; + prompt: string; + body: Record; +}) { + const { providerConfig, token, model, prompt, body } = params; + return fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...freepikAuthHeader(providerConfig, token), + }, + body: JSON.stringify({ + prompt, + model: model || "realism", + resolution: typeof body.resolution === "string" ? body.resolution : "1k", + aspect_ratio: typeof body.aspect_ratio === "string" ? body.aspect_ratio : "square_1_1", + }), + }); +} + +async function pollMysticTask(params: { + providerConfig: FreepikProviderConfig; + 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) }, + }); + const json = await res.json(); + const task = json?.data || json; + const status = typeof task?.status === "string" ? task.status : "IN_PROGRESS"; + const generated = Array.isArray(task?.generated) ? task.generated : []; + return { status, imageUrl: typeof generated[0] === "string" ? generated[0] : undefined }; +} + +async function downloadGeneratedImage(imageUrl: string): Promise< + { ok: true; b64: string } | { ok: false; status: number; error: string } +> { + const imgRes = await fetch(imageUrl); + if (!imgRes.ok) { + return { ok: false, status: imgRes.status, error: `Failed to download image: ${imgRes.status}` }; + } + const buf = await imgRes.arrayBuffer(); + return { ok: true, b64: Buffer.from(buf).toString("base64") }; +} + +async function resolveCompletedResult(params: { + provider: string; + model: string; + startTime: number; + imageUrl?: string; +}): Promise { + const { provider, model, startTime, imageUrl } = params; + if (!imageUrl) { + return logAndFail({ + provider, + model, + startTime, + status: 502, + error: "Freepik Mystic completed without a generated image URL", + }); + } + const downloaded = await downloadGeneratedImage(imageUrl); + if (!downloaded.ok) { + return { success: false, status: downloaded.status, error: downloaded.error }; + } + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + }).catch(() => {}); + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: [{ b64_json: downloaded.b64 }] }, + }; +} + +async function pollUntilDone(params: { + providerConfig: FreepikProviderConfig; + token: string; + taskId: string; + provider: string; + model: string; + startTime: number; + pollIntervalMs: number; + pollTimeoutMs: number; +}): Promise { + const { providerConfig, token, taskId, provider, model, startTime, pollIntervalMs, pollTimeoutMs } = + params; + const deadline = Date.now() + pollTimeoutMs; + + while (Date.now() < deadline) { + await sleep(pollIntervalMs); + const { status, imageUrl } = await pollMysticTask({ providerConfig, token, taskId }); + + if (status === "COMPLETED") { + return resolveCompletedResult({ provider, model, startTime, imageUrl }); + } + if (status === "FAILED") { + return logAndFail({ + provider, + model, + startTime, + status: 502, + error: "Freepik Mystic image generation failed", + }); + } + } + + return logAndFail({ + provider, + model, + startTime, + status: 504, + error: "Freepik Mystic image generation timed out", + }); +} + +async function submitAndGetTaskId(params: { + providerConfig: FreepikProviderConfig; + token: string; + model: string; + prompt: string; + body: Record; + provider: string; + startTime: number; +}): Promise<{ taskId: string } | { failed: FreepikImageResult }> { + 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 }) }; + } + + const submitJson = await res.json(); + const taskId = submitJson?.data?.task_id || submitJson?.task_id; + if (!taskId) { + return { + failed: await logAndFail({ + provider, + model, + startTime, + status: 502, + error: "Freepik Mystic did not return a task_id", + }), + }; + } + return { taskId }; +} + +export async function handleFreepikImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: FreepikGenerationParams): 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)}..."`); + } + + try { + const submitted = await submitAndGetTaskId({ + providerConfig, + token, + model, + prompt, + body, + provider, + startTime, + }); + if ("failed" in submitted) return submitted.failed; + + return await pollUntilDone({ + providerConfig, + token, + taskId: submitted.taskId, + provider, + model, + startTime, + pollIntervalMs, + pollTimeoutMs, + }); + } catch (err) { + const message = (err as Error)?.message || String(err); + if (log) log.error("IMAGE", `${provider} freepik error: ${sanitizeErrorMessage(message)}`); + return logAndFail({ + provider, + model, + startTime, + status: 502, + error: `Image provider error: ${message}`, + }); + } +} diff --git a/open-sse/handlers/imageGeneration/providers/minimax.ts b/open-sse/handlers/imageGeneration/providers/minimax.ts new file mode 100644 index 0000000000..1aa0595622 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/minimax.ts @@ -0,0 +1,190 @@ +// #2482: MiniMax Text-to-Image provider handler. +// MiniMax's image_generation endpoint is synchronous (unlike its video/music +// endpoints, which are task-based and polled) and returns image URLs directly +// in `data.image_urls`. This normalizes that response into the OpenAI-compatible +// images payload the rest of the handler expects. + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +interface MinimaxImageGenArgs { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: { prompt?: string; size?: string; n?: number; response_format?: string }; + credentials: { apiKey?: string; accessToken?: string }; + log?: { + info?: (tag: string, msg: string) => void; + error?: (tag: string, msg: string) => void; + } | null; +} + +interface MinimaxCallLogParams { + status: number; + model: string; + provider: string; + duration: number; + error?: string; + requestBody?: unknown; + responseBody?: unknown; +} + +const MINIMAX_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]); + +function mapMinimaxAspectRatio(size?: string): string { + if (size && MINIMAX_ASPECT_RATIOS.has(size)) return size; + return "1:1"; +} + +/** Fire-and-forget usage log for a MiniMax image-generation call. */ +function logMinimaxCall(params: MinimaxCallLogParams): void { + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + ...params, + }).catch(() => {}); +} + +/** Builds the upstream MiniMax request body from the OpenAI-shaped input body. */ +function buildMinimaxUpstreamBody(model: string, prompt: string, body: MinimaxImageGenArgs["body"]) { + return { + model: model || "image-01", + prompt, + aspect_ratio: mapMinimaxAspectRatio(body.size), + n: body.n ?? 1, + response_format: "url", + }; +} + +/** Handles a non-2xx MiniMax response: logs, records the call, and shapes the error result. */ +async function handleMinimaxUpstreamError( + response: Response, + ctx: { provider: string; model: string; startTime: number; upstreamBody: unknown; log?: MinimaxImageGenArgs["log"] } +) { + const errorText = await response.text(); + ctx.log?.error?.("IMAGE", `${ctx.provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + logMinimaxCall({ + status: response.status, + model: `${ctx.provider}/${ctx.model}`, + provider: ctx.provider, + duration: Date.now() - ctx.startTime, + error: errorText.slice(0, 500), + requestBody: ctx.upstreamBody, + }); + + return { success: false as const, status: response.status, error: errorText }; +} + +/** Extracts and validates the `image_urls` array from a MiniMax response payload. */ +function extractMinimaxImageUrls(data: unknown): unknown[] { + const record = data as { data?: { image_urls?: unknown } } | undefined; + return Array.isArray(record?.data?.image_urls) ? (record?.data?.image_urls as unknown[]) : []; +} + +interface MinimaxResultCtx { + provider: string; + model: string; + startTime: number; +} + +/** MiniMax returned 2xx but no images — logs and shapes the empty-result error. */ +function buildMinimaxNoImagesResult(data: unknown, ctx: MinimaxResultCtx) { + const record = data as { base_resp?: { status_msg?: string } } | undefined; + const errorMsg = record?.base_resp?.status_msg || "No images returned from MiniMax"; + logMinimaxCall({ + status: 502, + model: `${ctx.provider}/${ctx.model}`, + provider: ctx.provider, + duration: Date.now() - ctx.startTime, + error: errorMsg, + }); + return { success: false as const, status: 502, error: errorMsg }; +} + +/** MiniMax returned images — logs and shapes the OpenAI-compatible success result. */ +function buildMinimaxSuccessResult(imageUrls: unknown[], prompt: string, ctx: MinimaxResultCtx) { + const images = imageUrls.map((url) => ({ url, revised_prompt: prompt })); + + logMinimaxCall({ + status: 200, + model: `${ctx.provider}/${ctx.model}`, + provider: ctx.provider, + duration: Date.now() - ctx.startTime, + responseBody: { images_count: images.length }, + }); + + return { + success: true as const, + data: { created: Math.floor(Date.now() / 1000), data: images }, + }; +} + +/** Network/parse failure reaching MiniMax — logs and shapes the sanitized error result. */ +function buildMinimaxFetchErrorResult( + err: unknown, + ctx: MinimaxResultCtx & { log?: MinimaxImageGenArgs["log"] } +) { + const errMsg = err instanceof Error ? err.message : String(err); + ctx.log?.error?.("IMAGE", `${ctx.provider} fetch error: ${errMsg}`); + + logMinimaxCall({ + status: 502, + model: `${ctx.provider}/${ctx.model}`, + provider: ctx.provider, + duration: Date.now() - ctx.startTime, + error: errMsg, + }); + + return { + success: false as const, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage(errMsg)}`, + }; +} + +export async function handleMinimaxImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: MinimaxImageGenArgs) { + const startTime = Date.now(); + const token = credentials?.apiKey || credentials?.accessToken || ""; + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + const upstreamBody = buildMinimaxUpstreamBody(model, prompt, body); + + log?.info?.( + "IMAGE", + `${provider}/${model} (minimax-image) | prompt: "${prompt.slice(0, 60)}..." | aspect_ratio: ${upstreamBody.aspect_ratio}` + ); + + try { + const response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + return handleMinimaxUpstreamError(response, { provider, model, startTime, upstreamBody, log }); + } + + const data = await response.json(); + const imageUrls = extractMinimaxImageUrls(data); + const ctx: MinimaxResultCtx = { provider, model, startTime }; + + if (imageUrls.length === 0) { + return buildMinimaxNoImagesResult(data, ctx); + } + + return buildMinimaxSuccessResult(imageUrls, prompt, ctx); + } catch (err: unknown) { + return buildMinimaxFetchErrorResult(err, { provider, model, startTime, log }); + } +} diff --git a/open-sse/handlers/imageGeneration/providers/segmind.ts b/open-sse/handlers/imageGeneration/providers/segmind.ts new file mode 100644 index 0000000000..a042fa148b --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/segmind.ts @@ -0,0 +1,80 @@ +// Segmind image-generation provider (#6656). +// +// Thin body-builder + response-formatter around the shared Segmind REST +// client (open-sse/utils/segmindClient.ts) — see that module for the wire +// shape (x-api-key auth, raw image bytes response, no JSON envelope). + +import { segmindRequest } from "../../../utils/segmindClient.ts"; + +function parseSegmindSize(size: unknown): { width: number; height: number } { + if (typeof size === "string" && size.includes("x")) { + const [w, h] = size.split("x").map(Number); + if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { + return { width: w, height: h }; + } + } + return { width: 1024, height: 1024 }; +} + +function buildSegmindImageBody(body: Record, prompt: string) { + const { width, height } = parseSegmindSize(body.size); + const upstreamBody: Record = { + prompt, + width, + height, + samples: Number(body.n) > 0 ? Number(body.n) : 1, + }; + if (typeof body.negative_prompt === "string") upstreamBody.negative_prompt = body.negative_prompt; + if (typeof body.seed === "number") upstreamBody.seed = body.seed; + return upstreamBody; +} + +function formatSegmindImage(buffer: Buffer, contentType: string, prompt: string, wantsB64: boolean) { + const base64 = buffer.toString("base64"); + if (wantsB64) return { b64_json: base64, revised_prompt: prompt }; + const mimeType = contentType.startsWith("image/") ? contentType : "image/jpeg"; + return { url: `data:${mimeType};base64,${base64}`, revised_prompt: prompt }; +} + +export async function handleSegmindImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const token = credentials?.apiKey || credentials?.accessToken || ""; + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + const upstreamBody = buildSegmindImageBody(body, prompt); + + if (log) { + log.info("IMAGE", `${provider}/${model} (segmind) | prompt: "${prompt.slice(0, 60)}..."`); + } + + const result = await segmindRequest({ + baseUrl: providerConfig.baseUrl, + model, + token, + upstreamBody, + callLogPath: "/v1/images/generations", + provider, + scope: "IMAGE", + log, + }); + + if (!result.ok) { + return { success: false, status: result.status, error: result.error }; + } + + const image = formatSegmindImage( + result.buffer, + result.contentType, + prompt, + body.response_format === "b64_json" + ); + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: [image] }, + }; +} diff --git a/open-sse/handlers/musicGeneration.ts b/open-sse/handlers/musicGeneration.ts index 95b9ebd409..96ddbc2d3e 100644 --- a/open-sse/handlers/musicGeneration.ts +++ b/open-sse/handlers/musicGeneration.ts @@ -22,6 +22,7 @@ import { pollComfyResult, fetchComfyOutput, extractComfyOutputFiles, + resolveComfyUiBaseUrl, } from "../utils/comfyuiClient.ts"; import { saveCallLog } from "@/lib/usageDb"; import { getKieCallbackUrl, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; @@ -119,7 +120,16 @@ export async function handleMusicGeneration({ body, credentials, log }) { } if (providerConfig.format === "comfyui") { - return handleComfyUIMusicGeneration({ model, provider, providerConfig, body, log }); + return handleComfyUIMusicGeneration({ + model, + provider, + providerConfig: { + ...providerConfig, + baseUrl: resolveComfyUiBaseUrl(credentials, providerConfig.baseUrl), + }, + body, + log, + }); } if (providerConfig.format === "kie-music") { diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index faaf50d84b..12f384c6b1 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -556,6 +556,20 @@ export function translateNonStreamingResponse( return convertOpenAINonStreamingToClaude(toRecord(intermediateOpenAI)); } + // Gemini-family clients (Gemini, Antigravity): the streaming SSE path already + // projects OpenAI chunks into the `{ response: { candidates: [...] } }` envelope + // via the registered FORMATS.OPENAI -> FORMATS.ANTIGRAVITY translator + // (translator/response/openai-to-antigravity.ts), but this non-streaming path had + // no equivalent back-conversion step — it silently returned the raw OpenAI + // chat.completion shape (leaking `choices[]`/`tool_calls` instead of + // `candidates[]`/`functionCall`) to any non-streaming Gemini/Antigravity client. + if ( + (sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.ANTIGRAVITY) && + sourceFormat !== targetFormat + ) { + return convertOpenAINonStreamingToGeminiFamily(toRecord(intermediateOpenAI)); + } + // Return intermediateOpenAI (which is either the raw response if unknown targetFormat, or an OpenAI compatible payload) return intermediateOpenAI; } @@ -664,3 +678,92 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco return claudeResponse; } + +const OPENAI_TO_GEMINI_FINISH_REASON: Record = { + stop: "STOP", + length: "MAX_TOKENS", + tool_calls: "STOP", + content_filter: "SAFETY", +}; + +/** + * Parse an OpenAI tool-call `arguments` payload into a Gemini `functionCall.args` + * object. Never throws: a provider emitting malformed/truncated JSON must not take + * down the whole non-streaming response path, so an unparseable payload degrades to + * `{}` (matching the streaming Gemini translator's behaviour). + */ +function parseFunctionCallArgs(args: unknown): Record { + if (typeof args !== "string") return toRecord(args); + try { + return toRecord(JSON.parse(args || "{}")); + } catch { + return {}; + } +} + +/** + * Helper to convert an OpenAI chat.completion JSON object into the Gemini/Antigravity + * `{ response: { candidates: [...] } }` envelope for non-streaming clients. Mirrors the + * shape already produced for streaming by the registered + * FORMATS.OPENAI -> FORMATS.ANTIGRAVITY translator + * (translator/response/openai-to-antigravity.ts) so both paths agree. + */ +function convertOpenAINonStreamingToGeminiFamily(openaiResponse: JsonRecord): JsonRecord { + const choices = openaiResponse.choices as unknown[] | undefined; + const isChoicesArray = Array.isArray(choices); + if (!isChoicesArray && openaiResponse.object !== "chat.completion") { + return openaiResponse; // If it doesn't look like OpenAI, return as-is + } + + const choice = isChoicesArray ? toRecord(choices[0]) : {}; + const messageObj = toRecord(choice.message); + + const parts: JsonRecord[] = []; + const reasoningText = resolveReasoningText(messageObj); + if (reasoningText) { + parts.push({ text: reasoningText, thought: true }); + } + if (typeof messageObj.content === "string" && messageObj.content.length > 0) { + parts.push({ text: messageObj.content }); + } + const toolCalls = Array.isArray(messageObj.tool_calls) ? messageObj.tool_calls : []; + for (const toolCall of toolCalls) { + const toolObj = toRecord(toolCall); + const fn = toRecord(toolObj.function); + parts.push({ + functionCall: { + name: toString(fn.name), + args: parseFunctionCallArgs(fn.arguments), + }, + }); + } + if (parts.length === 0) parts.push({ text: "" }); + + const finishReason = + OPENAI_TO_GEMINI_FINISH_REASON[toString(choice.finish_reason, "stop")] ?? "STOP"; + + const usageSrc = toRecord(openaiResponse.usage); + const promptTokens = toNumber(usageSrc.prompt_tokens, 0); + const completionTokens = toNumber(usageSrc.completion_tokens, 0); + + const geminiResponse: JsonRecord = { + response: { + candidates: [ + { + content: { role: "model", parts }, + finishReason, + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: toNumber(usageSrc.total_tokens, promptTokens + completionTokens), + }, + modelVersion: toString(openaiResponse.model, "unknown"), + responseId: toString(openaiResponse.id, `resp_${Date.now()}`), + }, + }; + + return geminiResponse; +} diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 16c9c27d41..7de016a261 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -1,24 +1,22 @@ /** * Video Generation Handler * - * Handles POST /v1/videos/generations requests. - * Proxies to upstream video generation providers. - * - * Supported provider formats: - * - ComfyUI: submit AnimateDiff/SVD workflow → poll → fetch video - * - SD WebUI: POST to AnimateDiff extension endpoint - * - * Response format (OpenAI-like): - * { - * "created": 1234567890, - * "data": [{ "b64_json": "...", "format": "mp4" }] - * } + * Handles POST /v1/videos/generations requests. Proxies to upstream video + * generation providers (ComfyUI AnimateDiff/SVD, SD WebUI AnimateDiff, and + * more — see the per-format handlers below). Response format (OpenAI-like): + * { "created": 1234567890, "data": [{ "b64_json": "...", "format": "mp4" }] } */ import { getVideoProvider, parseVideoModel } from "../config/videoRegistry.ts"; import { kieExecutor } from "../executors/kie.ts"; import { vertexGenerateVideo } from "../executors/vertexMedia.ts"; import { handleGoogleFlowVideoGeneration } from "./videoGeneration/googleFlowHandler.ts"; +import { handleDeepinfraVideoGeneration } from "./videoGeneration/deepinfraHandler.ts"; +import { handleLeonardoVideoGeneration } from "./videoGeneration/leonardoHandler.ts"; +import { handleDashscopeVideoGeneration } from "./videoGeneration/dashscopeHandler.ts"; +import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts"; +import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts"; +import { handleSegmindVideoGeneration } from "./videoGeneration/providers/segmind.ts"; import { getExecutor } from "../executors/index.ts"; import { isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { @@ -31,6 +29,7 @@ import { pollComfyResult, fetchComfyOutput, extractComfyOutputFiles, + resolveComfyUiBaseUrl, } from "../utils/comfyuiClient.ts"; import { saveCallLog } from "@/lib/usageDb"; import { sanitizeErrorMessage } from "../utils/error.ts"; @@ -67,7 +66,16 @@ export async function handleVideoGeneration({ body, credentials, log }) { } if (providerConfig.format === "comfyui") { - return handleComfyUIVideoGeneration({ model, provider, providerConfig, body, log }); + return handleComfyUIVideoGeneration({ + model, + provider, + providerConfig: { + ...providerConfig, + baseUrl: resolveComfyUiBaseUrl(credentials, providerConfig.baseUrl), + }, + body, + log, + }); } if (providerConfig.format === "sdwebui-video") { @@ -101,6 +109,17 @@ export async function handleVideoGeneration({ body, credentials, log }) { }); } + if (providerConfig.format === "deepinfra-video") { + return handleDeepinfraVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "dashscope-video") { return handleDashscopeVideoGeneration({ model, @@ -112,6 +131,23 @@ export async function handleVideoGeneration({ body, credentials, log }) { }); } + if (providerConfig.format === "segmind") { + return handleSegmindVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "novita-video") { + return handleNovitaVideoGeneration({ model, provider, providerConfig, body, credentials, log }); + } + if (providerConfig.format === "xai-video") { + return handleXaiVideoGeneration({ model, provider, providerConfig, body, credentials, log }); + } + return { success: false, status: 400, @@ -119,177 +155,6 @@ export async function handleVideoGeneration({ body, credentials, log }) { }; } -/** - * Alibaba (DashScope) Wan video generation: create async task → poll → MP4. - * Targets wan2.7-t2v on the DashScope intl region. Reuses the stored alibaba - * provider Bearer apiKey — no separate credential flow. - */ -async function handleDashscopeVideoGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}: { - model: string; - provider: string; - providerConfig: { baseUrl: string; statusUrl?: string }; - body: Record & { - prompt?: unknown; - negative_prompt?: unknown; - size?: unknown; - aspect_ratio?: unknown; - duration?: unknown; - timeout_ms?: unknown; - poll_interval_ms?: unknown; - }; - credentials?: { apiKey?: string; accessToken?: string } | null; - log?: { - info: (scope: string, message: string) => void; - error: (scope: string, message: string) => void; - } | null; -}) { - const startTime = Date.now(); - const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000; - const pollIntervalMs = Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500; - const token = credentials?.apiKey || credentials?.accessToken; - const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); - const statusUrl = (providerConfig.statusUrl || `${baseUrl}/tasks`).replace(/\/$/, ""); - const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); - - if (!token) { - return { success: false, status: 401, error: "Alibaba DashScope API key is required" }; - } - - const sizeParam = normalizeDashscopeSize(body.size, body.aspect_ratio); - const parameters: Record = {}; - if (sizeParam) parameters.size = sizeParam; - if (body.duration != null) parameters.duration = Number(body.duration); - - const payload = { - model, - input: { - prompt, - ...(typeof body.negative_prompt === "string" - ? { negative_prompt: body.negative_prompt } - : {}), - }, - parameters, - }; - - if (log) { - log.info( - "VIDEO", - `${provider}/${model} (dashscope-video) | prompt: "${prompt.slice(0, 60)}..."` - ); - } - - try { - // Step 1: create async task (X-DashScope-Async: enable) - const createRes = await fetch(`${baseUrl}/services/aigc/video-generation/video-synthesis`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "X-DashScope-Async": "enable", - }, - body: JSON.stringify(payload), - }); - const createData = await createRes.json().catch(() => ({})); - const taskId = createData?.output?.task_id; - if (!taskId) { - const errorMessage = - createData?.message || - createData?.errors?.[0]?.message || - "DashScope video generation did not return task_id"; - if (log) { - log.error("VIDEO", `DashScope createTask failed: ${JSON.stringify(createData)}`); - } - return { success: false, status: 502, error: String(errorMessage) }; - } - - // Step 2: poll statusUrl/{task_id} until terminal - const deadline = startTime + timeoutMs; - let lastStatus = "PENDING"; - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - const pollRes = await fetch(`${statusUrl}/${taskId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const pollData = await pollRes.json().catch(() => ({})); - lastStatus = pollData?.output?.task_status || "PENDING"; - - if (lastStatus === "SUCCEEDED") { - const videoUrl = pollData?.output?.video_url; - if (!videoUrl) { - return { - success: false, - status: 502, - error: "DashScope task SUCCEEDED but no video_url", - }; - } - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { videos_count: 1 }, - }).catch(() => {}); - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: [{ url: videoUrl, format: "mp4" }], - }, - }; - } - - if (lastStatus === "FAILED" || lastStatus === "UNKNOWN_ERROR") { - const errorMessage = - pollData?.output?.message || - pollData?.output?.errors?.[0]?.message || - "DashScope video task FAILED"; - return { success: false, status: 502, error: String(errorMessage) }; - } - // PENDING / RUNNING → keep polling - } - - return { - success: false, - status: 504, - error: `DashScope task ${taskId} timed out (status: ${lastStatus})`, - }; - } catch (err: unknown) { - return { - success: false, - status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, - error: sanitizeErrorMessage(err) || "Video provider error", - }; - } -} - -// Map OmniRoute size/aspect_ratio → Alibaba DashScope "WxH" (1280*720). -// Accepts "1280*720", "1280x720", or a ratio "16:9". Returns undefined if unparseable -// (then omitted from the payload so DashScope applies its own default). -function normalizeDashscopeSize(size: unknown, aspectRatio: unknown): string | undefined { - if (typeof size === "string") { - if (/^\d+\*\d+$/.test(size)) return size; - if (/^\d+x\d+$/.test(size)) return size.replace("x", "*"); - } - if (typeof aspectRatio === "string") { - const ratioMap: Record = { - "16:9": "1280*720", - "9:16": "720*1280", - "1:1": "960*960", - }; - return ratioMap[aspectRatio]; - } - return undefined; -} - /** * Veo video generation via Vertex AI (predictLongRunning → poll → MP4). * Uses the Vertex chat credentials (Service Account JSON or Express key). @@ -1156,109 +1021,6 @@ async function handleHaiperVideoGeneration({ return { success: false, status: 504, error: "Haiper video generation timed out" }; } -async function handleLeonardoVideoGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials?.apiKey || ""; - const res = await fetch(providerConfig.baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - modelId: "phoenix", - prompt: body.prompt, - width: 1024, - height: 576, - num_frames: 24, - }), - }); - if (!res.ok) { - const errorText = await res.text(); - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: res.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - return { success: false, status: res.status, error: errorText }; - } - const { sdGenerationJob } = await res.json(); - const genId = sdGenerationJob?.generationId; - if (!genId) { - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "No generation ID returned", - }).catch(() => {}); - return { success: false, status: 502, error: "No generation ID returned" }; - } - const deadline = Date.now() + 300000; - while (Date.now() < deadline) { - await sleep(5000); - const statusRes = await fetch(`${providerConfig.baseUrl}/${genId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const status = await statusRes.json(); - const gen = status.generations_by_pk || status; - if (gen.status === "COMPLETE") { - const imgUrl = gen.generated_images?.[0]?.url; - if (imgUrl) { - const videoRes = await fetch(imgUrl); - const buf = await videoRes.arrayBuffer(); - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - }).catch(() => {}); - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: [{ b64_json: Buffer.from(buf).toString("base64"), format: "mp4" }], - }, - }; - } - } - if (gen.status === "FAILED") { - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "Leonardo video generation failed", - }).catch(() => {}); - return { success: false, status: 502, error: "Leonardo video generation failed" }; - } - } - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 504, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "Leonardo video generation timed out", - }).catch(() => {}); - return { success: false, status: 504, error: "Leonardo video generation timed out" }; -} - function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/open-sse/handlers/videoGeneration/dashscopeHandler.ts b/open-sse/handlers/videoGeneration/dashscopeHandler.ts new file mode 100644 index 0000000000..f1f7665b1b --- /dev/null +++ b/open-sse/handlers/videoGeneration/dashscopeHandler.ts @@ -0,0 +1,174 @@ +import { isJsonObject } from "../../utils/kieTask.ts"; +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +/** + * Alibaba (DashScope) Wan video generation: create async task → poll → MP4. + * Targets wan2.7-t2v on the DashScope intl region. Reuses the stored alibaba + * provider Bearer apiKey — no separate credential flow. + */ +export async function handleDashscopeVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string; statusUrl?: string }; + body: Record & { + prompt?: unknown; + negative_prompt?: unknown; + size?: unknown; + aspect_ratio?: unknown; + duration?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; + }; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; +}) { + const startTime = Date.now(); + const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000; + const pollIntervalMs = Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500; + const token = credentials?.apiKey || credentials?.accessToken; + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const statusUrl = (providerConfig.statusUrl || `${baseUrl}/tasks`).replace(/\/$/, ""); + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + + if (!token) { + return { success: false, status: 401, error: "Alibaba DashScope API key is required" }; + } + + const sizeParam = normalizeDashscopeSize(body.size, body.aspect_ratio); + const parameters: Record = {}; + if (sizeParam) parameters.size = sizeParam; + if (body.duration != null) parameters.duration = Number(body.duration); + + const payload = { + model, + input: { + prompt, + ...(typeof body.negative_prompt === "string" + ? { negative_prompt: body.negative_prompt } + : {}), + }, + parameters, + }; + + if (log) { + log.info( + "VIDEO", + `${provider}/${model} (dashscope-video) | prompt: "${prompt.slice(0, 60)}..."` + ); + } + + try { + // Step 1: create async task (X-DashScope-Async: enable) + const createRes = await fetch(`${baseUrl}/services/aigc/video-generation/video-synthesis`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + }, + body: JSON.stringify(payload), + }); + const createData = await createRes.json().catch(() => ({})); + const taskId = createData?.output?.task_id; + if (!taskId) { + const errorMessage = + createData?.message || + createData?.errors?.[0]?.message || + "DashScope video generation did not return task_id"; + if (log) { + log.error("VIDEO", `DashScope createTask failed: ${JSON.stringify(createData)}`); + } + return { success: false, status: 502, error: String(errorMessage) }; + } + + // Step 2: poll statusUrl/{task_id} until terminal + const deadline = startTime + timeoutMs; + let lastStatus = "PENDING"; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + const pollRes = await fetch(`${statusUrl}/${taskId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const pollData = await pollRes.json().catch(() => ({})); + lastStatus = pollData?.output?.task_status || "PENDING"; + + if (lastStatus === "SUCCEEDED") { + const videoUrl = pollData?.output?.video_url; + if (!videoUrl) { + return { + success: false, + status: 502, + error: "DashScope task SUCCEEDED but no video_url", + }; + } + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { videos_count: 1 }, + }).catch(() => {}); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url: videoUrl, format: "mp4" }], + }, + }; + } + + if (lastStatus === "FAILED" || lastStatus === "UNKNOWN_ERROR") { + const errorMessage = + pollData?.output?.message || + pollData?.output?.errors?.[0]?.message || + "DashScope video task FAILED"; + return { success: false, status: 502, error: String(errorMessage) }; + } + // PENDING / RUNNING → keep polling + } + + return { + success: false, + status: 504, + error: `DashScope task ${taskId} timed out (status: ${lastStatus})`, + }; + } catch (err: unknown) { + return { + success: false, + status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, + error: sanitizeErrorMessage(err) || "Video provider error", + }; + } +} + +// Map OmniRoute size/aspect_ratio → Alibaba DashScope "WxH" (1280*720). +// Accepts "1280*720", "1280x720", or a ratio "16:9". Returns undefined if unparseable +// (then omitted from the payload so DashScope applies its own default). +function normalizeDashscopeSize(size: unknown, aspectRatio: unknown): string | undefined { + if (typeof size === "string") { + if (/^\d+\*\d+$/.test(size)) return size; + if (/^\d+x\d+$/.test(size)) return size.replace("x", "*"); + } + if (typeof aspectRatio === "string") { + const ratioMap: Record = { + "16:9": "1280*720", + "9:16": "720*1280", + "1:1": "960*960", + }; + return ratioMap[aspectRatio]; + } + return undefined; +} diff --git a/open-sse/handlers/videoGeneration/deepinfraHandler.ts b/open-sse/handlers/videoGeneration/deepinfraHandler.ts new file mode 100644 index 0000000000..0312d6080c --- /dev/null +++ b/open-sse/handlers/videoGeneration/deepinfraHandler.ts @@ -0,0 +1,186 @@ +/** + * DeepInfra native text/image-to-video generation. + * + * DeepInfra's `/v1/inference/{model}` endpoint is already proven in this codebase for + * reranking (`open-sse/handlers/rerank.ts` + `open-sse/config/rerankRegistry.ts`) — same + * host, same Bearer auth, same non-OpenAI response shape. This reuses the stored + * `deepinfra` provider credential (already registered for chat) — no new credential flow. + * + * Confirmed against DeepInfra's own docs (https://deepinfra.com//api): the call is + * synchronous — `POST {prompt}` returns `{video_url, seed, request_id, inference_status}` + * directly, no task/poll loop like `kie-video`/`dashscope-video`. + */ + +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { saveCallLog } from "@/lib/usageDb"; + +interface DeepinfraHandlerArgs { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: Record & { + prompt?: unknown; + negative_prompt?: unknown; + image?: unknown; + image_url?: unknown; + seed?: unknown; + }; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: { + info?: (scope: string, message: string) => void; + error?: (scope: string, message: string) => void; + } | null; +} + +interface DeepinfraVideoResponse { + video_url?: unknown; + seed?: unknown; + request_id?: unknown; + inference_status?: { status?: unknown; error?: unknown } | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Builds the DeepInfra `/v1/inference/{model}` request body from the OmniRoute video body. */ +/* @testonly */ export function buildDeepinfraVideoRequestBody( + body: DeepinfraHandlerArgs["body"] +): Record { + const payload: Record = { + prompt: typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""), + }; + if (typeof body.negative_prompt === "string" && body.negative_prompt) { + payload.negative_prompt = body.negative_prompt; + } + const image = body.image ?? body.image_url; + if (typeof image === "string" && image) { + payload.image = image; + } + if (typeof body.seed === "number" && Number.isFinite(body.seed)) { + payload.seed = body.seed; + } + return payload; +} + +/** Extracts a human-readable error message from a DeepInfra error/inference_status payload. */ +/* @testonly */ export function extractDeepinfraErrorMessage(data: unknown): string | null { + if (!isRecord(data)) return null; + const direct = data.error ?? data.detail ?? data.message; + if (typeof direct === "string" && direct) return direct; + if (isRecord(direct) && typeof direct.message === "string" && direct.message) { + return direct.message; + } + const status = data.inference_status; + if (isRecord(status) && typeof status.error === "string" && status.error) { + return status.error; + } + return null; +} + +interface CallLogContext { + provider: string; + model: string; + startTime: number; +} + +function logDeepinfraCall( + ctx: CallLogContext, + status: number, + extra: { error?: string; responseBody?: Record } +) { + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status, + model: `${ctx.provider}/${ctx.model}`, + provider: ctx.provider, + duration: Date.now() - ctx.startTime, + ...extra, + }).catch(() => {}); +} + +function buildDeepinfraFetchError( + ctx: CallLogContext, + status: number, + data: unknown, + log?: DeepinfraHandlerArgs["log"] +) { + const errorMessage = extractDeepinfraErrorMessage(data) || `DeepInfra returned HTTP ${status}`; + log?.error?.("VIDEO", `${ctx.provider} deepinfra-video error ${status}: ${errorMessage}`); + logDeepinfraCall(ctx, status, { error: errorMessage.slice(0, 500) }); + return { success: false, status, error: errorMessage }; +} + +function buildDeepinfraSuccess(ctx: CallLogContext, videoUrl: string) { + logDeepinfraCall(ctx, 200, { responseBody: { videos_count: 1 } }); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url: videoUrl, format: "mp4" }], + }, + }; +} + +function buildDeepinfraCatchError( + ctx: CallLogContext, + err: unknown, + log?: DeepinfraHandlerArgs["log"] +) { + const errorMessage = sanitizeErrorMessage(err) || "Video provider error"; + log?.error?.("VIDEO", `${ctx.provider} deepinfra-video error: ${errorMessage}`); + logDeepinfraCall(ctx, 502, { error: errorMessage }); + return { success: false, status: 502, error: errorMessage }; +} + +async function fetchDeepinfraVideo( + baseUrl: string, + model: string, + token: string, + requestBody: unknown +) { + const res = await fetch(`${baseUrl}/${model}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }); + const data: DeepinfraVideoResponse = await res.json().catch(() => ({})); + return { res, data }; +} + +export async function handleDeepinfraVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: DeepinfraHandlerArgs) { + const ctx: CallLogContext = { provider, model, startTime: Date.now() }; + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return { success: false, status: 401, error: "DeepInfra API key is required" }; + } + + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const requestBody = buildDeepinfraVideoRequestBody(body); + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log?.info?.("VIDEO", `${provider}/${model} (deepinfra-video) | prompt: "${promptPreview}..."`); + + try { + const { res, data } = await fetchDeepinfraVideo(baseUrl, model, token, requestBody); + if (!res.ok) return buildDeepinfraFetchError(ctx, res.status, data, log); + + const videoUrl = typeof data.video_url === "string" ? data.video_url : null; + if (!videoUrl) { + const errorMessage = + extractDeepinfraErrorMessage(data) || "DeepInfra video generation did not return video_url"; + return { success: false, status: 502, error: errorMessage }; + } + + return buildDeepinfraSuccess(ctx, videoUrl); + } catch (err: unknown) { + return buildDeepinfraCatchError(ctx, err, log); + } +} diff --git a/open-sse/handlers/videoGeneration/leonardoHandler.ts b/open-sse/handlers/videoGeneration/leonardoHandler.ts new file mode 100644 index 0000000000..c583e4194b --- /dev/null +++ b/open-sse/handlers/videoGeneration/leonardoHandler.ts @@ -0,0 +1,116 @@ +/** + * Leonardo AI (Phoenix) video generation: submit → poll → fetch output. + * + * Extracted out of the frozen `videoGeneration.ts` god-file (unchanged behavior) to make + * room for the new DeepInfra video adapter without pushing the file-size ratchet over its + * baseline — mirrors the existing `googleFlowHandler.ts` extraction. + */ + +import { saveCallLog } from "@/lib/usageDb"; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function handleLeonardoVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials?.apiKey || ""; + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + modelId: "phoenix", + prompt: body.prompt, + width: 1024, + height: 576, + num_frames: 24, + }), + }); + if (!res.ok) { + const errorText = await res.text(); + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: res.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + return { success: false, status: res.status, error: errorText }; + } + const { sdGenerationJob } = await res.json(); + const genId = sdGenerationJob?.generationId; + if (!genId) { + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "No generation ID returned", + }).catch(() => {}); + return { success: false, status: 502, error: "No generation ID returned" }; + } + const deadline = Date.now() + 300000; + while (Date.now() < deadline) { + await sleep(5000); + const statusRes = await fetch(`${providerConfig.baseUrl}/${genId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const status = await statusRes.json(); + const gen = status.generations_by_pk || status; + if (gen.status === "COMPLETE") { + const imgUrl = gen.generated_images?.[0]?.url; + if (imgUrl) { + const videoRes = await fetch(imgUrl); + const buf = await videoRes.arrayBuffer(); + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + }).catch(() => {}); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: Buffer.from(buf).toString("base64"), format: "mp4" }], + }, + }; + } + } + if (gen.status === "FAILED") { + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "Leonardo video generation failed", + }).catch(() => {}); + return { success: false, status: 502, error: "Leonardo video generation failed" }; + } + } + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 504, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "Leonardo video generation timed out", + }).catch(() => {}); + return { success: false, status: 504, error: "Leonardo video generation timed out" }; +} diff --git a/open-sse/handlers/videoGeneration/novita.ts b/open-sse/handlers/videoGeneration/novita.ts new file mode 100644 index 0000000000..1107cae981 --- /dev/null +++ b/open-sse/handlers/videoGeneration/novita.ts @@ -0,0 +1,128 @@ +/** + * Novita AI video generation — pure helpers. + * + * Novita exposes per-model async endpoints under `/v3/async/` (e.g. + * `/v3/async/wan-t2v`, `/v3/async/kling-v1.6-t2v`) — unlike DashScope/Kie there is + * no single shared submit path; the model id IS the path segment. Every model + * shares one poll endpoint: `GET /v3/async/task-result?task_id=...`, returning + * `{ task: { status, reason, progress_percent }, videos: [{ video_url }] }`. + * Confirmed against Novita's published API reference (2026-07-17): + * https://novita.ai/docs/api-reference/model-apis-wan-t2v + * https://novita.ai/docs/api-reference/model-apis-kling-v1.6-t2v + */ + +export interface NovitaVideoParams { + prompt: string; + negativePrompt?: string; + duration?: number; + width?: number; + height?: number; +} + +export interface NovitaTaskResult { + /** true when the task has reached a terminal state (succeeded or failed) */ + done: boolean; + status: string; + videoUrl?: string; + errorMessage?: string; +} + +const NOVITA_TERMINAL_SUCCESS = new Set(["TASK_STATUS_SUCCEED", "SUCCEED", "SUCCEEDED"]); +const NOVITA_TERMINAL_FAILURE = new Set(["TASK_STATUS_FAILED", "FAILED", "UNKNOWN_ERROR"]); + +/** Build the submit URL for a given Novita model slug: `/`. */ +export function buildNovitaSubmitUrl(baseUrl: string, model: string): string { + return `${baseUrl.replace(/\/$/, "")}/${model}`; +} + +/** Build the poll URL for a task id: `?task_id=`. */ +export function buildNovitaPollUrl(statusUrl: string, taskId: string): string { + return `${statusUrl.replace(/\/$/, "")}?task_id=${encodeURIComponent(taskId)}`; +} + +/** + * Normalize an OpenAI-style /v1/videos/generations body into Novita params. + * Accepts both snake_case (OpenAI) and a "WxH"/"WxHxN" style `size` string. + */ +export function normalizeNovitaVideoParams( + body: Record | null | undefined +): NovitaVideoParams { + const b = body ?? {}; + const prompt = typeof b.prompt === "string" ? b.prompt : String(b.prompt ?? ""); + const negativePrompt = typeof b.negative_prompt === "string" ? b.negative_prompt : undefined; + + const durationRaw = typeof b.duration === "number" ? b.duration : undefined; + const duration = + typeof durationRaw === "number" && Number.isFinite(durationRaw) && durationRaw > 0 + ? durationRaw + : undefined; + + let width: number | undefined; + let height: number | undefined; + if (typeof b.size === "string") { + const match = /^(\d+)\s*[x*]\s*(\d+)/.exec(b.size); + if (match) { + width = Number(match[1]); + height = Number(match[2]); + } + } + + return { prompt, negativePrompt, duration, width, height }; +} + +/** + * Build the Novita submit request body. Only includes optional fields that were + * actually resolved — Novita applies its own defaults for the rest. + */ +export function buildNovitaSubmitBody(params: NovitaVideoParams): Record { + const payload: Record = { prompt: params.prompt }; + if (params.negativePrompt) payload.negative_prompt = params.negativePrompt; + if (typeof params.duration === "number") payload.duration = params.duration; + if (typeof params.width === "number") payload.width = params.width; + if (typeof params.height === "number") payload.height = params.height; + return payload; +} + +/** Extract the async task id from a submit response. */ +export function parseNovitaTaskId(json: unknown): string | null { + if (!json || typeof json !== "object") return null; + const taskId = (json as { task_id?: unknown }).task_id; + return typeof taskId === "string" && taskId.length > 0 ? taskId : null; +} + +/** + * Interpret a `/v3/async/task-result` poll response into a normalized result. + * `done: false` means still queued/processing — callers should keep polling. + */ +function extractNovitaVideoUrl(json: unknown): string | null { + const videos = (json as { videos?: unknown })?.videos; + if (!Array.isArray(videos) || videos.length === 0) return null; + const first = videos[0]; + if (!first || typeof first !== "object") return null; + const videoUrl = (first as Record).video_url; + return typeof videoUrl === "string" && videoUrl.length > 0 ? videoUrl : null; +} + +export function parseNovitaTaskResult(json: unknown): NovitaTaskResult { + if (!json || typeof json !== "object") { + return { done: false, status: "UNKNOWN" }; + } + + const task = (json as { task?: unknown }).task; + const taskRec = task && typeof task === "object" ? (task as Record) : {}; + const status = typeof taskRec.status === "string" ? taskRec.status : "UNKNOWN"; + + if (NOVITA_TERMINAL_FAILURE.has(status)) { + const reason = typeof taskRec.reason === "string" && taskRec.reason ? taskRec.reason : null; + return { done: true, status, errorMessage: reason || `Novita video task ${status}` }; + } + + if (!NOVITA_TERMINAL_SUCCESS.has(status)) { + return { done: false, status }; + } + + const videoUrl = extractNovitaVideoUrl(json); + if (videoUrl) return { done: true, status, videoUrl }; + + return { done: true, status, errorMessage: "Novita task succeeded but returned no video_url" }; +} diff --git a/open-sse/handlers/videoGeneration/novitaHandler.ts b/open-sse/handlers/videoGeneration/novitaHandler.ts new file mode 100644 index 0000000000..a9ddf1441e --- /dev/null +++ b/open-sse/handlers/videoGeneration/novitaHandler.ts @@ -0,0 +1,146 @@ +/** + * Novita AI video generation — request orchestration. + * + * Reuses the stored Novita provider Bearer apiKey (same credential the Novita + * chat/LLM gateway already uses — no separate credential flow). Submits to the + * model-specific `/v3/async/` endpoint, polls the shared `task-result` + * endpoint by `task_id` with backoff, and returns the OpenAI-like response shape. + */ + +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { + buildNovitaPollUrl, + buildNovitaSubmitBody, + buildNovitaSubmitUrl, + normalizeNovitaVideoParams, + parseNovitaTaskId, + parseNovitaTaskResult, +} from "./novita.ts"; + +interface NovitaHandlerArgs { + model: string; + provider: string; + providerConfig: { baseUrl: string; statusUrl?: string }; + body: Record & { timeout_ms?: unknown; poll_interval_ms?: unknown }; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: { + info?: (scope: string, message: string) => void; + error?: (scope: string, message: string) => void; + } | null; +} + +const DEFAULT_TIMEOUT_MS = 300_000; +const DEFAULT_POLL_INTERVAL_MS = 2500; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +type NovitaHandlerResult = + | { success: true; data: { created: number; data: [{ url: string; format: string }] } } + | { success: false; status: number; error: string }; + +/** Submit the async video task; returns the task_id or a ready-to-return error result. */ +async function submitNovitaTask( + submitUrl: string, + headers: Record, + payload: Record, + log: NovitaHandlerArgs["log"] +): Promise<{ taskId: string } | { error: NovitaHandlerResult }> { + const submitRes = await fetch(submitUrl, { method: "POST", headers, body: JSON.stringify(payload) }); + const submitData = await submitRes.json().catch(() => ({})); + const taskId = parseNovitaTaskId(submitData); + if (taskId) return { taskId }; + + const errorMessage = + (submitData as { message?: unknown })?.message || "Novita did not return a task_id"; + log?.error?.("VIDEO", `Novita createTask failed: ${JSON.stringify(submitData)}`); + return { + error: { + success: false, + status: submitRes.ok ? 502 : submitRes.status, + error: String(errorMessage), + }, + }; +} + +/** Resolve the request timeout + poll interval, falling back to the module defaults. */ +function resolveNovitaTiming(body: NovitaHandlerArgs["body"]): { + timeoutMs: number; + pollIntervalMs: number; +} { + const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : DEFAULT_TIMEOUT_MS; + const pollIntervalMs = + Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : DEFAULT_POLL_INTERVAL_MS; + return { timeoutMs, pollIntervalMs }; +} + +/** Poll task-result until terminal (success/failure) or the deadline elapses. */ +async function pollNovitaTask( + pollUrl: string, + token: string, + taskId: string, + deadline: number, + pollIntervalMs: number +): Promise { + let lastStatus = "UNKNOWN"; + + while (Date.now() < deadline) { + await sleep(pollIntervalMs); + const pollRes = await fetch(pollUrl, { headers: { Authorization: `Bearer ${token}` } }); + const pollData = await pollRes.json().catch(() => ({})); + const result = parseNovitaTaskResult(pollData); + lastStatus = result.status; + + if (!result.done) continue; + + if (result.videoUrl) { + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: [{ url: result.videoUrl, format: "mp4" }] }, + }; + } + + return { success: false, status: 502, error: sanitizeErrorMessage(result.errorMessage) }; + } + + return { success: false, status: 504, error: `Novita task ${taskId} timed out (status: ${lastStatus})` }; +} + +export async function handleNovitaVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: NovitaHandlerArgs): Promise { + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return { success: false, status: 401, error: "Novita AI API key is required" }; + } + + const { timeoutMs, pollIntervalMs } = resolveNovitaTiming(body); + + const statusUrl = providerConfig.statusUrl || `${providerConfig.baseUrl}/task-result`; + const submitUrl = buildNovitaSubmitUrl(providerConfig.baseUrl, model); + const params = normalizeNovitaVideoParams(body); + const payload = buildNovitaSubmitBody(params); + const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + + log?.info?.("VIDEO", `${provider}/${model} (novita-video) | prompt: "${params.prompt.slice(0, 60)}..."`); + + try { + const submitted = await submitNovitaTask(submitUrl, headers, payload, log); + if ("error" in submitted) return submitted.error; + + const pollUrl = buildNovitaPollUrl(statusUrl, submitted.taskId); + return await pollNovitaTask(pollUrl, token, submitted.taskId, Date.now() + timeoutMs, pollIntervalMs); + } catch (err) { + const e = (err ?? {}) as { message?: string; status?: number }; + log?.error?.("VIDEO", `Novita video generation failed: ${e.message}`); + return { + success: false, + status: typeof e.status === "number" ? e.status : 502, + error: sanitizeErrorMessage(e.message || "Novita video generation failed"), + }; + } +} diff --git a/open-sse/handlers/videoGeneration/providers/segmind.ts b/open-sse/handlers/videoGeneration/providers/segmind.ts new file mode 100644 index 0000000000..cc00bc9ab1 --- /dev/null +++ b/open-sse/handlers/videoGeneration/providers/segmind.ts @@ -0,0 +1,57 @@ +// Segmind video-generation provider (#6656). +// +// Thin body-builder + response-formatter around the shared Segmind REST +// client (open-sse/utils/segmindClient.ts) — same wire shape as the image +// handler (imageGeneration/providers/segmind.ts): x-api-key auth, raw video +// bytes response (e.g. `video/mp4`) on success, no JSON envelope. + +import { segmindRequest } from "../../../utils/segmindClient.ts"; + +function buildSegmindVideoBody(body: Record, prompt: string) { + const upstreamBody: Record = { prompt }; + if (typeof body.negative_prompt === "string") upstreamBody.negative_prompt = body.negative_prompt; + if (typeof body.seed === "number") upstreamBody.seed = body.seed; + if (body.duration != null) upstreamBody.duration = Number(body.duration); + if (typeof body.aspect_ratio === "string") upstreamBody.aspect_ratio = body.aspect_ratio; + return upstreamBody; +} + +export async function handleSegmindVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const token = credentials?.apiKey || credentials?.accessToken || ""; + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + const upstreamBody = buildSegmindVideoBody(body, prompt); + + if (log) { + log.info("VIDEO", `${provider}/${model} (segmind) | prompt: "${prompt.slice(0, 60)}..."`); + } + + const result = await segmindRequest({ + baseUrl: providerConfig.baseUrl, + model, + token, + upstreamBody, + callLogPath: "/v1/videos/generations", + provider, + scope: "VIDEO", + log, + }); + + if (!result.ok) { + return { success: false, status: result.status, error: result.error }; + } + + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: result.buffer.toString("base64"), format: "mp4" }], + }, + }; +} diff --git a/open-sse/handlers/videoGeneration/xaiGrokImagineHandler.ts b/open-sse/handlers/videoGeneration/xaiGrokImagineHandler.ts new file mode 100644 index 0000000000..3a785cc8bd --- /dev/null +++ b/open-sse/handlers/videoGeneration/xaiGrokImagineHandler.ts @@ -0,0 +1,243 @@ +/** + * xAI Grok Imagine video generation: create async job → poll → MP4. + * Reuses the stored xai provider Bearer apiKey (same credential the + * image-generation "xai" entry in imageRegistry.ts already uses) — no + * separate credential flow. Mirrors the DashScope create+poll shape in + * videoGeneration.ts, adapted to xAI's request_id / status + * ("pending"|"processing"|"done"|"failed") job shape + * (https://docs.x.ai/developers/rest-api-reference/inference/videos). + */ + +import { isJsonObject } from "../../utils/kieTask.ts"; +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +interface XaiVideoBody { + prompt?: unknown; + image?: unknown; + duration?: unknown; + aspect_ratio?: unknown; + resolution?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; + [key: string]: unknown; +} + +interface XaiVideoLog { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; +} + +/** Map the OmniRoute video body onto xAI's create-job payload. */ +function buildXaiVideoPayload(model: string, prompt: string, body: XaiVideoBody) { + const payload: Record = { model, prompt }; + if (typeof body.image === "string") payload.image = body.image; + if (body.duration != null) payload.duration = Number(body.duration); + if (typeof body.aspect_ratio === "string") payload.aspect_ratio = body.aspect_ratio; + if (typeof body.resolution === "string") payload.resolution = body.resolution; + return payload; +} + +/** POST the create-job request; resolves to the request_id or a ready error message. */ +async function createXaiVideoJob({ + baseUrl, + token, + payload, + log, +}: { + baseUrl: string; + token: string; + payload: Record; + log?: XaiVideoLog | null; +}): Promise<{ requestId?: string; error?: string }> { + const createRes = await fetch(`${baseUrl}/generations`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const createData = await createRes.json().catch(() => ({})); + const requestId = createData?.request_id; + if (requestId) return { requestId: String(requestId) }; + + const errorMessage = + createData?.error?.message || + createData?.message || + "xAI video generation did not return request_id"; + if (log) { + log.error("VIDEO", `xAI createJob failed: ${JSON.stringify(createData)}`); + } + return { error: String(errorMessage) }; +} + +type XaiPollOutcome = + | { terminal: "done"; videoUrl?: string } + | { terminal: "failed"; error?: unknown } + | { terminal: "timeout"; lastStatus: string }; + +/** + * Poll statusUrl/{request_id} until a terminal status or the deadline. + * Date.now() is read only in the loop condition, so the caller keeps full + * control over the timeout budget it computed from its own startTime. + */ +async function pollXaiVideoJob({ + statusUrl, + requestId, + token, + deadline, + pollIntervalMs, +}: { + statusUrl: string; + requestId: string; + token: string; + deadline: number; + pollIntervalMs: number; +}): Promise { + let lastStatus = "pending"; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + const pollRes = await fetch(`${statusUrl}/${requestId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const pollData = await pollRes.json().catch(() => ({})); + lastStatus = pollData?.status || "pending"; + + if (lastStatus === "done") return { terminal: "done", videoUrl: pollData?.video?.url }; + if (lastStatus === "failed") return { terminal: "failed", error: pollData?.error }; + // pending / processing → keep polling + } + return { terminal: "timeout", lastStatus }; +} + +/** Resolve the request knobs (timeouts, credential, endpoints, prompt) from the call. */ +function resolveXaiVideoOptions( + body: XaiVideoBody, + providerConfig: { baseUrl: string; statusUrl?: string }, + credentials?: { apiKey?: string; accessToken?: string } | null +) { + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + return { + timeoutMs: Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000, + pollIntervalMs: Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500, + token: credentials?.apiKey || credentials?.accessToken, + baseUrl, + statusUrl: (providerConfig.statusUrl || baseUrl).replace(/\/$/, ""), + prompt: typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""), + }; +} + +/** Map a terminal poll outcome onto the OpenAI-like video response (or an error). */ +function buildXaiVideoResponse({ + outcome, + requestId, + provider, + model, + startTime, +}: { + outcome: XaiPollOutcome; + requestId: string; + provider: string; + model: string; + startTime: number; +}) { + if (outcome.terminal === "failed") { + return { success: false, status: 502, error: String(outcome.error || "xAI video job failed") }; + } + + if (outcome.terminal === "timeout") { + return { + success: false, + status: 504, + error: `xAI video job ${requestId} timed out (status: ${outcome.lastStatus})`, + }; + } + + if (!outcome.videoUrl) { + return { success: false, status: 502, error: "xAI video job done but no video.url" }; + } + + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { videos_count: 1 }, + }).catch(() => {}); + + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url: outcome.videoUrl, format: "mp4" }], + }, + }; +} + +export async function handleXaiVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string; statusUrl?: string }; + body: XaiVideoBody; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: XaiVideoLog | null; +}) { + const startTime = Date.now(); + const { timeoutMs, pollIntervalMs, token, baseUrl, statusUrl, prompt } = resolveXaiVideoOptions( + body, + providerConfig, + credentials + ); + + if (!token) { + return { success: false, status: 401, error: "xAI API key is required" }; + } + + if (log) { + log.info("VIDEO", `${provider}/${model} (xai-video) | prompt: "${prompt.slice(0, 60)}..."`); + } + + try { + const created = await createXaiVideoJob({ + baseUrl, + token, + payload: buildXaiVideoPayload(model, prompt, body), + log, + }); + if (!created.requestId) { + return { success: false, status: 502, error: created.error }; + } + + const outcome = await pollXaiVideoJob({ + statusUrl, + requestId: created.requestId, + token, + deadline: startTime + timeoutMs, + pollIntervalMs, + }); + + return buildXaiVideoResponse({ + outcome, + requestId: created.requestId, + provider, + model, + startTime, + }); + } catch (err: unknown) { + return { + success: false, + status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, + error: sanitizeErrorMessage(err) || "Video provider error", + }; + } +} diff --git a/open-sse/services/claudeTurnstileSolver.ts b/open-sse/services/claudeTurnstileSolver.ts index c206edf563..2f3af01fa3 100644 --- a/open-sse/services/claudeTurnstileSolver.ts +++ b/open-sse/services/claudeTurnstileSolver.ts @@ -10,7 +10,7 @@ * 6. Returns fresh cookie for tls-client-node */ -import { chromium, type Browser, type Page } from "playwright"; +import type { Browser, Page } from "playwright"; const CLAUDE_WEB_URL = "https://claude.ai"; const CHALLENGE_TIMEOUT = 60000; // 60s to solve challenge @@ -80,7 +80,9 @@ export async function solveTurnstile(options?: { let page: Page | null = null; try { - // Launch headless browser + // Launch headless browser (lazy import — avoids crashing platforms + // playwright-core doesn't support, e.g. Termux/Android, on module load) + const { chromium } = await import("playwright"); browser = await chromium.launch({ headless }); const context = await browser.newContext({ userAgent: diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 21735e31a4..a1f43236a6 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -146,6 +146,7 @@ import { } from "./combo/comboPredicates.ts"; import { applyComboTargetExhaustion } from "./combo/targetExhaustion.ts"; import { executeRuntimeUnitCombo } from "./combo/runtimeUnits.ts"; +import { extractFusionPanelSpec, buildFusionHandleSingleModel } from "./combo/fusionPanel.ts"; import { isRecord } from "./combo/comboData.ts"; import { expandProviderWildcardsInCombo, @@ -818,20 +819,47 @@ export async function handleComboChat({ ); } if (strategy === "fusion") { - const fusionModels = (combo.models || []) - .map((m) => { - if (typeof m === "string") return m; - if (m && typeof m === "object") { - const obj = m as Record; - if (typeof obj.model === "string") return obj.model; - } - return null; - }) - .filter((m): m is string => Boolean(m)); + const { panel: fusionModels, comboRefUnits } = extractFusionPanelSpec( + combo.models || [], + combo.name, + allCombos + ); + // Untyped like the existing `nestingContext` further down — `nesting` is + // already `ComboNestingContext | null` per HandleComboChatOptions, no new + // import needed. + const fusionNesting = nesting || { + depth: 0, + maxDepth: clampComboDepth(config.maxComboDepth), + visitedComboNames: [combo.name], + rootComboName: combo.name, + attemptBudget: { count: 0, limit: MAX_GLOBAL_ATTEMPTS }, + }; + const fusionHandleSingleModel = + comboRefUnits.size > 0 + ? buildFusionHandleSingleModel({ + handleSingleModel: handleSingleModelWithTimeout, + comboRefUnits, + allCombos, + nesting: fusionNesting, + baseOptions: { + body, + combo, + handleSingleModel, + isModelAvailable, + log, + settings, + allCombos, + relayOptions, + signal, + apiKeyAllowedConnections, + }, + runCombo: handleComboChat, + }) + : handleSingleModelWithTimeout; return handleFusionChat({ body, models: fusionModels, - handleSingleModel: handleSingleModelWithTimeout, + handleSingleModel: fusionHandleSingleModel, log, comboName: combo.name, judgeModel, diff --git a/open-sse/services/combo/fusionPanel.ts b/open-sse/services/combo/fusionPanel.ts new file mode 100644 index 0000000000..6397c5120c --- /dev/null +++ b/open-sse/services/combo/fusionPanel.ts @@ -0,0 +1,79 @@ +/** + * Fusion panel member extraction — resolves combo.models entries for the + * fusion strategy, including nested `combo-ref` steps (#6764). + * + * A combo-ref panel member is dispatched as ONE black-box panel voice (a full + * recursive handleComboChat call for the referenced combo, reusing the same + * executeComboRefUnit + cycle/depth guards every other combo-ref-consuming + * strategy already uses) — NOT a fan-out of the referenced combo's own + * targets. This keeps panel sizing and cost predictable and matches how a + * literal `auto/*` string panel member already behaves via the single- + * dispatch safety net in src/sse/handlers/chat.ts. + */ +import { normalizeComboStep } from "../../../src/lib/combos/steps.ts"; +import { executeComboRefUnit } from "./runtimeUnits.ts"; +import type { + ComboCollectionLike, + ComboNestingContext, + HandleComboChatOptions, + HandleSingleModel, + ResolvedComboRefTarget, +} from "./types.ts"; + +export type FusionPanelSpec = { + /** Dispatch keys handed to fusion.ts's `models` — comboName for combo-ref members, plain model string otherwise. */ + panel: string[]; + /** comboName -> resolved combo-ref unit, consumed by buildFusionHandleSingleModel. */ + comboRefUnits: Map; +}; + +export function extractFusionPanelSpec( + models: unknown[], + comboName: string, + allCombos: ComboCollectionLike +): FusionPanelSpec { + const panel: string[] = []; + const comboRefUnits = new Map(); + models.forEach((entry, index) => { + const step = normalizeComboStep(entry, { comboName, index, allCombos }); + if (!step) return; + if (step.kind === "combo-ref") { + if (!comboRefUnits.has(step.comboName)) { + comboRefUnits.set(step.comboName, { + kind: "combo-ref", + stepId: step.id, + executionKey: step.id, + comboName: step.comboName, + weight: step.weight, + label: step.label ?? null, + }); + } + panel.push(step.comboName); + return; + } + panel.push(step.model); + }); + return { panel, comboRefUnits }; +} + +export function buildFusionHandleSingleModel(args: { + handleSingleModel: HandleSingleModel; + comboRefUnits: Map; + allCombos: ComboCollectionLike; + nesting: ComboNestingContext; + baseOptions: HandleComboChatOptions; + runCombo: (options: HandleComboChatOptions) => Promise; +}): HandleSingleModel { + return (body, modelStr, target) => { + const unit = args.comboRefUnits.get(modelStr); + if (!unit) return args.handleSingleModel(body, modelStr, target); + return executeComboRefUnit({ + body, + unit, + allCombos: args.allCombos, + runCombo: args.runCombo, + baseOptions: args.baseOptions, + nesting: args.nesting, + }); + }; +} diff --git a/open-sse/services/combo/runtimeUnits.ts b/open-sse/services/combo/runtimeUnits.ts index d106ade336..e839fc01bb 100644 --- a/open-sse/services/combo/runtimeUnits.ts +++ b/open-sse/services/combo/runtimeUnits.ts @@ -98,7 +98,7 @@ function buildChildNestingContext(args: { }; } -async function executeComboRefUnit(args: { +export async function executeComboRefUnit(args: { body: Record; unit: ResolvedComboRefTarget; allCombos: ComboCollectionLike; diff --git a/open-sse/services/combo/sessionStickiness.ts b/open-sse/services/combo/sessionStickiness.ts index 2b9f746133..d6d6eb06dd 100644 --- a/open-sse/services/combo/sessionStickiness.ts +++ b/open-sse/services/combo/sessionStickiness.ts @@ -35,6 +35,15 @@ * the same dynamic-import-with-injectable-override seam (fail-open on lookup * errors, mirroring resolveSaturation) and gates the pin alongside headroom. * For tests the fetcher is injected via __setStickinessConnectionFetcherForTests. + * • Quota-exhaustion gate (#7387): testStatus/rateLimitedUntil alone still + * miss a connection whose 5h/weekly quota window is depleted but that + * hasn't (yet) received a hard failure severe enough to flip either field — + * exactly what a quota-preflight/dashboard-detected depletion looks like + * before any upstream 429 lands for this run. isAccountQuotaExhausted() + * (src/domain/quotaCache.ts) is the authoritative per-window signal the rest + * of the credential-selection pipeline already gates on (auth.ts, + * sessionAffinityPin.ts); it now also releases the combo-level sticky pin. + * For tests the checker is injected via __setStickinessQuotaCheckerForTests. * * No barrel import — consistent with the other combo/* helpers. * @@ -164,6 +173,51 @@ export function isStickyConnectionTerminallyUnhealthy( return Number.isFinite(rl) && rl > now; } +// ─── Per-window quota-exhaustion gate (#7387) ──────────────────────────────── + +/** + * Injectable quota-exhaustion checker seam (for unit tests that don't want to + * hydrate the real in-memory quota cache). + */ +export type QuotaExhaustionChecker = (connectionId: string) => boolean; + +let _quotaExhaustionOverride: QuotaExhaustionChecker | null = null; + +/** Test-only: inject the quota-exhaustion checker; pass null to restore default. */ +export function __setStickinessQuotaCheckerForTests( + checker: QuotaExhaustionChecker | null +): void { + _quotaExhaustionOverride = checker; +} + +/** + * Is the sticky-bound connection's per-window (5h/weekly) quota exhausted? + * + * `isStickyConnectionTerminallyUnhealthy` above only looks at testStatus/ + * rateLimitedUntil (#6692) — it misses a connection whose quota window is + * fully depleted (per src/domain/quotaCache.ts::isAccountQuotaExhausted, the + * same authoritative per-window signal src/sse/services/auth.ts and + * sessionAffinityPin.ts already gate on) but that hasn't yet received a hard + * failure severe enough to flip testStatus or set rateLimitedUntil. Without + * this check the combo-level sticky pin re-promotes the depleted account on + * every request, defeating whatever strategy picked a healthy one. (#7387) + * + * Dynamic import (mirroring resolveConnectionHealth/resolveSaturation above) + * so this open-sse/ leaf keeps no static edge into src/domain/. Fail-open + * (false) on any lookup error — an unresolved check must never drop a + * healthy pin. + */ +async function isStickyConnectionQuotaExhausted(connectionId: string): Promise { + if (_quotaExhaustionOverride) return _quotaExhaustionOverride(connectionId); + + try { + const mod = await import("../../../src/domain/quotaCache"); + return Boolean(mod.isAccountQuotaExhausted(connectionId)); + } catch { + return false; + } +} + /** * Resolve the HeadroomSaturation for a connection by fetching both the 5h and * weekly utilisation signals. Uses the same dynamic-import pattern as @@ -374,15 +428,17 @@ export async function applySessionStickiness( // accounts report healthy 5h/weekly utilization, so headroom alone never // catches them). const stickyTarget = orderedTargets[stickyIdx]; - const [sat, connHealth] = await Promise.all([ + const [sat, connHealth, quotaExhausted] = await Promise.all([ resolveSaturation(connectionId, stickyTarget.provider), resolveConnectionHealth(connectionId, stickyTarget.provider), + isStickyConnectionQuotaExhausted(connectionId), ]); const headroom = computeHeadroom(sat); if ( headroom <= STICKINESS_HEADROOM_THRESHOLD || - isStickyConnectionTerminallyUnhealthy(connHealth, Date.now()) + isStickyConnectionTerminallyUnhealthy(connHealth, Date.now()) || + quotaExhausted ) { // Connection saturated or durably unhealthy — rebind on next success clearStickyBinding(messageHash); diff --git a/open-sse/services/combo/validateQuality.ts b/open-sse/services/combo/validateQuality.ts index 7f1a32b38a..99805eb35c 100644 --- a/open-sse/services/combo/validateQuality.ts +++ b/open-sse/services/combo/validateQuality.ts @@ -8,7 +8,9 @@ import { createSSEDataLineNormalizer, + hasOpenAIFinishReason, isKnownNonClaudeStreamPayload, + isOpenAIChoicesPayload, } from "../../utils/streamHelpers.ts"; import { evaluateResponseValidation, type ResponseValidationConfig } from "./responseValidation.ts"; import { getReasoningTokens } from "../../../src/lib/usage/tokenAccounting.ts"; @@ -98,6 +100,29 @@ function messageDeltaEndsLifecycle(parsed: Record): boolean { return asObject(parsed, "delta")?.stop_reason != null; } +/** + * Mutable OpenAI-shape lifecycle flags (#7285) — tracked independently of + * {@link SseLifecycleFlags} because the truncation signal here (a stream that + * closes without ever carrying `finish_reason` or a `[DONE]` sentinel) is + * orthogonal to the Claude event switch and must fire even when + * `hasOpenAICompatibleStreamValue()` never sees real content (e.g. a + * role-only delta). + */ +interface OpenAiLifecycleFlags { + hasChoicePayload: boolean; + hasTerminalMarker: boolean; +} + +/** Update `flags` in place from one parsed OpenAI-shape SSE `data:` payload. */ +function applyOpenAiLifecycleEvent( + parsed: Record, + flags: OpenAiLifecycleFlags +): void { + if (!isOpenAIChoicesPayload(parsed)) return; + flags.hasChoicePayload = true; + if (hasOpenAIFinishReason(parsed)) flags.hasTerminalMarker = true; +} + /** * Apply a single parsed Claude SSE event to the peeked lifecycle `flags` * (mutated in place). Extracted from `parseAccumulatedSse`'s inline switch to @@ -228,6 +253,8 @@ export async function validateResponseQuality( }; let anyContentFound = false; let sawAnyBytes = false; + // #7285: OpenAI-shape lifecycle tracking, parallel to `sse` above. + const openAi: OpenAiLifecycleFlags = { hasChoicePayload: false, hasTerminalMarker: false }; const sseLineNormalizer = createSSEDataLineNormalizer(); let pendingEventType = ""; @@ -259,7 +286,13 @@ export async function validateResponseQuality( } const data = trimmed.slice(5).trim(); - if (!data || data === "[DONE]") continue; + if (!data) continue; + if (data === "[DONE]") { + // #7285: `[DONE]` is itself a terminal marker for OpenAI-shape + // streams, even when no earlier chunk carried `finish_reason`. + openAi.hasTerminalMarker = true; + continue; + } let parsed: Record; try { @@ -268,6 +301,8 @@ export async function validateResponseQuality( continue; } + applyOpenAiLifecycleEvent(parsed, openAi); + const eventType = (typeof parsed.type === "string" ? parsed.type : null) || pendingEventType || ""; pendingEventType = ""; @@ -362,6 +397,23 @@ export async function validateResponseQuality( return { valid: false, reason: "streaming no recognized content" }; } + // Issue #7285: an OpenAI-shape stream (`choices[]` chunks) that + // closes without ever carrying `finish_reason` or a `[DONE]` + // sentinel, and without producing recognized content, is a + // truncated response — failover to a sibling combo target rather + // than forwarding the incomplete stream as a success. Does not + // affect Claude-shape streams (`openAi.hasChoicePayload` stays + // false for those) and does not regress the #3399/#3685 + // pass-through contract: a healthy stream exits the peek loop + // early via the `foundContent` branch above and never reaches here. + if (openAi.hasChoicePayload && !openAi.hasTerminalMarker && !anyContentFound) { + log.warn?.( + "COMBO", + "Streaming OpenAI-shape response ended with no finish_reason or [DONE] — marking as invalid for combo failover" + ); + return { valid: false, reason: "streaming openai truncated without finish_reason" }; + } + // Incomplete lifecycle or non-Claude stream — replay all buffered // bytes. The reader is exhausted so the forwarding reader will // immediately signal done. diff --git a/open-sse/services/compression/cachingAware.ts b/open-sse/services/compression/cachingAware.ts index 54a72ed638..dc48339005 100644 --- a/open-sse/services/compression/cachingAware.ts +++ b/open-sse/services/compression/cachingAware.ts @@ -6,7 +6,10 @@ * @exports CachingContext, CacheAwareStrategy, detectCachingContext, getCacheAwareStrategy */ -import { providerSupportsCaching } from "../../utils/cacheControlPolicy.ts"; +import { + providerSupportsCaching, + type ConnectionCacheOverride, +} from "../../utils/cacheControlPolicy.ts"; type JsonRecord = Record; @@ -14,6 +17,7 @@ export interface CachingDetectionContext { provider?: string | null; targetFormat?: string | null; model?: string | null; + connectionCacheOverride?: ConnectionCacheOverride | null; } export interface CachingContext { @@ -94,7 +98,7 @@ export function detectCachingContext( hasCacheControl: hasCacheControl(body), provider, targetFormat, - isCachingProvider: providerSupportsCaching(provider, targetFormat), + isCachingProvider: providerSupportsCaching(provider, targetFormat, context.connectionCacheOverride), }; } diff --git a/open-sse/services/compression/engines/rtk/codeStripper.ts b/open-sse/services/compression/engines/rtk/codeStripper.ts index 655c7d6ac3..ef5c0419c3 100644 --- a/open-sse/services/compression/engines/rtk/codeStripper.ts +++ b/open-sse/services/compression/engines/rtk/codeStripper.ts @@ -1,4 +1,57 @@ -import ts from "typescript"; +import { createRequire } from "node:module"; +// Type-only import: erased at build time, so it never forces the `typescript` +// package to be present at runtime. The value handle is resolved lazily below. +import type * as TypeScriptApi from "typescript"; + +type TypeScriptModule = typeof import("typescript"); + +// `typescript` is a devDependency used only for opt-in AST-based comment +// stripping. A production-lean deploy (`npm run build && npm prune --omit=dev`, +// recommended in Discussion #6956) removes it, so importing it eagerly at module +// top level broke *every* Compression Context page (#7096). Resolve it lazily on +// first use and degrade to a no-op when it is unavailable. +let typeScriptModule: TypeScriptModule | null | undefined; +let warnedMissingTypeScript = false; +let loadTypeScriptModule: () => TypeScriptModule | null = defaultLoadTypeScriptModule; + +function defaultLoadTypeScriptModule(): TypeScriptModule | null { + try { + const requireFromHere = createRequire(import.meta.url); + return requireFromHere("typescript") as TypeScriptModule; + } catch { + return null; + } +} + +function resolveTypeScript(): TypeScriptModule | null { + if (typeScriptModule === undefined) { + typeScriptModule = loadTypeScriptModule(); + if (!typeScriptModule && !warnedMissingTypeScript) { + warnedMissingTypeScript = true; + // One-time warning: compression still works, just without AST-based + // code-comment stripping (which is opt-in and off by default anyway). + console.warn( + "[compression/rtk] optional dependency 'typescript' is not installed; " + + "skipping AST-based code-comment stripping (compression still works). " + + "Install 'typescript' to re-enable it." + ); + } + } + return typeScriptModule; +} + +/** + * @internal Test seam — override the lazy TypeScript loader (pass `null` to + * restore the default) and reset the cache so graceful degradation can be + * exercised without uninstalling the package. Not part of the public API. + */ +export function __setTypeScriptModuleLoaderForTests( + loader: (() => TypeScriptModule | null) | null +): void { + loadTypeScriptModule = loader ?? defaultLoadTypeScriptModule; + typeScriptModule = undefined; + warnedMissingTypeScript = false; +} export type CodeLanguage = | "javascript" @@ -60,6 +113,12 @@ export function detectCodeLanguage(text: string): CodeLanguage { * JSX expression-container comments are never corrupted. */ function stripJsTsComments(text: string, preserveDocstrings: boolean): string { + const ts = resolveTypeScript(); + // Graceful degradation: when `typescript` is unavailable (e.g. after + // `npm prune --omit=dev`), skip AST-based comment stripping and leave the + // code untouched rather than crashing (#7096). + if (!ts) return text; + const source = ts.createSourceFile( "snippet.tsx", text, @@ -69,7 +128,7 @@ function stripJsTsComments(text: string, preserveDocstrings: boolean): string { ); let hasJsx = false; - const detectJsx = (node: ts.Node): void => { + const detectJsx = (node: TypeScriptApi.Node): void => { if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) { hasJsx = true; return; @@ -79,8 +138,8 @@ function stripJsTsComments(text: string, preserveDocstrings: boolean): string { detectJsx(source); if (hasJsx) return text; - const ranges = new Map(); - const collect = (node: ts.Node): void => { + const ranges = new Map(); + const collect = (node: TypeScriptApi.Node): void => { for (const range of ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []) { ranges.set(range.pos, range); } diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 3d978fec4c..d3765737d0 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -99,6 +99,19 @@ export function isContextOverflow(errorText: string): boolean { return CONTEXT_OVERFLOW_REGEX.test(String(errorText || "")); } +// Matches phrasing like `Model minimax-m3-free is not supported` or +// `model "gpt-9" is not supported` — free-tier/aggregator providers name the +// specific model in the sentence instead of using a fixed fragment like +// "model not supported". Shared by modelFamilyFallback.ts's +// isModelUnavailableError() (400/403/404) and this module's 401 branch below, +// so the same phrasing locks the model out on either status. Bounded +// quantifier ({0,80}) keeps it ReDoS-safe. (#7268) +const MODEL_NAMED_UNSUPPORTED_REGEX = /\bmodel\b[^\n]{0,80}\bis not supported\b/i; + +export function containsModelUnavailableMessage(errorMessage: string): boolean { + return MODEL_NAMED_UNSUPPORTED_REGEX.test(String(errorMessage || "").toLowerCase()); +} + function responseBodyToString(responseBody: unknown): string { if (typeof responseBody === "string") return responseBody; if (responseBody !== null && typeof responseBody === "object") { @@ -158,6 +171,16 @@ export function classifyProviderError( if (oauthInvalid) { return PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN; } + // Some free-tier/aggregator providers return 401 (instead of 404) for a + // model the account isn't entitled to, with a body like "Model X is not + // supported". Without this check the error falls through to a generic + // UNAUTHORIZED classification, which never triggers lockModel() in + // chatCore.ts — auto-combo keeps re-selecting the same broken model on + // every request. Detect the phrasing here, same as the 404 branch above + // always does regardless of body content. (#7268) + if (containsModelUnavailableMessage(bodyStr)) { + return PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND; + } return accountDeactivated ? PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED : PROVIDER_ERROR_TYPES.UNAUTHORIZED; diff --git a/open-sse/services/fusion.ts b/open-sse/services/fusion.ts index d6e5f4fa1f..dd92c3bdbc 100644 --- a/open-sse/services/fusion.ts +++ b/open-sse/services/fusion.ts @@ -144,6 +144,18 @@ export function buildJudgePrompt(answers: Array<{ text: string }>): string { ].join("\n"); } +/** + * A request is "tool-bearing" when the client supplied tools AND did not + * explicitly opt out of tool use this turn (tool_choice: "none" is a valid + * way to declare available tools while opting out — that must NOT trigger + * the bypass, see issue #6771). + */ +export function isToolBearingRequest(body: Body): boolean { + const hasTools = Array.isArray(body.tools) && body.tools.length > 0; + if (!hasTools) return false; + return body.tool_choice !== "none"; +} + type Sentinel = { __timeout?: true; __error?: unknown }; // Resolve a Response (or sentinel) within ms; the loser keeps running but is ignored. @@ -230,6 +242,12 @@ export type HandleFusionChatOptions = { * complete prose to synthesize). The judge call keeps the client's original * stream flag + tools, so streaming and downstream tool use still work. * + * Tool-bearing requests (non-empty `tools` with `tool_choice` not "none") + * skip panel synthesis entirely and route straight to a single model (the + * configured judge, or panel[0]) with tools/tool_choice intact — panel + * members have no tool access and the judge's synthesis directive steers + * even a tools-capable judge away from emitting a tool call (#6771). + * * Speed: quorum-grace collection caps the straggler penalty. Quality: the judge * runs the consensus/contradiction/blind-spot analysis before writing. * @@ -284,6 +302,20 @@ export async function handleFusionChat({ `Combo "${comboName ?? ""}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}` ); + // Tool-bearing requests get no value from panel synthesis — panel members + // would answer with no tool access (degraded prose), and the judge's + // synthesis directive steers it away from emitting a tool call even though + // it technically still receives `tools`. Skip straight to a single model + // with the full, unmodified body (tools/tool_choice intact) so agentic + // clients get a real tool-call decision (#6771). + if (isToolBearingRequest(body)) { + log.info( + "FUSION", + `Combo "${comboName ?? ""}" received a tool-bearing request — bypassing panel synthesis, routing directly to ${judge} with tools intact` + ); + return handleSingleModel(body, judge); + } + // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose). const { tools: _tools, tool_choice: _tc, ...rest } = body; void _tools; diff --git a/open-sse/services/gpt5SamplingGuard.ts b/open-sse/services/gpt5SamplingGuard.ts index b5b35863fc..729c1ce450 100644 --- a/open-sse/services/gpt5SamplingGuard.ts +++ b/open-sse/services/gpt5SamplingGuard.ts @@ -19,6 +19,8 @@ * Azure Foundry reasoning matrix, openai-python#2072. */ +import { FORMATS } from "../translator/formats.ts"; + type JsonRecord = Record; const SAMPLING_PARAMS = ["temperature", "top_p"] as const; @@ -79,3 +81,76 @@ export function stripGpt5SamplingWhenReasoning ); return next as T; } + +const REASONING_FIELDS = ["reasoning_effort", "reasoning"] as const; + +/** + * True when the request carries a non-empty `tools` array holding at least one + * function-shaped tool entry (`{type:"function", ...}` or a bare `{name, ...}` + * without a `type`, the OpenAI Chat Completions convention). + */ +function hasFunctionTools(record: JsonRecord): boolean { + if (!Array.isArray(record.tools) || record.tools.length === 0) return false; + return record.tools.some((toolValue) => { + const tool = asRecord(toolValue); + if (!tool) return false; + const toolType = typeof tool.type === "string" ? tool.type : ""; + return toolType === "" || toolType === "function"; + }); +} + +/** + * Raw api.openai.com Chat Completions rejects GPT-5.x reasoning models that + * carry BOTH function `tools` and an active `reasoning_effort` with HTTP 400: + * "Function tools with reasoning_effort are not supported for in + * /v1/chat/completions. Please use /v1/responses instead." Historically the + * plain `openai` provider always stayed on `/chat/completions` for every + * GPT-5.x model, so this combination reached the upstream 400 with no way to + * recover other than dropping the reasoning fields. + * + * That is no longer true for every GPT-5.x model: the public GPT-5.6 family + * is tagged with `targetFormat: "openai-responses"` (see + * `GPT_5_6_API_CAPABILITIES` in `config/providers/shared.ts`, closes #2540 / + * 9router#2547) and is routed to `/v1/responses` instead, which natively + * accepts tools + reasoning together — /v1/responses is literally the + * endpoint the 400 message tells callers to use. Gate on the resolved + * `targetFormat` (the fact chatCore already computed for this request) + * rather than a model-name list: only strip when the request is actually + * going out over `/chat/completions`. If a future GPT-5.x family also moves + * to `/responses`, this guard keeps working with no change needed here. + * Port of 9router#2540. + */ +export function stripGpt5ReasoningWhenTools>( + body: T, + provider: string | null | undefined, + model: string | null | undefined, + targetFormat: string | null | undefined, + log?: { warn?: (tag: string, message: string) => void } | null +): T { + if (provider !== "openai") return body; + if (typeof model !== "string" || !/^gpt-5/i.test(model)) return body; + // Already routed to /v1/responses (e.g. GPT-5.6, #7242) — that endpoint + // supports tools + reasoning natively, nothing to strip. + if (targetFormat === FORMATS.OPENAI_RESPONSES) return body; + + const record = asRecord(body); + if (!record) return body; + if (!hasFunctionTools(record)) return body; + if (!hasActiveReasoning(record, model)) return body; + + const stripped: string[] = []; + for (const field of REASONING_FIELDS) { + if (Object.hasOwn(record, field)) stripped.push(field); + } + if (stripped.length === 0) return body; + + const next: JsonRecord = { ...record }; + for (const field of stripped) delete next[field]; + + log?.warn?.( + "PARAMS", + `Stripped ${stripped.join(", ")} for ${model} (function tools + reasoning_effort ` + + `are rejected on /v1/chat/completions; use /v1/responses instead)` + ); + return next as T; +} diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index 16ef338874..81f569764e 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -13,7 +13,7 @@ import { getModelContextLimit } from "../../src/lib/modelCapabilities"; import { parseModel } from "./model.ts"; -import { CONTEXT_OVERFLOW_REGEX } from "./errorClassifier.ts"; +import { CONTEXT_OVERFLOW_REGEX, containsModelUnavailableMessage } from "./errorClassifier.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; // ── Model Family Definitions ───────────────────────────────────────────────── @@ -129,7 +129,8 @@ export function isModelUnavailableError(status: number, errorMessage: string): b if (status !== 400 && status !== 403) return false; const msg = errorMessage.toLowerCase(); - return MODEL_UNAVAILABLE_FRAGMENTS.some((fragment) => msg.includes(fragment)); + if (MODEL_UNAVAILABLE_FRAGMENTS.some((fragment) => msg.includes(fragment))) return true; + return containsModelUnavailableMessage(errorMessage); } export function isContextOverflowError(status: number, errorMessage: string): boolean { diff --git a/open-sse/translator/bootstrap.ts b/open-sse/translator/bootstrap.ts index 6a89341ab5..df852d483c 100644 --- a/open-sse/translator/bootstrap.ts +++ b/open-sse/translator/bootstrap.ts @@ -18,6 +18,7 @@ import "./response/openai-to-claude.ts"; import "./response/gemini-to-openai.ts"; import "./response/gemini-to-claude.ts"; import "./response/openai-to-antigravity.ts"; +import "./response/openai-to-gemini.ts"; import "./response/openai-responses.ts"; import "./response/kiro-to-openai.ts"; import "./response/cursor-to-openai.ts"; diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 6d8c8edb81..93054871d1 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -419,10 +419,27 @@ export function prepareClaudeRequest( // for the latest assistant (if it already has non-empty thinking text); // field cleanup (signature strip, type normalization) still runs. const isLatestAssistant = i === latestAssistantIndex; - const latestHasExistingThinking = - isLatestAssistant && - content.some((b: any) => b.type === "thinking" || b.type === "redacted_thinking"); - if (latestHasExistingThinking && supportsRedactedThinking) { + const latestThinkingBlocks: ClaudeContentBlock[] = isLatestAssistant + ? content.filter( + (b: ClaudeContentBlock) => b.type === "thinking" || b.type === "redacted_thinking" + ) + : []; + const latestHasExistingThinking = latestThinkingBlocks.length > 0; + // #6953: a synthetic thinking block with an EMPTY signature/data (fabricated by a + // non-Anthropic provider leg, e.g. codex reasoning_content) is NOT a genuine Claude + // replay signature. Forwarding it verbatim to a real Anthropic-native upstream always + // 400s ("Invalid signature in thinking block"), permanently poisoning the combo onto + // the non-Anthropic leg. Only skip the verbatim-preserve path when every thinking-ish + // block on the latest assistant message carries a non-empty signature/data — older + // turns are already sanitized below (redacted_thinking + DEFAULT_THINKING_CLAUDE_SIGNATURE); + // the latest turn must go through the same sanitization when its signature is empty. + const latestHasGenuineThinkingSignature = latestThinkingBlocks.every( + (b: ClaudeContentBlock) => + b.type === "redacted_thinking" + ? typeof b.data === "string" && (b.data as string).length > 0 + : typeof b.signature === "string" && b.signature.length > 0 + ); + if (latestHasExistingThinking && supportsRedactedThinking && latestHasGenuineThinkingSignature) { // Anthropic: skip all thinking-block rewrites entirely — the // blocks must remain verbatim (type, thinking, signature, data). continue; diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 60ecfa44a7..3c2aa48bc8 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -290,6 +290,72 @@ export function coerceToolSchemas(tools: unknown): unknown { }); } +// #7023 — Responses API strict mode forces every "optional" tool property into +// `required`, so a model that intends to OMIT an optional enum property (no declared +// `default`) must still emit a concrete value (e.g. Agent.isolation:"remote"). Neither +// #6992 op (drop-if-default / drop-if-empty) can catch this, so we widen such properties +// to accept `null` on the request side (OpenAI's own documented nullable-union idiom for +// this exact strict-mode limitation) and drop the key response-side when the model emits +// `null` (see pureHelpers.ts::isDroppableNullEntry). Scope: top-level +// `properties[key].enum` only — does not recurse into `items`/`anyOf`/`oneOf` branches +// (no real-world case beyond Agent.isolation is documented; extend with a concrete repro). +function shouldInjectNullOmission(key: string, propSchema: unknown, required: Set): boolean { + return ( + isPlainObject(propSchema) && + Array.isArray(propSchema.enum) && + !required.has(key) && + !hasOwn(propSchema, "default") + ); +} + +function widenPropertyForNullOmission(propSchema: JsonRecord): JsonRecord { + const widened: JsonRecord = { ...propSchema }; + const enumValues = propSchema.enum as unknown[]; + widened.enum = enumValues.includes(null) ? enumValues : [...enumValues, null]; + if (typeof propSchema.type === "string") { + widened.type = [propSchema.type, "null"]; + } else if (Array.isArray(propSchema.type) && !propSchema.type.includes("null")) { + widened.type = [...propSchema.type, "null"]; + } + const note = "null = omit this parameter"; + widened.description = + typeof propSchema.description === "string" && propSchema.description.length > 0 + ? `${propSchema.description} (${note})` + : note; + return widened; +} + +export function injectOptionalEnumOmissionSentinel(schema: unknown): unknown { + if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema; + + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + let changed = false; + const nextProperties: JsonRecord = { ...schema.properties }; + + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (!shouldInjectNullOmission(key, propSchema, required)) continue; + nextProperties[key] = widenPropertyForNullOmission(propSchema as JsonRecord); + changed = true; + } + + if (!changed) return schema; + return { ...schema, properties: nextProperties }; +} + +export function injectOptionalEnumOmissionForTools(tools: unknown): unknown { + if (!Array.isArray(tools)) return tools; + + return tools.map((tool) => { + if (!isPlainObject(tool)) return tool; + + const result: JsonRecord = { ...tool }; + if ("parameters" in result && !isPlainObject(result.function)) { + result.parameters = injectOptionalEnumOmissionSentinel(result.parameters); + } + return result; + }); +} + export function sanitizeToolDescriptions(tools: unknown): unknown { if (!Array.isArray(tools)) return tools; return tools.map((tool) => sanitizeToolDescription(tool)); diff --git a/open-sse/translator/helpers/strictSystemHoist.ts b/open-sse/translator/helpers/strictSystemHoist.ts new file mode 100644 index 0000000000..dddbec4937 --- /dev/null +++ b/open-sse/translator/helpers/strictSystemHoist.ts @@ -0,0 +1,66 @@ +import { systemMessageMustBeFirst } from "../../../src/lib/memory/injection.ts"; + +type Message = { role: string; content: unknown; [key: string]: unknown }; + +function toTextContent(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((part): part is { type: string; text?: unknown } => { + return Boolean(part) && typeof part === "object" && (part as { type?: unknown }).type === "text"; + }) + .map((part) => String(part.text ?? "")) + .join("\n"); + } + return ""; +} + +/** + * #7293: hoist every `system`-role message onto index 0 for providers that reject a + * non-first system message (`systemMessageMustBeFirst()` — the single source of truth + * already used by `src/lib/memory/injection.ts`'s memory-injection half, #6135/PR#6225). + * + * `translateRequest()` is the single outbound choke point every request passes through, + * including same-format (OpenAI→OpenAI) passthrough where none of the format-specific + * translators run — so a client-injected `system` message landing mid-array (OpenCode / + * Kilo Code style clients, Discussion #6129) previously reached the upstream untouched. + * + * Merge, never drop: multiple offending system messages are folded (in original order) + * into the single leading system message, mirroring `injectSystemFirst()`'s + * `${memoryText}\n${first.content}` pattern and `openai-to-claude.ts`'s system-array-merge + * pattern. + * + * No-op (same array reference) whenever the provider is not strict, or the request is + * already compliant — required for prompt-cache prefix stability (#3890 class). + */ +export function hoistLeadingSystemMessage( + messages: Message[], + provider: string | null | undefined +): Message[] { + if (!Array.isArray(messages) || messages.length === 0) return messages; + if (!systemMessageMustBeFirst(provider)) return messages; + + const offendingIndices: number[] = []; + for (let i = 1; i < messages.length; i++) { + if (messages[i]?.role === "system") offendingIndices.push(i); + } + if (offendingIndices.length === 0) return messages; + + const offending = offendingIndices.map((i) => messages[i]); + const rest = messages.filter((_, i) => !offendingIndices.includes(i)); + + const mergedText = [ + rest[0]?.role === "system" ? toTextContent(rest[0].content) : null, + ...offending.map((m) => toTextContent(m.content)), + ] + .filter((text): text is string => Boolean(text)) + .join("\n"); + + if (rest[0]?.role === "system") { + const mergedFirst: Message = { ...rest[0], content: mergedText }; + return [mergedFirst, ...rest.slice(1)]; + } + + const leadingSystem: Message = { role: "system", content: mergedText }; + return [leadingSystem, ...rest]; +} diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 12406078ec..cc31aca671 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -9,10 +9,14 @@ import { prepareClaudeRequest, } from "./helpers/claudeHelper.ts"; import { filterToOpenAIFormat } from "./helpers/openaiHelper.ts"; -import { providerHonorsOpenAIFormatCacheControl } from "../utils/cacheControlPolicy.ts"; +import { + providerHonorsOpenAIFormatCacheControl, + resolveConnectionCacheOverride, +} from "../utils/cacheControlPolicy.ts"; import { coerceToolSchemas, injectEmptyReasoningContentForToolCalls, + injectOptionalEnumOmissionForTools, sanitizeToolDescriptions, } from "./helpers/schemaCoercion.ts"; import { getRequestTranslator, getResponseTranslator } from "./registry.ts"; @@ -21,6 +25,7 @@ import { hasThinkingConfig, normalizeThinkingConfig } from "../services/provider import { applyThinkingBudget } from "../services/thinkingBudget.ts"; import { getResolvedModelCapabilities, supportsReasoning } from "../services/modelCapabilities.ts"; import { normalizeRoles } from "../services/roleNormalizer.ts"; +import { hoistLeadingSystemMessage } from "./helpers/strictSystemHoist.ts"; import { lookupReasoning, recordReplay, @@ -169,6 +174,9 @@ export function translateRequest( let result = body; const use9CharId = options?.normalizeToolCallId === true; const preserveDeveloperRole = options?.preserveDeveloperRole; + const connectionCacheOverride = resolveConnectionCacheOverride( + (credentials as { providerSpecificData?: unknown } | null)?.providerSpecificData + ); // Phase 2: Apply thinking budget control before normalization result = applyThinkingBudget(result); @@ -198,6 +206,21 @@ export function translateRequest( ); } + // #7293: hoist any system message at index > 0 onto index 0 for providers that reject + // a non-first system role (systemMessageMustBeFirst() — same source of truth as the + // memory-injection half, #6135/PR#6225). Runs for every path — including same-format + // (OpenAI→OpenAI) passthrough, where none of the format-specific translators below + // execute — so a client-injected mid-array system message (OpenCode/Kilo Code style + // clients) is still normalized before reaching the upstream. No-op for non-strict + // providers and for already-compliant requests (prompt-cache prefix stability). + if ( + targetFormat === FORMATS.OPENAI && + result.messages && + Array.isArray(result.messages) + ) { + result.messages = hoistLeadingSystemMessage(result.messages, provider); + } + // If same format, skip translation steps if (sourceFormat !== targetFormat) { // Check for direct translation path first (e.g., Claude → Gemini) @@ -229,7 +252,7 @@ export function translateRequest( // stripped. const preserveCacheControl = options?.preserveCacheControl === true && - providerHonorsOpenAIFormatCacheControl(provider); + providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride); const step1Credentials = options?.copilotClient || hasTargetHint || preserveCacheControl ? { @@ -296,7 +319,8 @@ export function translateRequest( // requested upstream; generic/implicit-cache OpenAI providers stay stripped. result = filterToOpenAIFormat(result, { preserveCacheControl: - options?.preserveCacheControl === true && providerHonorsOpenAIFormatCacheControl(provider), + options?.preserveCacheControl === true && + providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride), // #4849 regression guard: keep client reasoning_content for replay providers. preserveReasoningContent: isReasoner, }); @@ -338,6 +362,9 @@ export function translateRequest( if (result.tools !== undefined) { result.tools = coerceToolSchemas(result.tools); result.tools = sanitizeToolDescriptions(result.tools); + if (targetFormat === FORMATS.OPENAI_RESPONSES) { + result.tools = injectOptionalEnumOmissionForTools(result.tools); + } } if (targetFormat === FORMATS.OPENAI && result.messages && Array.isArray(result.messages)) { diff --git a/open-sse/translator/paramSupport.ts b/open-sse/translator/paramSupport.ts index 974e1ef311..ca10d1a223 100644 --- a/open-sse/translator/paramSupport.ts +++ b/open-sse/translator/paramSupport.ts @@ -59,6 +59,17 @@ const STRIP_RULES: StripRule[] = [ // OmniRoute's actual volcengine Kimi id (not a broad /kimi/i regex) so it // never clamps an unrelated future Kimi listing whose Ark cap may differ. { provider: "volcengine", match: /^kimi-k2-5-260127$/, maxOutputCap: 32768, clampToModelMaxOutput: true }, + // #7364: Z.AI's glm-4.6v vision endpoint enforces a 32768 max_tokens ceiling + // server-side and 400s when a client sends a larger explicit max_tokens (e.g. a + // client defaulting to 65536). Scoped to both wire paths that can reach this + // model: "zai" (DefaultExecutor, Claude format by default — glm-4.6v is only + // reachable there as a custom model attached to the connection, so it is NOT in + // PROVIDER_MODELS["zai"] and clampToModelMaxOutput would find no catalog ceiling + // to clamp against, hence the fixed maxOutputCap) and "glm" (GlmExecutor, OpenAI + // format — glm-4.6v IS in the registry catalog there, `GLM_SHARED_MODELS` in + // glmProvider.ts, maxOutputTokens: 32768, so clampToModelMaxOutput suffices). + { provider: "zai", match: /^glm-4\.6v$/i, maxOutputCap: 32768 }, + { provider: "glm", match: /^glm-4\.6v$/i, clampToModelMaxOutput: true }, ]; function matches(rule: StripRule, model: string): boolean { diff --git a/open-sse/translator/request/antigravity-to-openai.ts b/open-sse/translator/request/antigravity-to-openai.ts index 032d509127..922cf0c17b 100644 --- a/open-sse/translator/request/antigravity-to-openai.ts +++ b/open-sse/translator/request/antigravity-to-openai.ts @@ -2,6 +2,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; import { fixToolPairs } from "../../services/contextManager.ts"; +import { normalizeEffort } from "@/shared/reasoning/effortStandardization"; type JsonRecord = Record; @@ -21,6 +22,16 @@ export function antigravityToOpenAIRequest(model, body, stream) { stream: stream, }; + // Explicit per-alias reasoning-effort override (Antigravity MITM layer only — + // `src/mitm/aliasConfig.ts` / `src/mitm/_internal/aliasConfig.cjs`). Set at the same + // envelope level as `model` (top-level `body`, sibling of `.request`), so it survives + // regardless of which cloudcode envelope shape the caller used. When present it takes + // priority over the thinkingConfig-derived value below: an explicit "none" suppresses + // reasoning_effort entirely even if Antigravity's own thinkingConfig requested thinking; + // any other explicit tier is emitted verbatim instead of the coarse budget-based guess. + // Ported from upstream decolua/9router#2584 ("add Antigravity reasoning effort overrides"). + const effortOverride = normalizeEffort((body as JsonRecord).reasoningEffortOverride); + // Generation config if (req.generationConfig) { const config = req.generationConfig; @@ -38,8 +49,8 @@ export function antigravityToOpenAIRequest(model, body, stream) { result.top_k = config.topK; } - // Thinking config → reasoning_effort - if (config.thinkingConfig) { + // Thinking config → reasoning_effort (skipped when an explicit override is present). + if (effortOverride === undefined && config.thinkingConfig) { const budget = config.thinkingConfig.thinkingBudget || 0; if (budget > 0) { if (budget <= 2048) { @@ -53,6 +64,12 @@ export function antigravityToOpenAIRequest(model, body, stream) { } } + if (effortOverride !== undefined && effortOverride !== "none") { + result.reasoning_effort = effortOverride; + } else if (effortOverride === "none") { + delete result.reasoning_effort; + } + // System instruction if (req.systemInstruction) { const systemText = extractText(req.systemInstruction); diff --git a/open-sse/translator/request/gemini-to-openai.ts b/open-sse/translator/request/gemini-to-openai.ts index f073eaf288..b7f1d4b16d 100644 --- a/open-sse/translator/request/gemini-to-openai.ts +++ b/open-sse/translator/request/gemini-to-openai.ts @@ -47,7 +47,7 @@ export function geminiToOpenAIRequest(model, body, stream) { // Convert contents to messages if (body.contents && Array.isArray(body.contents)) { for (const content of splitCoLocatedFunctionResponses(body.contents)) { - const converted = convertGeminiContent(content); + const converted = convertGeminiContentWithReasoning(content); if (converted) { result.messages.push(converted); } @@ -180,6 +180,50 @@ function convertGeminiContent(content) { return null; } +// Gemini marks thinking-mode output with `part.thought === true` on the model's own +// `parts` array (no separate field on the content itself). Left alone, +// convertGeminiContent() treats a thought part exactly like a visible text part — +// merging the model's internal reasoning into the message's regular `content`, which +// both leaks the private reasoning to whatever the OpenAI pivot forwards to next and +// prevents Reasoning Replay Cache (docs/routing/REASONING_REPLAY.md) from ever seeing +// it as `reasoning_content`. Split thought parts out first and re-attach the joined +// text as `reasoning_content` on the resulting message instead. +function convertGeminiContentWithReasoning(content) { + if (!content || !Array.isArray(content.parts)) { + return convertGeminiContent(content); + } + + let reasoningContent = ""; + const visibleParts = []; + for (const part of content.parts) { + if (part && part.thought === true) { + if (typeof part.text === "string") reasoningContent += part.text; + } else { + visibleParts.push(part); + } + } + + if (!reasoningContent) { + return convertGeminiContent(content); + } + + const converted = convertGeminiContent({ ...content, parts: visibleParts }); + + if (converted && converted.role !== "tool") { + return { ...converted, reasoning_content: reasoningContent }; + } + + if (!converted) { + const role = content.role === "user" ? "user" : "assistant"; + return { role, reasoning_content: reasoningContent }; + } + + // A `tool` message (functionResponse) can't carry reasoning_content — fall back to + // returning it unchanged rather than fabricating a field the tool-message schema + // doesn't expect. + return converted; +} + // Extract text from Gemini content function extractGeminiText(content) { if (typeof content === "string") return content; diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 83b1aedb20..7141d9f946 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -79,11 +79,28 @@ export function openaiResponsesToOpenAIRequest( const result: JsonRecord = { ...root }; + // #7533: `verbosity` and `prompt_cache_key` are GPT-5/OpenAI-only Chat Completions + // parameters. A strict-protocol non-OpenAI upstream (NVIDIA confirmed by the reporter; + // likely also GLM/Kimi/Deepseek direct endpoints) 400s on unrecognized top-level + // parameters, so they must only survive the downgrade when the destination really is + // an OpenAI-operated endpoint. + // + // Allowlist, NOT a denylist: over-stripping costs a cache hit, over-preserving costs a + // hard 400. `codex` is in the list because it IS an OpenAI upstream + // (chatgpt.com/backend-api/codex) and is precisely the destination #517 needed + // `prompt_cache_key` preserved for — /v1/responses runs every request through this + // downgrade (handleResponsesCore -> convertResponsesApiFormat) regardless of provider, + // so gating on "openai" alone silently re-broke Codex prompt caching. Other + // OpenAI-compatible passthroughs (e.g. Azure OpenAI) are deliberately NOT assumed in — + // add them only with evidence that the endpoint accepts these fields. + const OPENAI_PARAM_DESTINATIONS = new Set(["openai", "codex"]); + const isOpenAIDestination = OPENAI_PARAM_DESTINATIONS.has(toString(credentialRecord.provider)); + // GPT-5 verbosity: Responses `text.verbosity` → Chat Completions top-level `verbosity`. // Chat has no `text` wrapper, so carry the level across and drop the Responses-only // `text` object (a strict Chat endpoint 400s on unknown fields). const responsesVerbosity = normalizeVerbosity(toRecord(result.text).verbosity); - if (responsesVerbosity) result.verbosity = responsesVerbosity; + if (responsesVerbosity && isOpenAIDestination) result.verbosity = responsesVerbosity; delete result.text; // background: true requests a deferred Responses API run (the upstream @@ -331,11 +348,12 @@ export function openaiResponsesToOpenAIRequest( .filter((toolValue) => { const tool = toRecord(toolValue); const toolType = toString(tool.type); - // tool_search (#2766) and image_generation (#2950) are Responses API built-ins - // with no Chat Completions equivalent; drop them silently. - return ( - !TOOL_SEARCH_TOOL_TYPES.test(toolType) && !IMAGE_GENERATION_TOOL_TYPES.test(toolType) - ); + // image_generation (#2950) is a Responses API server-side hosted tool with no + // Chat Completions equivalent; drop it silently. tool_search (#2766) used to be + // dropped here too, but it is a CLIENT-executed tool (Codex sends it with + // `execution: "client"`) — see the flatMap branch below (#7532) for why it is + // now mapped onto a Chat function tool instead of discarded. + return !IMAGE_GENERATION_TOOL_TYPES.test(toolType); }) .flatMap((toolValue) => { const tool = toRecord(toolValue); @@ -365,6 +383,33 @@ export function openaiResponsesToOpenAIRequest( }, })); } + // tool_search (#2766) is a Responses API built-in Codex sends with + // `execution: "client"` — the CLIENT (Codex CLI) resolves the call locally, + // regardless of whether the wire format is Responses `{type:"tool_search"}` or + // Chat `{type:"function"}`. Dropping it silently (as before) hid the tool from + // the model entirely and broke Codex's lazy/deferred tool-loading protocol for + // any provider downgraded to Chat Completions (#7532). Map it onto a normal + // Chat function tool instead, mirroring the local_shell -> shell pattern below. + if (TOOL_SEARCH_TOOL_TYPES.test(toolType)) { + return { + type: "function", + function: { + name: toString(tool.name) || "tool_search", + description: + toString(tool.description) || "Search for additional deferred tools by query.", + parameters: tool.parameters ?? { + type: "object", + properties: { + query: { + type: "string", + description: "Natural-language or keyword query over available tools.", + }, + }, + required: ["query"], + }, + }, + }; + } // Pass web_search server tools through with their original type (versioned or plain). // These have no Chat Completions equivalent; preserve as-is so upstreams that understand // Anthropic-style web_search_YYYYMMDD naming receive the exact name they expect. @@ -483,8 +528,12 @@ export function openaiResponsesToOpenAIRequest( } // Cleanup Responses API specific fields - // Note: prompt_cache_key is intentionally preserved — it is used by Codex and other - // providers as a cache-affinity signal. Stripping it breaks prompt caching (#517). + // Note: prompt_cache_key is intentionally preserved for OpenAI destinations — it is + // used by Codex as a cache-affinity signal and stripping it unconditionally broke + // prompt caching (#517). But #517's fix never added a provider gate, so it leaked to + // every destination, OpenAI or not — a strict non-OpenAI upstream (NVIDIA) 400s on the + // unrecognized field (#7533). Strip it for any non-OpenAI destination. + if (!isOpenAIDestination) delete result.prompt_cache_key; delete result.input; delete result.instructions; delete result.include; diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts index 50e9cfe6c6..e2cc70fce4 100644 --- a/open-sse/translator/response/openai-responses/pureHelpers.ts +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -56,6 +56,14 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) { return allowlisted || (propSchema != null && !required.has(key)); } +// #7023 — the request-side counterpart (injectOptionalEnumOmissionSentinel) widens +// no-default optional enum properties to accept `null`, meaning "omitted" (OpenAI's own +// nullable-union idiom for Responses-API strict mode). Drop the key when the model +// follows that idiom for a non-required, schema-declared property. +function isDroppableNullEntry(entry, propSchema, required, key) { + return entry === null && propSchema != null && !required.has(key); +} + function stripEmptyOptionalToolArgsObject(value, toolName, schema) { const properties = schemaProperties(schema); const required = schemaRequiredSet(schema); @@ -66,7 +74,8 @@ function stripEmptyOptionalToolArgsObject(value, toolName, schema) { const propSchema = properties ? properties[key] : null; if ( matchesSchemaDefault(propSchema, entry) || - isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) + isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) || + isDroppableNullEntry(entry, propSchema, required, key) ) { delete cleaned[key]; } diff --git a/open-sse/translator/response/openai-to-gemini.ts b/open-sse/translator/response/openai-to-gemini.ts new file mode 100644 index 0000000000..5d0881a19f --- /dev/null +++ b/open-sse/translator/response/openai-to-gemini.ts @@ -0,0 +1,14 @@ +import { register } from "../registry.ts"; +import { FORMATS } from "../formats.ts"; +import { openaiToAntigravityResponse } from "./openai-to-antigravity.ts"; + +// Gemini and Antigravity clients share the same Cloud Code +// `{ response: { candidates: [...] } }` envelope (see `unwrapGeminiChunk` +// callers in open-sse/utils/stream.ts, which treat FORMATS.GEMINI and +// FORMATS.ANTIGRAVITY identically). The response registry only had an +// OpenAI -> Antigravity projection registered, so an OpenAI-native provider +// serving a client whose request was detected as Gemini format (`sourceFormat`, +// e.g. a body-shape match on `contents: [...]`) streamed raw OpenAI +// `chat.completion.chunk` objects instead of the Gemini candidates envelope. +// Reuse the existing Antigravity projection — no new conversion logic needed. +register(FORMATS.OPENAI, FORMATS.GEMINI, null, openaiToAntigravityResponse); diff --git a/open-sse/utils/audioResponse.ts b/open-sse/utils/audioResponse.ts new file mode 100644 index 0000000000..d0c6bf61d7 --- /dev/null +++ b/open-sse/utils/audioResponse.ts @@ -0,0 +1,65 @@ +/** + * Shared audio/speech HTTP response helpers. + * + * Extracted from `open-sse/handlers/audioSpeech.ts` so that both the handler + * and any provider-specific adapter modules extracted alongside it (e.g. + * `open-sse/executors/awsPollyTts.ts`) can share the same response-shaping + * logic without importing from the (frozen, file-size-ratcheted) handler + * itself — which would create a circular import. + */ +import { CORS_HEADERS } from "./cors.ts"; + +/** + * Pull a human-readable error message out of a parsed upstream JSON error body. + */ +function extractUpstreamErrorMessage(parsed) { + const detail = parsed?.detail; + const candidates = [ + parsed?.err_msg, + parsed?.error?.message, + typeof parsed?.error === "string" ? parsed.error : null, + parsed?.message, + typeof detail === "string" ? detail : detail?.message, + ]; + + const raw = candidates.find(Boolean); + return raw ? String(raw) : null; +} + +/** + * Return a CORS error response from an upstream fetch failure. + */ +export function upstreamErrorResponse(res: Response, errText: string): Response { + // Always return JSON so the client can detect 401/credential errors reliably + let errorMessage: string; + try { + const parsed = JSON.parse(errText); + errorMessage = + extractUpstreamErrorMessage(parsed) || errText || `Upstream error (${res.status})`; + } catch { + errorMessage = errText || `Upstream error (${res.status})`; + } + + return Response.json( + { error: { message: errorMessage, code: res.status } }, + { + status: res.status, + headers: { ...CORS_HEADERS }, + } + ); +} + +/** + * Return a CORS audio stream response. + */ +export function audioStreamResponse(res: Response, defaultContentType = "audio/mpeg"): Response { + const contentType = res.headers.get("content-type") || defaultContentType; + return new Response(res.body, { + status: 200, + headers: { + ...CORS_HEADERS, + "Content-Type": contentType, + "Transfer-Encoding": "chunked", + }, + }); +} diff --git a/open-sse/utils/bypassHandler.ts b/open-sse/utils/bypassHandler.ts index 618d1f4db5..d2fd3b600b 100644 --- a/open-sse/utils/bypassHandler.ts +++ b/open-sse/utils/bypassHandler.ts @@ -1,9 +1,7 @@ import { CORS_HEADERS } from "./cors.ts"; import { detectFormat } from "../services/provider.ts"; -import { translateResponse, initState } from "../translator/index.ts"; -import { FORMATS } from "../translator/formats.ts"; import { SKIP_PATTERNS } from "../config/constants.ts"; -import { formatSSE } from "./stream.ts"; +import { createNonStreamingResponse, createStreamingResponse } from "./bypassResponse.ts"; /** * Check for bypass patterns — return fake response without calling provider. @@ -90,211 +88,3 @@ export function handleBypassRequest(body, model, userAgent = "") { ? createStreamingResponse(sourceFormat, model) : createNonStreamingResponse(sourceFormat, model); } - -/** - * Create OpenAI standard format response - */ -function createOpenAIResponse(model) { - const id = `chatcmpl-${Date.now()}`; - const created = Math.floor(Date.now() / 1000); - const text = "CLI Command Execution: Clear Terminal"; - - return { - id, - object: "chat.completion", - created, - model, - choices: [ - { - index: 0, - message: { - role: "assistant", - content: text, - }, - finish_reason: "stop", - }, - ], - usage: { - prompt_tokens: 1, - completion_tokens: 1, - total_tokens: 2, - }, - }; -} - -/** - * Create non-streaming response with translation - * Use translator to convert OpenAI → sourceFormat - */ -function createNonStreamingResponse(sourceFormat, model) { - const openaiResponse = createOpenAIResponse(model); - - // If sourceFormat is OpenAI, return directly - if (sourceFormat === FORMATS.OPENAI) { - return { - success: true, - response: new Response(JSON.stringify(openaiResponse), { - headers: { - "Content-Type": "application/json", - }, - }), - }; - } - - // Use translator to convert: simulate streaming then collect all chunks - const state = initState(sourceFormat); - state.model = model; - - const openaiChunks = createOpenAIStreamingChunks(openaiResponse); - const allTranslated = []; - - for (const chunk of openaiChunks) { - const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state); - if (translated?.length > 0) { - allTranslated.push(...translated); - } - } - - // Flush remaining - const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state); - if (flushed?.length > 0) { - allTranslated.push(...flushed); - } - - // For non-streaming, merge all chunks into final response - const finalResponse = mergeChunksToResponse(allTranslated, sourceFormat); - - return { - success: true, - response: new Response(JSON.stringify(finalResponse), { - headers: { - "Content-Type": "application/json", - }, - }), - }; -} - -/** - * Create streaming response with translation - * Use translator to convert OpenAI chunks → sourceFormat - */ -function createStreamingResponse(sourceFormat, model) { - const openaiResponse = createOpenAIResponse(model); - const state = initState(sourceFormat); - state.model = model; - - // Create OpenAI streaming chunks - const openaiChunks = createOpenAIStreamingChunks(openaiResponse); - - // Translate each chunk to sourceFormat using translator - const translatedChunks = []; - - for (const chunk of openaiChunks) { - const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state); - if (translated?.length > 0) { - for (const item of translated) { - translatedChunks.push(formatSSE(item, sourceFormat)); - } - } - } - - // Flush remaining events - const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state); - if (flushed?.length > 0) { - for (const item of flushed) { - translatedChunks.push(formatSSE(item, sourceFormat)); - } - } - - // Add [DONE] - translatedChunks.push("data: [DONE]\n\n"); - - return { - success: true, - response: new Response(translatedChunks.join(""), { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }), - }; -} - -/** - * Merge translated chunks into final response object (for non-streaming) - * Takes the last complete chunk as the final response - */ -function mergeChunksToResponse(chunks, sourceFormat) { - if (!chunks || chunks.length === 0) { - return createOpenAIResponse("unknown"); - } - - // For most formats, the last chunk before done contains the complete response - // Find the most complete chunk (usually the last one with content) - let finalChunk = chunks[chunks.length - 1]; - - // For Claude format, find the message_stop or final message - if (sourceFormat === FORMATS.CLAUDE) { - const messageStop = chunks.find((c) => c.type === "message_stop"); - if (messageStop) { - // Reconstruct complete message from chunks - const contentDelta = chunks.find((c) => c.type === "content_block_delta"); - const messageDelta = chunks.find((c) => c.type === "message_delta"); - const messageStart = chunks.find((c) => c.type === "message_start"); - - if (messageStart?.message) { - finalChunk = messageStart.message; - // Merge usage if available - if (messageDelta?.usage) { - finalChunk.usage = messageDelta.usage; - } - } - } - } - - return finalChunk; -} - -/** - * Create OpenAI streaming chunks from complete response - */ -function createOpenAIStreamingChunks(completeResponse) { - const { id, created, model, choices } = completeResponse; - const content = choices[0].message.content; - - return [ - // Chunk with content - { - id, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: { - role: "assistant", - content, - }, - finish_reason: null, - }, - ], - }, - // Final chunk with finish_reason - { - id, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - usage: completeResponse.usage, - }, - ]; -} diff --git a/open-sse/utils/bypassResponse.ts b/open-sse/utils/bypassResponse.ts new file mode 100644 index 0000000000..0727d5ccf5 --- /dev/null +++ b/open-sse/utils/bypassResponse.ts @@ -0,0 +1,229 @@ +import { translateResponse, initState } from "../translator/index.ts"; +import { FORMATS } from "../translator/formats.ts"; +import { formatSSE } from "./stream.ts"; + +/** + * Shared synthetic-response builders for the various "answer without calling + * the provider" code paths (CLI bypass patterns today; any future canned/ + * synthetic response can reuse these instead of re-deriving format + * translation). Extracted out of bypassHandler.ts so the logic has exactly + * one owner. Ported from upstream decolua/9router#2404 (bypassResponse.js), + * with the Claude-format content reconstruction fixed — see + * mergeChunksToResponse() below. + */ + +const DEFAULT_BYPASS_TEXT = "CLI Command Execution: Clear Terminal"; + +/** Build a complete (non-chunked) OpenAI chat-completion response object. */ +export function createOpenAIResponse(model, text = DEFAULT_BYPASS_TEXT) { + const id = `chatcmpl-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + return { + id, + object: "chat.completion", + created, + model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: text, + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + total_tokens: 2, + }, + }; +} + +/** Split a complete OpenAI response into the two streaming chunks a client expects. */ +export function createOpenAIStreamingChunks(completeResponse) { + const { id, created, model, choices } = completeResponse; + const content = choices[0].message.content; + + return [ + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { role: "assistant", content }, + finish_reason: null, + }, + ], + }, + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: completeResponse.usage, + }, + ]; +} + +/** + * Reconstruct the Claude `content` array from the content_block_start/delta + * events emitted for a synthetic (one-shot) response. The translator always + * starts `message_start.message.content` empty and streams blocks in via + * separate events, so the blocks have to be replayed and merged by index. + */ +function buildClaudeContentBlocks(chunks) { + const blockMap = new Map(); + for (const chunk of chunks) { + if (chunk?.type === "content_block_start" && typeof chunk.index === "number") { + blockMap.set(chunk.index, { ...(chunk.content_block || {}) }); + } + if (chunk?.type === "content_block_delta" && typeof chunk.index === "number") { + const current = blockMap.get(chunk.index) || { type: "text", text: "" }; + if (chunk.delta?.type === "text_delta") { + current.type = current.type || "text"; + current.text = `${current.text || ""}${chunk.delta.text || ""}`; + } + blockMap.set(chunk.index, current); + } + } + return [...blockMap.entries()].sort((a, b) => a[0] - b[0]).map(([, block]) => block); +} + +/** Apply the trailing message_delta's usage/stop fields onto the merged message. */ +function applyClaudeMessageDelta(mergedMessage, messageStart, messageDelta) { + const startUsage = messageStart.message.usage; + const deltaUsage = messageDelta?.usage; + if (startUsage || deltaUsage) { + mergedMessage.usage = { + ...(startUsage || {}), + ...(deltaUsage || {}), + }; + } + if (messageDelta?.delta?.stop_reason !== undefined) { + mergedMessage.stop_reason = messageDelta.delta.stop_reason; + } + if (messageDelta?.delta?.stop_sequence !== undefined) { + mergedMessage.stop_sequence = messageDelta.delta.stop_sequence; + } +} + +/** + * Reconstruct the final Claude message from a synthetic bypass response's + * chunk stream — taking the raw `message_start.message` would return an + * empty `content: []`. Falls back to `fallback` (the raw last chunk) when + * the stream never completed or never carried a `message_start`. + */ +function mergeClaudeChunks(chunks, fallback) { + const messageStop = chunks.find((c) => c.type === "message_stop"); + if (!messageStop) return fallback; + + const messageStart = chunks.find((c) => c.type === "message_start"); + if (!messageStart?.message) return fallback; + + const messageDelta = chunks.find((c) => c.type === "message_delta"); + const mergedMessage = { + ...messageStart.message, + content: buildClaudeContentBlocks(chunks), + }; + applyClaudeMessageDelta(mergedMessage, messageStart, messageDelta); + return mergedMessage; +} + +/** + * Merge translated chunks into a final response object (for non-streaming + * callers). For most formats the last chunk is already complete. Claude + * format is chunk-oriented even for "one-shot" synthetic responses, so the + * final message has to be reconstructed — see mergeClaudeChunks() above. + */ +export function mergeChunksToResponse(chunks, sourceFormat) { + if (!chunks || chunks.length === 0) { + return createOpenAIResponse("unknown"); + } + + const finalChunk = chunks[chunks.length - 1]; + + if (sourceFormat === FORMATS.CLAUDE) { + return mergeClaudeChunks(chunks, finalChunk); + } + + return finalChunk; +} + +/** Build a non-streaming Response translated from OpenAI into `sourceFormat`. */ +export function createNonStreamingResponse(sourceFormat, model, text?: string) { + const openaiResponse = createOpenAIResponse(model, text); + + if (sourceFormat === FORMATS.OPENAI) { + return { + success: true, + response: new Response(JSON.stringify(openaiResponse), { + headers: { "Content-Type": "application/json" }, + }), + }; + } + + const state = initState(sourceFormat); + state.model = model; + + const openaiChunks = createOpenAIStreamingChunks(openaiResponse); + const allTranslated: unknown[] = []; + + for (const chunk of openaiChunks) { + const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state); + if (translated?.length > 0) allTranslated.push(...translated); + } + + const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state); + if (flushed?.length > 0) allTranslated.push(...flushed); + + const finalResponse = mergeChunksToResponse(allTranslated, sourceFormat); + + return { + success: true, + response: new Response(JSON.stringify(finalResponse), { + headers: { "Content-Type": "application/json" }, + }), + }; +} + +/** Build a streaming (SSE) Response translated from OpenAI into `sourceFormat`. */ +export function createStreamingResponse(sourceFormat, model, text?: string) { + const openaiResponse = createOpenAIResponse(model, text); + const state = initState(sourceFormat); + state.model = model; + + const openaiChunks = createOpenAIStreamingChunks(openaiResponse); + const translatedChunks: string[] = []; + + for (const chunk of openaiChunks) { + const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state); + if (translated?.length > 0) { + for (const item of translated) translatedChunks.push(formatSSE(item, sourceFormat)); + } + } + + const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state); + if (flushed?.length > 0) { + for (const item of flushed) translatedChunks.push(formatSSE(item, sourceFormat)); + } + + translatedChunks.push("data: [DONE]\n\n"); + + return { + success: true, + response: new Response(translatedChunks.join(""), { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + }; +} diff --git a/open-sse/utils/cacheControlPolicy.ts b/open-sse/utils/cacheControlPolicy.ts index e886bd3e63..69177154e4 100644 --- a/open-sse/utils/cacheControlPolicy.ts +++ b/open-sse/utils/cacheControlPolicy.ts @@ -123,14 +123,54 @@ const OPENAI_FORMAT_CACHE_CONTROL_PROVIDERS = new Set([ "xiaomi-mimo", ]); +/** + * Per-connection override for cache behavior, resolved from the connection's + * `provider_specific_data.cache` JSON sub-object (see `resolveConnectionCacheOverride`). + * Lets an operator opt a custom/openai-compatible connection into prompt-cache + * behavior that the hardcoded provider-name sets above can never match (#6880). + */ +export interface ConnectionCacheOverride { + supportsPromptCaching?: boolean; + cacheControlPassthrough?: "strip" | "openai-format" | "claude-format"; +} + +/** + * Extract and validate a `ConnectionCacheOverride` from a connection's + * `providerSpecificData` bag. Returns `null` when absent/malformed so every + * call site can safely pass the result straight through. + */ +export function resolveConnectionCacheOverride( + providerSpecificData: unknown +): ConnectionCacheOverride | null { + if (!providerSpecificData || typeof providerSpecificData !== "object") return null; + const cache = (providerSpecificData as Record).cache; + if (!cache || typeof cache !== "object" || Array.isArray(cache)) return null; + const record = cache as Record; + const result: ConnectionCacheOverride = {}; + if (typeof record.supportsPromptCaching === "boolean") { + result.supportsPromptCaching = record.supportsPromptCaching; + } + if ( + record.cacheControlPassthrough === "strip" || + record.cacheControlPassthrough === "openai-format" || + record.cacheControlPassthrough === "claude-format" + ) { + result.cacheControlPassthrough = record.cacheControlPassthrough; + } + return Object.keys(result).length > 0 ? result : null; +} + /** * Whether `cache_control` markers should be PASSED THROUGH the OpenAI-format * translation for this provider (vs. stripped). Used to gate the request-side * passthrough so generic / implicit-cache OpenAI providers keep getting cleaned. */ export function providerHonorsOpenAIFormatCacheControl( - provider: string | null | undefined + provider: string | null | undefined, + connectionCacheOverride?: ConnectionCacheOverride | null ): boolean { + if (connectionCacheOverride?.cacheControlPassthrough === "openai-format") return true; + if (connectionCacheOverride?.cacheControlPassthrough === "strip") return false; if (!provider) return false; return OPENAI_FORMAT_CACHE_CONTROL_PROVIDERS.has(provider.toLowerCase()); } @@ -159,8 +199,12 @@ export function isClaudeCodeClient(userAgent: string | null | undefined): boolea */ export function providerSupportsCaching( provider: string | null | undefined, - targetFormat?: string | null + targetFormat?: string | null, + connectionCacheOverride?: ConnectionCacheOverride | null ): boolean { + if (typeof connectionCacheOverride?.supportsPromptCaching === "boolean") { + return connectionCacheOverride.supportsPromptCaching; + } if (!provider) return false; if (CACHING_PROVIDERS.has(provider.toLowerCase())) return true; // All Claude-protocol providers support prompt caching @@ -195,6 +239,7 @@ export function shouldPreserveCacheControl({ targetProvider, targetFormat, settings, + connectionCacheOverride, }: { userAgent: string | null | undefined; isCombo: boolean; @@ -202,6 +247,7 @@ export function shouldPreserveCacheControl({ targetProvider: string | null | undefined; targetFormat?: string | null; settings?: CacheControlSettings; + connectionCacheOverride?: ConnectionCacheOverride | null; }): boolean { // User override takes precedence if (settings?.alwaysPreserveClientCache === "always") { @@ -218,7 +264,7 @@ export function shouldPreserveCacheControl({ } // Target provider must support caching - if (!providerSupportsCaching(targetProvider, targetFormat)) { + if (!providerSupportsCaching(targetProvider, targetFormat, connectionCacheOverride)) { return false; } diff --git a/open-sse/utils/comfyuiClient.ts b/open-sse/utils/comfyuiClient.ts index 63198c8600..7c85f851a5 100644 --- a/open-sse/utils/comfyuiClient.ts +++ b/open-sse/utils/comfyuiClient.ts @@ -124,3 +124,26 @@ export function extractComfyOutputFiles( return files; } + +/** + * Resolve the ComfyUI base URL to use for a request. + * + * Prefers a per-connection override (`credentials.providerSpecificData.baseUrl`, + * the same storage convention self-hosted chat providers use — see + * `providerPageHelpers.ts`'s `CONFIGURABLE_BASE_URL_PROVIDERS`) over the registry + * default, so operators running ComfyUI on a Docker-network hostname (e.g. + * `http://comfyui:8188`) aren't stuck on `localhost:8188` (#6928). Falls back to + * `fallback` when no connection exists or no override is set — zero-config + * localhost users see no behavior change. + */ +export function resolveComfyUiBaseUrl( + credentials: { providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined, + fallback: string +): string { + const psd = credentials?.providerSpecificData; + const override = + psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim() + ? psd.baseUrl.trim() + : null; + return override || fallback; +} diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index bb17d9c944..502f475711 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -302,14 +302,54 @@ export function normalizeCursorModelId(modelId: string): string { return alias ?? id; } +// #7289: pinned Claude/GPT model ids carry an effort/reasoning suffix +// (e.g. "claude-opus-4-8-high", "gpt-5.5-high"). cursor's server has no route +// for the suffixed id — it only accepts the base id plus an out-of-band +// ModelParameter. Ground truth captured from the real cursor-agent client: +// Claude ids surface the suffix as {id:"effort", value:}, GPT ids as +// {id:"reasoning", value:}. "-fast"/"-thinking" are separate toggles +// (already handled elsewhere / not covered by this suffix set) and must not +// be misread as an effort value. +const CURSOR_EFFORT_SUFFIXES = ["low", "medium", "high", "xhigh", "max"] as const; + +/** + * If `normalized` starts with `prefix` and ends with one of the known effort + * suffixes, split it into the base model id plus a `{id: paramId, value}` + * ModelParameter. Returns null when no known suffix matches, leaving the id + * untouched (e.g. "claude-2.5" with no suffix, or an unrecognized tail). + */ +function splitCursorEffortSuffix( + normalized: string, + prefix: string, + paramId: string +): { modelId: string; parameters: Array<{ id: string; value: string }> } | null { + if (!normalized.startsWith(prefix)) { + return null; + } + for (const suffix of CURSOR_EFFORT_SUFFIXES) { + const marker = `-${suffix}`; + if (normalized.endsWith(marker) && normalized.length > prefix.length + marker.length) { + return { + modelId: normalized.slice(0, -marker.length), + parameters: [{ id: paramId, value: suffix }], + }; + } + } + return null; +} + /** * cursor-agent rewrites model ids before putting them on the wire: - * "auto" → RequestedModel { model_id: "default" } - * "composer-2-fast" → RequestedModel { model_id: "composer-2", - * parameters: [{id: "fast", value: "true"}] } + * "auto" → RequestedModel { model_id: "default" } + * "composer-2-fast" → RequestedModel { model_id: "composer-2", + * parameters: [{id: "fast", value: "true"}] } + * "claude-opus-4-8-high" → RequestedModel { model_id: "claude-opus-4-8", + * parameters: [{id: "effort", value: "high"}] } + * "gpt-5.5-high" → RequestedModel { model_id: "gpt-5.5", + * parameters: [{id: "reasoning", value: "high"}] } * - * Other ids (e.g. "claude-4.6-sonnet-medium") are passed through verbatim - * after spelling-variant normalization (see normalizeCursorModelId). + * Other ids are passed through verbatim after spelling-variant normalization + * (see normalizeCursorModelId). */ export function resolveRequestedModel(modelId: string): { modelId: string; @@ -327,6 +367,14 @@ export function resolveRequestedModel(modelId: string): { parameters: [{ id: "fast", value: "true" }], }; } + const claudeSplit = splitCursorEffortSuffix(normalized, "claude-", "effort"); + if (claudeSplit) { + return claudeSplit; + } + const gptSplit = splitCursorEffortSuffix(normalized, "gpt-", "reasoning"); + if (gptSplit) { + return gptSplit; + } return { modelId: normalized, parameters: [] }; } diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 62ed9356be..d73fdcb5d7 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -296,6 +296,19 @@ export function resolveProxyForRequest(targetUrl) { return { source: "direct", proxyUrl: null }; } +/** + * A caller-initiated abort/timeout is not a proxy transport failure — it must + * not be misreported as one. Prefer `signal.aborted` because + * `AbortController.abort(reason)` may surface a custom Error rather than a + * standard AbortError/TimeoutError name. + * Ported from decolua/9router#2589 (`isCallerAbort`). + */ +function isCallerAbort(error: unknown, signal: AbortSignal | null | undefined): boolean { + if (signal?.aborted === true) return true; + const name = (error as { name?: unknown } | null)?.name; + return name === "AbortError" || name === "TimeoutError"; +} + function getTargetUrl(input) { if (typeof input === "string") return input; if (input && typeof input.url === "string") return input.url; @@ -614,8 +627,12 @@ async function patchedFetch( dispatcher, }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`[ProxyFetch] Proxy request failed (${source}, fail-closed): ${message}`); + // A caller abort/timeout must propagate unchanged and without a noisy + // "Proxy request failed" log — it's not a proxy transport failure. + if (!isCallerAbort(error, options?.signal)) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[ProxyFetch] Proxy request failed (${source}, fail-closed): ${message}`); + } throw error; } } diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index 87f67564f3..799901ca48 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -184,6 +184,23 @@ const EMBEDDED_DEFAULTS = { ], // Trae Cloud IDE — public oauth client id trae_id: [10, 3, 95, 6, 10, 22, 66, 3, 11, 90, 72, 31, 91, 2], + // Microsoft Designer web app — public ClientId header sent by the + // designer.microsoft.com frontend to designerapp.officeapps.live.com + // (not a secret — every browser session sends the same fixed value; + // reverse-engineered from the g4f MicrosoftDesigner provider reference). + microsoft_designer_client_id: [ + 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 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. + // rany2/edge-tts constants.py) — not a per-user secret, just an + // abuse-mitigation constant Microsoft ships in public client binaries. + edgetts_token: [ + 89, 44, 91, 40, 51, 94, 49, 64, 32, 108, 54, 51, 86, 41, 80, 37, 111, 69, 6, 42, 95, 93, 45, 68, + 87, 65, 77, 84, 105, 70, 51, 86, + ], } as const; export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS; diff --git a/open-sse/utils/requestLogger.ts b/open-sse/utils/requestLogger.ts index 560c65a1a6..350d50e4f2 100644 --- a/open-sse/utils/requestLogger.ts +++ b/open-sse/utils/requestLogger.ts @@ -122,6 +122,13 @@ export function cloneBoundedForLog(value: unknown, depth = 0, key: string | null if (value === null || value === undefined) return value; if (typeof value === "string") return truncateLogString(value); if (typeof value !== "object") return value; + // Binary/opaque byte views (Uint8Array, Buffer, DataView, ...) are not + // "real" arrays to Array.isArray(); without this guard they fall through + // to the generic-object branch below and get expanded into one JS key per + // decoded byte instead of being treated as an opaque buffer (see #7297). + if (ArrayBuffer.isView(value)) { + return `[binary ${(value as ArrayBufferView).byteLength} bytes]`; + } if (depth >= 6) return "[MaxDepth]"; if (Array.isArray(value)) { diff --git a/open-sse/utils/segmindClient.ts b/open-sse/utils/segmindClient.ts new file mode 100644 index 0000000000..2c711d6f98 --- /dev/null +++ b/open-sse/utils/segmindClient.ts @@ -0,0 +1,111 @@ +// Shared Segmind (#6656) REST wire client — used by both the image +// (imageGeneration/providers/segmind.ts) and video +// (videoGeneration/providers/segmind.ts) handlers, since Segmind exposes +// image and video models under the exact same `POST /v1/{model}` shape: +// x-api-key auth, JSON request body, raw media bytes response (no JSON +// envelope) on success, JSON/text error body on failure. +// +// Factored out so each per-modality handler stays a thin body-builder + +// response-formatter (keeps both under the complexity/max-lines ratchets). + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "./error.ts"; + +export interface SegmindLogger { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; +} + +export interface SegmindRequestOptions { + baseUrl: string; + model: string; + token: string; + upstreamBody: Record; + callLogPath: string; + provider: string; + scope: "IMAGE" | "VIDEO"; + log?: SegmindLogger | null; +} + +export type SegmindRequestResult = + | { ok: true; buffer: Buffer; contentType: string } + | { ok: false; status: number; error: string }; + +async function logSegmindFailure( + opts: SegmindRequestOptions, + status: number, + duration: number, + errorText: string +): Promise { + if (opts.log) { + opts.log.error( + opts.scope, + `${opts.provider} error ${status}: ${errorText.slice(0, 200)}` + ); + } + saveCallLog({ + method: "POST", + path: opts.callLogPath, + status, + model: `${opts.provider}/${opts.model}`, + provider: opts.provider, + duration, + error: errorText.slice(0, 500), + }).catch(() => {}); + return { + ok: false, + status, + error: sanitizeErrorMessage(errorText) || `Segmind request failed (${status})`, + }; +} + +/** + * POST {baseUrl}/{model} with x-api-key auth and a JSON body. Returns the + * raw response bytes + content-type on success, or a sanitized error result. + */ +export async function segmindRequest(opts: SegmindRequestOptions): Promise { + const startTime = Date.now(); + try { + const response = await fetch(`${opts.baseUrl.replace(/\/$/, "")}/${opts.model}`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": opts.token }, + body: JSON.stringify(opts.upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + return logSegmindFailure(opts, response.status, Date.now() - startTime, errorText); + } + + const contentType = response.headers.get("content-type") || ""; + const buffer = Buffer.from(await response.arrayBuffer()); + + saveCallLog({ + method: "POST", + path: opts.callLogPath, + status: 200, + model: `${opts.provider}/${opts.model}`, + provider: opts.provider, + duration: Date.now() - startTime, + }).catch(() => {}); + + return { ok: true, buffer, contentType }; + } catch (err) { + const message = (err as Error)?.message ?? String(err); + if (opts.log) opts.log.error(opts.scope, `${opts.provider} fetch error: ${message}`); + saveCallLog({ + method: "POST", + path: opts.callLogPath, + status: 502, + model: `${opts.provider}/${opts.model}`, + provider: opts.provider, + duration: Date.now() - startTime, + error: message, + }).catch(() => {}); + return { + ok: false, + status: 502, + error: `${opts.scope === "IMAGE" ? "Image" : "Video"} provider error: ${sanitizeErrorMessage(message)}`, + }; + } +} diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index 1f4b5f9198..2f25a13ab8 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -317,6 +317,22 @@ function hasGeminiCandidateStreamValue(parsed: Record): boolean }); } +// Issue #7285: an OpenAI-shape SSE stream that closes without ever emitting a +// chunk carrying `finish_reason` (and without a `data: [DONE]` sentinel) is a +// truncated response — combo failover needs to detect that shape independently +// of `hasOpenAICompatibleStreamValue()` (which only looks for *content*, not +// the terminal marker). Kept alongside the other shape-detection helpers so +// callers can distinguish "OpenAI-shape chunk seen" from "OpenAI-shape stream +// reached its terminal marker". +export function isOpenAIChoicesPayload(parsed: Record): boolean { + return Array.isArray(parsed.choices); +} + +export function hasOpenAIFinishReason(parsed: Record): boolean { + if (!Array.isArray(parsed.choices)) return false; + return parsed.choices.some((choice) => isRecord(choice) && choice.finish_reason != null); +} + export function isKnownNonClaudeStreamPayload( parsed: Record, eventType = "" diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 7afe168b29..bdaed25aea 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -39,7 +39,8 @@ * prune + validate (pack-artifact-policy) - Y - UNIQUE (prepublish) * data/ dir creation - Y - UNIQUE (prepublish) * --- electron-UNIQUE --- - * better-sqlite3 + keytar native strip (ABI rebuild) - - Y UNIQUE (electron) + * better-sqlite3 native strip + Electron-ABI rebuild - - Y UNIQUE (electron) + * Turbopack hashed-module symlink materialize (node_modules) - - Y SHARED (opt-in: materializeSymlinks) * symlink guard (assertBundleIsPackagable) - - Y UNIQUE (electron) * removeGeneratedElectronArtifacts - - Y UNIQUE (electron) */ @@ -469,6 +470,140 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) { } } +/** + * Materialize Turbopack "hashed external module" symlinks inside a bundled + * node_modules dir into real, self-contained directories. + * + * Next.js/Turbopack standalone output emits entries like + * better-sqlite3-90e2652d1716b047 -> /node_modules/better-sqlite3 + * as ABSOLUTE symlinks into the build machine's tree. cpSync preserves symlinks and + * electron-builder preserves extraResources symlinks verbatim, so the packaged app + * ships dangling links pointing at e.g. /Users/runner/work/... On the end-user machine + * those targets don't exist → the instrumentation hook throws + * ERR_MODULE_NOT_FOUND: Cannot find package 'ws-' → server boot fails. + * (issues #6724, #6594). Windows is doubly broken because it can't follow POSIX + * symlinks at all. + * + * The fix: for every symlink under the given node_modules (top level + one level of + * scoped @scope/ dirs), replace it with a REAL directory copy of its dereferenced + * target — a dereference is the only option that is correct on every OS (Windows + * included) and survives the machine that built it. If the link is already dangling + * (target absent), fall back to copying a sibling real package whose name is the + * hashed name with its trailing `-` suffix stripped; if none exists, drop the + * dangling link so it cannot poison module resolution. + * + * @param {string} nodeModulesDir - absolute path to a bundled node_modules directory + * @returns {{ materialized: number, relinked: number, removed: number }} + */ +export function materializeBundledSymlinks(nodeModulesDir) { + const summary = { materialized: 0, relinked: 0, removed: 0 }; + if (!fsSync.existsSync(nodeModulesDir)) return summary; + + const entries = []; + for (const name of fsSync.readdirSync(nodeModulesDir)) { + const entryPath = path.join(nodeModulesDir, name); + if (name.startsWith("@") && fsSync.lstatSync(entryPath).isDirectory()) { + // Scoped packages live one level deeper (@scope/pkg). + for (const scoped of fsSync.readdirSync(entryPath)) { + entries.push(path.join(entryPath, scoped)); + } + continue; + } + entries.push(entryPath); + } + + for (const entryPath of entries) { + let stat; + try { + stat = fsSync.lstatSync(entryPath); + } catch { + continue; + } + if (!stat.isSymbolicLink()) continue; + + let realTarget = null; + try { + realTarget = fsSync.realpathSync(entryPath); + } catch { + realTarget = null; + } + + if (realTarget && fsSync.existsSync(realTarget)) { + // Dereference: copy the resolved real files in place of the link. + fsSync.rmSync(entryPath, { recursive: true, force: true }); + fsSync.cpSync(realTarget, entryPath, { recursive: true, dereference: true }); + summary.materialized += 1; + continue; + } + + // Dangling link (e.g. absolute path into the build machine that no longer + // exists). Try a sibling real package named without the trailing - hash. + const baseName = path.basename(entryPath).replace(/-[0-9a-f]{8,}$/i, ""); + const sibling = path.join(path.dirname(entryPath), baseName); + if (baseName !== path.basename(entryPath) && fsSync.existsSync(sibling)) { + let siblingStat = null; + try { + siblingStat = fsSync.lstatSync(sibling); + } catch { + siblingStat = null; + } + if (siblingStat && siblingStat.isDirectory()) { + fsSync.rmSync(entryPath, { recursive: true, force: true }); + fsSync.cpSync(sibling, entryPath, { recursive: true, dereference: true }); + summary.relinked += 1; + continue; + } + } + + // Nothing to resolve to — drop the dangling link so it cannot shadow resolution. + console.warn( + `[assembleStandalone] Dropping dangling module symlink (target missing): ${entryPath}` + ); + fsSync.rmSync(entryPath, { recursive: true, force: true }); + summary.removed += 1; + } + + return summary; +} + +/** + * Sync an Electron-ABI-rebuilt native module into any hashed/plain copies of + * that module already materialized inside a nested node_modules dir. + * + * materializeBundledSymlinks() turns Turbopack hashed-module symlinks (e.g. + * `better-sqlite3-90e2652d1716b047`) into real directory copies of the + * Node-ABI build. A later step in prepare-electron-standalone.mjs rebuilds + * better-sqlite3 against the Electron ABI at the bundle root — but the + * hashed copy under the nested node_modules still holds the stale Node-ABI + * build, and the server's hashed `require("better-sqlite3-")` resolves + * to it, not the rebuilt root module. Previously that hashed copy was simply + * deleted, which caused MODULE_NOT_FOUND and a silent fallback to the sql.js + * WASM driver in the packaged app (issue #6794 follow-up). Overwriting each + * matching entry with the rebuilt root module keeps the hashed require + * resolving to a working, ABI-correct native driver instead. + * + * @param {string} rootModuleDir - absolute path to the already-rebuilt module (e.g. /node_modules/better-sqlite3) + * @param {string} nodeModulesDir - absolute path to the nested node_modules dir to scan + * @returns {{ synced: number }} + */ +export function syncRebuiltNativeModuleIntoHashedEntries(rootModuleDir, nodeModulesDir) { + const summary = { synced: 0 }; + if (!fsSync.existsSync(rootModuleDir) || !fsSync.existsSync(nodeModulesDir)) return summary; + + const baseName = path.basename(rootModuleDir); + const pattern = new RegExp(`^${baseName}(-[0-9a-f]{8,})?$`, "i"); + + for (const name of fsSync.readdirSync(nodeModulesDir)) { + if (!pattern.test(name)) continue; + const entryPath = path.join(nodeModulesDir, name); + fsSync.rmSync(entryPath, { recursive: true, force: true }); + fsSync.cpSync(rootModuleDir, entryPath, { recursive: true, dereference: true }); + summary.synced += 1; + } + + return summary; +} + /** * Assemble the Next.js standalone bundle into outDir. * @@ -485,6 +620,7 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) { * @param {boolean} [opts.sanitizePaths] - replace build-machine abs paths with "." (default false) * @param {boolean} [opts.patchTurbopackChunks] - strip hashed externals from .next/server js files (default false) * @param {boolean} [opts.copyNatives] - copy native assets + extra modules (default true) + * @param {boolean} [opts.materializeSymlinks] - dereference Turbopack hashed-module symlinks in node_modules (default false) * @returns {void} */ export function assembleStandalone({ @@ -494,6 +630,7 @@ export function assembleStandalone({ sanitizePaths = false, patchTurbopackChunks: doPatchChunks = false, copyNatives = true, + materializeSymlinks = false, }) { if (!distDir) throw new Error("[assembleStandalone] distDir is required"); if (!outDir) throw new Error("[assembleStandalone] outDir is required"); @@ -550,4 +687,23 @@ export function assembleStandalone({ if (copyNatives) { copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir); } + + // 7. Optionally dereference Turbopack hashed-module symlinks so the bundle is + // self-contained (no absolute links into the build machine). Runs AFTER the + // native/extra-module copy so the sibling-package relink fallback can find + // real packages. See materializeBundledSymlinks + issues #6724, #6594. + if (materializeSymlinks) { + for (const nmDir of [ + path.join(resolvedOutDir, "node_modules"), + path.join(resolvedOutDir, relDistDir, "node_modules"), + ]) { + const s = materializeBundledSymlinks(nmDir); + if (s.materialized || s.relinked || s.removed) { + console.log( + `[assembleStandalone] Materialized module symlinks in ${path.relative(resolvedOutDir, nmDir) || "."}: ` + + `${s.materialized} dereferenced, ${s.relinked} relinked, ${s.removed} dropped` + ); + } + } + } } diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index 463190d430..f3e6c406ff 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from "node:fs/promises"; +import { mkdirSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; @@ -79,13 +80,26 @@ export async function movePath(sourcePath, destinationPath, fsImpl = fs) { } } +/** + * Best-effort: physically create the isolated Windows profile dirs that + * resolveNextBuildEnv() may have pointed APPDATA/LOCALAPPDATA at. No-op when + * resolveNextBuildEnv didn't set them (non-Windows, or NEXT_DIST_DIR already set). + */ +export function ensureWindowsBuildProfileDirs(env, mkdirImpl = mkdirSync) { + if (!env?.APPDATA || !env?.LOCALAPPDATA) return; + mkdirImpl(env.APPDATA, { recursive: true }); + mkdirImpl(env.LOCALAPPDATA, { recursive: true }); +} + function runNextBuild() { return new Promise((resolve) => { const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next"); + const buildEnv = resolveNextBuildEnv(process.env); + ensureWindowsBuildProfileDirs(buildEnv); const child = spawn(process.execPath, [nextBin, "build", resolveNextBuildBundlerFlag()], { cwd: projectRoot, stdio: "inherit", - env: resolveNextBuildEnv(process.env), + env: buildEnv, }); const forward = (signal) => { @@ -116,12 +130,45 @@ export function resolveNextBuildBundlerFlag(baseEnv = process.env) { return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack"; } -export function resolveNextBuildEnv(baseEnv = process.env) { +/** + * Deterministic per-process isolated Windows user-profile directory, used to + * sandbox HOME/USERPROFILE/APPDATA/LOCALAPPDATA for the spawned `next build`. + * Kept as a separate helper (rather than inline in resolveNextBuildEnv) so the + * directory-creation side effect (ensureWindowsBuildProfileDirs) can be invoked + * once per real build without re-deriving the path. + */ +export function getWindowsBuildProfileDir() { + return path.join(os.tmpdir(), `omniroute-build-winhome-${process.pid}`); +} + +export function resolveNextBuildEnv(baseEnv = process.env, platform = process.platform) { const env = { ...baseEnv, NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0", }; + // Windows-only: `next build`'s static-generation glob scan and framework cache + // helpers walk %USERPROFILE%/AppData, which on GitHub-hosted Windows runners (and + // some OneDrive-backed dev profiles) contains reparse points/junctions that raise + // EPERM during Next's file-system scans. `.github/workflows/electron-release.yml` + // ("Sanitize Windows home directory" step) already patches USERPROFILE for the CI + // runner, but that only covers the electron-release CI job — a local `npm run + // build` on Windows (or any other Windows CI path that calls this script + // directly) hits the same EPERM unprotected. Doing the isolation here covers + // every caller of build-next-isolated.mjs, not just one workflow step. Skipped + // when a caller has already sandboxed the build via NEXT_DIST_DIR (the existing + // signal this file already reads for "isolated build" callers — see `distDir` + // above) to avoid double-isolating nested build invocations. + // Port of decolua/9router#2402 ("fix(build): isolate Windows HOME/AppData + // during next build"). + if (platform === "win32" && !baseEnv.NEXT_DIST_DIR) { + const buildHomeDir = getWindowsBuildProfileDir(); + env.HOME = buildHomeDir; + env.USERPROFILE = buildHomeDir; + env.APPDATA = path.join(buildHomeDir, "AppData", "Roaming"); + env.LOCALAPPDATA = path.join(buildHomeDir, "AppData", "Local"); + } + // Raise the Node heap for the spawned `next build`. The webpack production pass // ("Compiling instrumentation" bundles the whole server graph) is the heaviest // phase and overflows V8's default ~2 GB ceiling on memory-constrained machines, diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index cc60babe71..90e9216dd2 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -168,6 +168,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ // tests/unit/pack-artifact-entrypoint-closures.test.ts). "bin/cli/data-dir.mjs", "bin/cli/utils/storageKeyProvision.mjs", + "bin/cli/utils/versionFastPath.mjs", "bin/mcp-server.mjs", "bin/nodeRuntimeSupport.mjs", "bin/omniroute.mjs", diff --git a/scripts/build/prepare-electron-standalone.mjs b/scripts/build/prepare-electron-standalone.mjs index f48e1556a8..da04fca00b 100644 --- a/scripts/build/prepare-electron-standalone.mjs +++ b/scripts/build/prepare-electron-standalone.mjs @@ -178,6 +178,9 @@ assembleStandalone({ projectRoot: ROOT, sanitizePaths: true, copyNatives: true, + // #6724/#6594: dereference Turbopack hashed-module symlinks — inside the packaged + // app they would point at the build machine's absolute paths and break on install. + materializeSymlinks: true, }); // Electron-UNIQUE post-assembly steps diff --git a/scripts/dev/responses-ws-proxy.mjs b/scripts/dev/responses-ws-proxy.mjs index 411cb78828..1d66c57cb1 100644 --- a/scripts/dev/responses-ws-proxy.mjs +++ b/scripts/dev/responses-ws-proxy.mjs @@ -31,6 +31,9 @@ const WS_QUERY_TOKEN_KEYS = ["api_key", "token", "access_token"]; const textDecoder = new TextDecoder(); const DEFAULT_MAX_WS_BUFFER_BYTES = 16 * 1024 * 1024; const DEFAULT_MAX_WS_MESSAGE_BYTES = 16 * 1024 * 1024; +// #7388: sentinel turn key for session-ending terminal events that don't carry +// a `response.id` (prepare failure, upstream error/close, connect failure). +const SESSION_TERMINAL_TURN_KEY = "__session_terminal__"; class WebSocketInputTooLargeError extends Error { constructor(message, reason = "message_too_large") { @@ -414,8 +417,16 @@ class ResponsesWsSession { this.upstream = null; this.upstreamReady = null; this.firstResponseBody = null; + this.currentRequestBody = null; this.preparedContext = null; - this.historyLogged = false; + // #7388: logging must be scoped per logical turn (one `response.create` + // through its terminal event), not once for the lifetime of the WS + // connection — a single boolean here silently dropped every turn after + // the first on a reused connection. Terminal events carry a + // `response.id` we can key on; session-ending failure paths (prepare + // failure, upstream error/close, connect failure) don't, so they fall + // back to a session-scoped sentinel key that still logs exactly once. + this.loggedTurnIds = new Set(); this.lastSeenAt = Date.now(); this.pingTimer = setInterval(() => { @@ -577,6 +588,7 @@ class ResponsesWsSession { throw new Error("First Responses WebSocket message must be response.create"); } this.firstResponseBody ||= responseBody; + this.currentRequestBody = responseBody; const prepared = await callInternal( this.fetchImpl, @@ -681,6 +693,12 @@ class ResponsesWsSession { upstream.send(jsonStringifySafe(firstMessage)); return; } + // #7388: a reused WS connection forwards subsequent response.create + // turns straight through (ensureUpstream() only runs once); track each + // turn's own request body so persistHistory() attaches the right + // clientRequest instead of always the first turn's. + const nextTurnBody = getResponseCreatePayload(message); + if (nextTurnBody !== null) this.currentRequestBody = nextTurnBody; this.upstream.send(jsonStringifySafe(message)); } catch (error) { const code = error?.code || "upstream_websocket_connect_failed"; @@ -705,8 +723,17 @@ class ResponsesWsSession { terminalMessage = null, responseBody = null, } = {}) { - if (this.historyLogged || !this.firstResponseBody) return; - this.historyLogged = true; + if (!this.firstResponseBody) return; + // #7388: key the "already logged" guard per logical turn instead of once + // per WS connection. Terminal events from a real response carry + // `response.id` — use it so each turn on a reused connection logs + // independently, while the same id firing twice (retries) still logs + // exactly once. Session-ending failure paths (prepare failure, upstream + // error/close, connect failure) don't carry a response id — they end the + // session, so they share one sentinel key and still log exactly once. + const turnId = toStringOrNull(terminalMessage?.response?.id) || SESSION_TERMINAL_TURN_KEY; + if (this.loggedTurnIds.has(turnId)) return; + this.loggedTurnIds.add(turnId); const finishedAt = Date.now(); try { @@ -723,7 +750,7 @@ class ResponsesWsSession { success, errorCode, errorMessage, - clientRequest: this.firstResponseBody, + clientRequest: this.currentRequestBody || this.firstResponseBody, terminalMessage, responseBody, sourceFormat: "openai-responses", diff --git a/src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx index 8a8f3d2232..eb584ba888 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx @@ -6,6 +6,22 @@ import { MITM_TOOL_HOSTS } from "@/shared/constants/mitmToolHosts"; import { useTranslations } from "next-intl"; import ProviderIcon from "@/shared/components/ProviderIcon"; +import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization"; + +// Reasoning-effort override per Antigravity model row (ported from upstream +// decolua/9router#2584). Empty value ("") means "Default" — preserve whatever +// thinking/effort Antigravity's own request already carries; an explicit tier overrides +// it end-to-end via `reasoningEffortOverride` (see `open-sse/translator/request/antigravity-to-openai.ts`). +const REASONING_EFFORT_OPTIONS = ["", ...CANONICAL_EFFORT_VALUES]; + +/** Read the `{ model?, reasoningEffort? }` entry for an alias, upgrading a legacy plain + * string mapping (still possible right after a save that only touched other aliases). */ +function getMappingEntry(mappings: Record, alias: string) { + const raw = mappings[alias]; + if (typeof raw === "string") return { model: raw }; + if (raw && typeof raw === "object") return raw as { model?: string; reasoningEffort?: string }; + return {}; +} export default function AntigravityToolCard({ tool, @@ -204,7 +220,7 @@ export default function AntigravityToolCard({ if (currentEditingAlias) { setModelMappings((prev) => ({ ...prev, - [currentEditingAlias]: model.value, + [currentEditingAlias]: { ...getMappingEntry(prev, currentEditingAlias), model: model.value }, })); } }; @@ -212,10 +228,19 @@ export default function AntigravityToolCard({ const handleModelMappingChange = (alias, value) => { setModelMappings((prev) => ({ ...prev, - [alias]: value, + [alias]: { ...getMappingEntry(prev, alias), model: value }, })); }; + const handleReasoningEffortChange = (alias, reasoningEffort) => { + setModelMappings((prev) => { + const entry = { ...getMappingEntry(prev, alias) }; + if (reasoningEffort) entry.reasoningEffort = reasoningEffort; + else delete entry.reasoningEffort; + return { ...prev, [alias]: entry }; + }); + }; + const handleSaveMappings = async () => { setLoading(true); setMessage(null); @@ -336,39 +361,55 @@ export default function AntigravityToolCard({ )}
- {(tool.defaultModels || []).map((model) => ( -
- - {model.name} - - - arrow_forward - - handleModelMappingChange(model.alias, e.target.value)} - placeholder={t("modelPlaceholder")} - className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" - /> - - {modelMappings[model.alias] && ( - - )} -
- ))} + {(entry.model || entry.reasoningEffort) && ( + + )} + + ); + })}
+ {/* Type filter chips + "group by type" sort (#6915) */} +
+ {TYPE_OPTIONS.map((opt) => ( + + ))} + +
+

{t("typeLegend")}

+ {error &&
{error}
} {loading ? ( @@ -156,9 +208,9 @@ export default function FreeProviderRankingsPage() { ) : ( <> {/* Top 3 Podium */} - {rankings.length >= 3 && ( + {displayedRankings.length >= 3 && (
- {rankings.slice(0, 3).map((provider, idx) => ( + {displayedRankings.slice(0, 3).map((provider, idx) => (
0 && ( + {displayedRankings.length > 0 && (
🚫 Never hit limits
Auto-fallback across 250 providers in milliseconds. Quota out? Next provider takes over — zero downtime.
🚫 Never hit limits
Auto-fallback across 259 providers in milliseconds. Quota out? Next provider takes over — zero downtime.
💸 Save up to 95% tokens
RTK + Caveman stacked compression cuts 15–95% of eligible tokens (~89% avg on tool-heavy sessions).
🆓 $0 to start
90+ providers with a free tier, 11 free forever (Kiro, Qoder, Pollinations, LongCat…). No card needed.
@@ -214,11 +266,13 @@ export default function FreeProviderRankingsPage() { - + - {rankings.map((provider, idx) => ( + {displayedRankings.map((provider, idx) => (
{t("colScore")} {t("colAvgScore")} {t("colModels")}{t("colType")} + {t("colType")} +
{idx + 1} @@ -271,7 +325,7 @@ export default function FreeProviderRankingsPage() { )} - {rankings.length === 0 && !error && ( + {displayedRankings.length === 0 && !error && (
{t("emptyState")}
diff --git a/src/app/(dashboard)/dashboard/onboarding/components/TierFlowDiagram.tsx b/src/app/(dashboard)/dashboard/onboarding/components/TierFlowDiagram.tsx index d2cc88ad13..ded42fdf0d 100644 --- a/src/app/(dashboard)/dashboard/onboarding/components/TierFlowDiagram.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/components/TierFlowDiagram.tsx @@ -5,7 +5,8 @@ import { useTranslations } from "next-intl"; import Image from "next/image"; export function TierFlowDiagram() { - const t = useTranslations("onboarding"); + const t = useTranslations("onboarding.tier"); + const tOnboarding = useTranslations("onboarding"); const { resolvedTheme } = useTheme(); const src = resolvedTheme === "dark" ? "/images/tier-flow-dark.svg" : "/images/tier-flow-light.svg"; @@ -14,15 +15,14 @@ export function TierFlowDiagram() {
{t("tierFlowDiagramAlt")} -

- Requests flow through your subscription quotas first, then pay-per-token cheap providers, - then free-tier providers — automatic, zero-config. +

+ {t("flowCaption")}

); diff --git a/src/app/(dashboard)/dashboard/onboarding/page.tsx b/src/app/(dashboard)/dashboard/onboarding/page.tsx index a1cbf3544e..de8b44e3cf 100644 --- a/src/app/(dashboard)/dashboard/onboarding/page.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/page.tsx @@ -274,7 +274,12 @@ export default function OnboardingWizard() { > {currentStep.icon} -

{currentStep.title}

+

{currentStep.title}

+ {currentStep.id === "tiers" && ( +

+ {t("tier.subtitle")} +

+ )} {/* Step Content */} @@ -283,7 +288,7 @@ export default function OnboardingWizard() { {currentStep.id === "welcome" && (

{t("welcomeDesc")}

-
+
{[ { icon: "swap_horiz", label: t("multiProvider") }, { icon: "monitoring", label: t("usageTracking") }, @@ -291,12 +296,14 @@ export default function OnboardingWizard() { ].map((f) => (
- - {f.icon} - - {f.label} +
+ + {f.icon} + + {f.label} +
))}
diff --git a/src/app/(dashboard)/dashboard/onboarding/steps/TierTour.tsx b/src/app/(dashboard)/dashboard/onboarding/steps/TierTour.tsx index 02f0d3d286..c09276df3d 100644 --- a/src/app/(dashboard)/dashboard/onboarding/steps/TierTour.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/steps/TierTour.tsx @@ -19,7 +19,7 @@ function TierCard({ number, colorClass, label, description, examples }: TierCard {number} {label}
-

{description}

+

{description}

    {examples.map((e) => (
  • · {e}
  • @@ -34,10 +34,6 @@ export function TierTour() { return (
    -
    -

    {t("subtitle")}

    -
    -
    @@ -68,7 +64,7 @@ export function TierTour() { {t("configure")} {" "} - after setup. + {t("afterSetup")}

    ); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 0862282ba2..87851fe1f0 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -131,6 +131,8 @@ export default function ProviderDetailPageClient() { handleRetestConnection, handleRefreshToken, handleSwapPriority, + handleReorderByAvailability, + reorderingByAvailability, handleBatchSetActive, handleBatchDeleteOpenModal, handleBatchDeleteConfirm, @@ -498,6 +500,8 @@ export default function ProviderDetailPageClient() { retestingId={retestingId} distributingProxies={distributingProxies} proxyConfig={proxyConfig} + reorderingByAvailability={reorderingByAvailability} + handleReorderByAvailability={handleReorderByAvailability} preferClaudeCodeForUnprefixedClaudeModels={preferClaudeCodeForUnprefixedClaudeModels} claudeRoutingSettingsLoaded={claudeRoutingSettingsLoaded} claudeRoutingSettingsLoadError={claudeRoutingSettingsLoadError} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/codexPlanLabel.ts b/src/app/(dashboard)/dashboard/providers/[id]/codexPlanLabel.ts new file mode 100644 index 0000000000..767bd651cd --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/codexPlanLabel.ts @@ -0,0 +1,19 @@ +/** + * Codex subscription plan label (e.g. "Plus", "Pro", "Team"), persisted on the + * connection's providerSpecificData.chatgptPlanType at OAuth import time (see + * src/lib/oauth/services/codexImport.ts). Returns "" when the connection is + * not Codex or the value is missing/blank — callers gate rendering on that. + * + * Kept in its own module (not providerPageHelpers.ts) because that file is + * frozen at its file-size ratchet cap (config/quality/file-size-baseline.json) + * and this helper is fully self-contained. + */ +export function getCodexPlanLabel(isCodex: boolean, providerSpecificData: unknown): string { + if (!isCodex) return ""; + const record = + providerSpecificData && typeof providerSpecificData === "object" + ? (providerSpecificData as Record) + : {}; + const raw = record.chatgptPlanType; + return typeof raw === "string" ? raw.trim() : ""; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx index 267cfda72e..72e9229c02 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx @@ -15,7 +15,12 @@ import { getCodexEffectiveServiceTier, type CodexGlobalServiceMode, } from "@/lib/providers/codexFastTier"; -import { normalizeCodexLimitPolicy, providerText, ERROR_TYPE_LABELS } from "../providerPageHelpers"; +import { + normalizeCodexLimitPolicy, + providerText, + ERROR_TYPE_LABELS, +} from "../providerPageHelpers"; +import { getCodexPlanLabel } from "../codexPlanLabel"; // --------------------------------------------------------------------------- // Types (exported so the client can reference them without re-importing) @@ -499,6 +504,7 @@ export default function ConnectionRow({ const claudeBlockExtraUsageEnabled = isClaude ? isClaudeExtraUsageBlockEnabled("claude", connection.providerSpecificData) : false; + const codexPlanLabel = getCodexPlanLabel(!!isCodex, connection.providerSpecificData); const cliproxyapiDeepMode = !!cliproxyapiEnabled; return ( @@ -540,6 +546,11 @@ export default function ConnectionRow({ {statusPresentation.statusLabel} + {codexPlanLabel && ( + + {codexPlanLabel} + + )} {/* T12: Token expiry status indicator (state-driven, no Date.now in render) */} {/* #5836: the red "Token Expired" badge is TERMINAL-only — for OAuth refresh-capable providers (Antigravity/Gemini) the access token lapses diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx index 2fe7c8d0e3..0ccd343137 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx @@ -16,6 +16,8 @@ type ConnectionsHeaderToolbarProps = { batchRetesting: boolean; retestingId: string | null; proxyConfig: any; + reorderingByAvailability: boolean; + handleReorderByAvailability: () => void | Promise; // from useProviderSettings preferClaudeCodeForUnprefixedClaudeModels: boolean; claudeRoutingSettingsLoaded: boolean; @@ -61,6 +63,8 @@ export default function ConnectionsHeaderToolbar({ batchRetesting, retestingId, proxyConfig, + reorderingByAvailability, + handleReorderByAvailability, preferClaudeCodeForUnprefixedClaudeModels, claudeRoutingSettingsLoaded, claudeRoutingSettingsLoadError, @@ -245,6 +249,23 @@ export default function ConnectionsHeaderToolbar({ {batchTesting ? t("testing") : t("testAll")} )} + {connections.length > 1 && ( + + )} {!isCompatible ? ( <> {isCommandCode || providerId === "clinepass" ? ( diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx index 76b861739d..dd2e842159 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx @@ -98,6 +98,11 @@ export default function CustomModelsSection({ // #4125: manual context-window override (Feature 5004 table) — free text so the // field can be left blank (no override) without fighting a number input's "0". const [editingContextWindowOverride, setEditingContextWindowOverride] = useState(""); + // #1904: manual vision-capability override — some self-hosted/local OpenAI-compatible + // backends don't self-report an image input modality, so the user needs a way to flag + // the model as vision-capable by hand (read back by getCustomVisionCapabilityFields()). + const [newSupportsVision, setNewSupportsVision] = useState(false); + const [editingSupportsVision, setEditingSupportsVision] = useState(false); const customMap = useMemo(() => buildCompatMap(customModels), [customModels]); const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]); @@ -135,6 +140,7 @@ export default function CustomModelsSection({ apiFormat: newApiFormat, supportedEndpoints: newEndpoints, ...(newTargetFormat ? { targetFormat: newTargetFormat } : {}), + ...(newSupportsVision ? { supportsVision: true } : {}), }), }); if (res.ok) { @@ -143,6 +149,7 @@ export default function CustomModelsSection({ setNewApiFormat("chat-completions"); setNewEndpoints(["chat"]); setNewTargetFormat(""); + setNewSupportsVision(false); await fetchCustomModels(); onModelsChanged?.(); } @@ -202,6 +209,7 @@ export default function CustomModelsSection({ setEditingContextWindowOverride( typeof model.contextWindowOverride === "number" ? String(model.contextWindowOverride) : "" ); + setEditingSupportsVision(model.supportsVision === true); }; const cancelEdit = () => { @@ -210,6 +218,7 @@ export default function CustomModelsSection({ setEditingEndpoints(["chat"]); setEditingTargetFormat(""); setEditingContextWindowOverride(""); + setEditingSupportsVision(false); setSavingModelId(null); }; @@ -268,6 +277,9 @@ export default function CustomModelsSection({ ...(editingTargetFormat ? { targetFormat: editingTargetFormat } : {}), // #4125: manual context-window override — number to set, null to clear. contextWindowOverride, + // #1904: manual vision-capability override — true/false to set, null to + // clear back to the id-based heuristic. + supportsVision: editingSupportsVision ? true : null, }), }); @@ -425,6 +437,23 @@ export default function CustomModelsSection({ ))}
+
+   + +
@@ -482,6 +511,14 @@ export default function CustomModelsSection({ {`🪟 ${model.contextWindowOverride.toLocaleString()}`} )} + {model.supportsVision === true && ( + + {`👁️ ${t("visionCapableLabel")}`} + + )} {model.supportedEndpoints?.includes("embeddings") && ( {`📐 ${t("supportedEndpointEmbeddings")}`} @@ -578,6 +615,23 @@ export default function CustomModelsSection({ className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background text-text-main focus:outline-none focus:border-primary" /> +
+ + +
{t("supportedEndpointsLabel")} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/connectionRowHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/connectionRowHelpers.ts index be7e5fdf9f..3899c27f56 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/connectionRowHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/connectionRowHelpers.ts @@ -13,3 +13,63 @@ export function shouldShowConnectionLastError(connection: { }): boolean { return Boolean(connection.lastError); } + +/** + * Availability-sort input shape — the two resilience-runtime fields that + * decide whether a connection is currently usable. Deliberately narrow: this + * mirrors the two fields `ConnectionRow`'s own `effectiveStatus` computation + * reads (`rateLimitedUntil` = connection cooldown, `testStatus` = last test + * result), so the "Reorder" button and the row badges never disagree about + * what "available" means. + */ +export interface AvailabilitySortableConnection { + testStatus?: string; + rateLimitedUntil?: string; +} + +/** + * Effective status for a connection, factoring in connection cooldown. + * + * A connection can be recorded as `testStatus: "unavailable"` (see the + * "Connection Cooldown" resilience layer in CLAUDE.md) yet the cooldown + * itself is lazy — once `rateLimitedUntil` is in the past, the connection is + * eligible again even though nothing has re-tested it yet. Treat that case + * as "active" so the reorder button (and the row's own badge, which this + * mirrors) reflect the lazy-recovery model instead of stale state. + */ +export function getConnectionEffectiveStatus( + connection: AvailabilitySortableConnection +): string | undefined { + const isCooldown = Boolean( + connection.rateLimitedUntil && new Date(connection.rateLimitedUntil).getTime() > Date.now() + ); + return connection.testStatus === "unavailable" && !isCooldown ? "active" : connection.testStatus; +} + +/** A connection is "available" for reorder purposes when its effective status is active/success. */ +export function isConnectionAvailable(connection: AvailabilitySortableConnection): boolean { + const status = getConnectionEffectiveStatus(connection); + return status === "active" || status === "success"; +} + +/** + * Sort connections with available ones first, unavailable ones last. + * + * Stable sort: connections within the same availability group keep their + * relative (existing priority) order, so reordering only moves groups + * relative to each other, never scrambles ties. `Array.prototype.sort` has + * been a stable sort in V8/Node since ES2019, so no manual tie-break index + * is needed here (unlike `handleSwapPriority`'s two-item swap, which reads + * ordering intent directly instead). + */ +export function sortConnectionsByAvailability( + connections: T[] +): T[] { + return [...connections].sort((a, b) => { + const availableA = isConnectionAvailable(a); + const availableB = isConnectionAvailable(b); + if (availableA && !availableB) return -1; + if (!availableA && availableB) return 1; + return 0; + }); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts index a5f8a8fcd0..ca31016194 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts @@ -27,6 +27,7 @@ import { useNotificationStore } from "@/store/notificationStore"; import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers"; import type { ConnectionRowConnection } from "../components/ConnectionRow"; import { normalizeCodexLimitPolicy } from "../providerPageHelpers"; +import { useReorderByAvailability } from "./useReorderByAvailability"; // Max connection ids accepted per bulk request — mirrors API-side cap. const MAX_BULK_IDS = 100; @@ -93,6 +94,8 @@ export interface UseProviderConnectionsReturn { handleRetestConnection: (connectionId: string) => Promise; handleRefreshToken: (connectionId: string) => Promise; handleSwapPriority: (conn1: any, conn2: any) => Promise; + handleReorderByAvailability: () => Promise; + reorderingByAvailability: boolean; // Batch handlers handleBatchSetActive: (isActive: boolean) => Promise; @@ -607,6 +610,16 @@ export function useProviderConnections( } }; + // Reorder-by-availability toolbar action — extracted to its own hook + // (see useReorderByAvailability.ts) to keep this file under the file-size cap. + const { reorderingByAvailability, handleReorderByAvailability } = useReorderByAvailability({ + connections, + setConnections, + fetchConnections, + notify, + t, + }); + // ──────────────────────────────────────────────────────────────────────── // Selection handlers // ──────────────────────────────────────────────────────────────────────── @@ -880,6 +893,7 @@ export function useProviderConnections( connProxyMap, cpaProviderEnabled, refreshingId, + reorderingByAvailability, // Setters setPage, @@ -906,6 +920,7 @@ export function useProviderConnections( handleRetestConnection, handleRefreshToken, handleSwapPriority, + handleReorderByAvailability, // Batch handlers handleBatchSetActive, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useReorderByAvailability.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useReorderByAvailability.ts new file mode 100644 index 0000000000..9527e4b99c --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useReorderByAvailability.ts @@ -0,0 +1,88 @@ +"use client"; + +/** + * useReorderByAvailability — extracted from useProviderConnections (file-size + * ratchet: useProviderConnections.ts is frozen at 954 lines; this feature + * pushed it to 974) to keep the god-file from growing. + * + * Owns the "Reorder by availability" toolbar action: sorts a provider's + * connections so available ones float to the top and unavailable ones sink + * to the bottom (stable sort — see `sortConnectionsByAvailability`), then + * persists the new order via the same per-connection priority PUT endpoint + * `handleSwapPriority` already uses in useProviderConnections. + * + * Cycle-safe: imports only from leaf modules. No import from + * ProviderDetailPageClient or useProviderConnections. + */ + +import { useState } from "react"; +import { sortConnectionsByAvailability } from "../components/connectionRowHelpers"; +import type { ConnectionRowConnection } from "../components/ConnectionRow"; + +/** Minimal surface of the notification store this hook needs. */ +interface ReorderNotifier { + error: (message: string) => void; +} + +export interface UseReorderByAvailabilityParams { + connections: ConnectionRowConnection[]; + setConnections: ( + updater: + | ConnectionRowConnection[] + | ((prev: ConnectionRowConnection[]) => ConnectionRowConnection[]) + ) => void; + fetchConnections: () => Promise; + notify: ReorderNotifier; + t: (key: string, params?: Record) => string; +} + +export interface UseReorderByAvailabilityReturn { + reorderingByAvailability: boolean; + handleReorderByAvailability: () => Promise; +} + +export function useReorderByAvailability({ + connections, + setConnections, + fetchConnections, + notify, + t, +}: UseReorderByAvailabilityParams): UseReorderByAvailabilityReturn { + const [reorderingByAvailability, setReorderingByAvailability] = useState(false); + + /** + * Reorder every connection for this provider by availability: connections + * whose effective status is active/success move to the top, the rest move + * to the bottom, each group keeping its existing relative order (stable + * sort — see `sortConnectionsByAvailability`). Persists the new order as + * sequential `priority` values via the same PUT endpoint `handleSwapPriority` + * already uses, then re-fetches from the server so the UI never runs ahead + * of persisted state on a partial failure (#2558 upstream: fzrilsh). + */ + const handleReorderByAvailability = async () => { + if (reorderingByAvailability || (connections as any[]).length < 2) return; + setReorderingByAvailability(true); + const sorted = sortConnectionsByAvailability(connections as any[]); + setConnections(sorted as ConnectionRowConnection[]); + try { + await Promise.all( + sorted.map((conn: any, idx: number) => + fetch(`/api/providers/${conn.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ priority: idx }), + }) + ) + ); + await fetchConnections(); + } catch (error) { + console.log("Error reordering connections by availability:", error); + notify.error(t("reorderByAvailabilityError")); + await fetchConnections(); + } finally { + setReorderingByAvailability(false); + } + }; + + return { reorderingByAvailability, handleReorderByAvailability }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerCredentialText.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerCredentialText.ts new file mode 100644 index 0000000000..6c2c20026d --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerCredentialText.ts @@ -0,0 +1,132 @@ +// Pure, shared helpers for provider credential copy (labels/hints/titles for +// the API-key and web-session-credential modals). Extracted out of +// providerPageHelpers.ts (Issue #3501 strangler-fig decomposition) — that leaf +// is frozen at its file-size cap, so this cohesive slice (message-translation +// utility + the 4 web-session-credential text builders) lives here instead and +// is re-exported from providerPageHelpers.ts for backward compatibility. Leaf +// module — imports only from @/shared, @/lib and colocated sibling modules. +import { type WebSessionCredentialRequirement } from "./webSessionCredentials"; + +export type ProviderMessageTranslator = (( + key: string, + values?: Record +) => string) & { + has?: (key: string) => boolean; +}; + +export function providerText( + t: ProviderMessageTranslator, + key: string, + fallback: string, + values?: Record +): string { + if (typeof t.has === "function" && t.has(key)) { + return t(key, values); + } + if (values) { + return Object.entries(values).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + fallback + ); + } + return fallback; +} + +export function getWebSessionCredentialLabel( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement, + optional: boolean +): string { + if (requirement.kind === "none") { + return providerText(t, "webNoAuthCredentialLabel", "No credential required"); + } + const baseLabel = + requirement.kind === "token" + ? providerText(t, "webTokenCredentialLabel", "Web session token") + : t("sessionCookieLabel"); + return optional ? `${baseLabel} (${t("optional").toLowerCase()})` : baseLabel; +} + +export function getWebSessionCredentialHint( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement, + providerName: string, + editing: boolean +): string | undefined { + if (requirement.kind === "none") return undefined; + + const values = { provider: providerName, credential: requirement.credentialName }; + if (editing) { + return requirement.kind === "token" + ? providerText( + t, + "webTokenEditHint", + "Leave blank to keep the current web session token. Credential: {credential}.", + values + ) + : providerText( + t, + "webCookieEditHint", + "Leave blank to keep the current session cookie. Required cookie: {credential}.", + values + ); + } + + // #5465 — a provider-specific hint (e.g. t3.chat's step-by-step DevTools copy) + // replaces the generic one-line cookie/token template when that template is + // unclear for the provider (t3.chat needs a localStorage value AND the Cookie + // header, so "Required cookie: convex-session-id + Cookie header…" reads + // circular). The override key ships translated in every locale. + if (requirement.hintKey) { + return providerText( + t, + requirement.hintKey, + requirement.hintFallback ?? + "Open the provider's web session in DevTools, copy the required credential(s), and paste them in the fields below.", + values + ); + } + + return requirement.kind === "token" + ? providerText( + t, + "webTokenCredentialHint", + "Credential: {credential}. Paste the token value from your own signed-in {provider} web session, or a DevTools HAR export if the provider supports it.", + values + ) + : providerText( + t, + "webCookieCredentialHint", + "Required cookie: {credential}. Paste the Cookie header value from your own signed-in {provider} web session. Do not include the Cookie: prefix.", + values + ); +} + +export function getWebSessionCredentialCheckLabel( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement +): string { + if (requirement.kind === "token") return providerText(t, "checkWebToken", "Check token"); + return providerText(t, "checkCookie", "Check cookie"); +} + +export function getAddCredentialModalTitle( + t: ProviderMessageTranslator, + providerName: string, + requirement: WebSessionCredentialRequirement | null +): string { + if (!requirement) return t("addProviderApiKeyTitle", { provider: providerName }); + if (requirement.kind === "none") { + return providerText(t, "addProviderConnectionTitle", "Add {provider} connection", { + provider: providerName, + }); + } + if (requirement.kind === "token") { + return providerText(t, "addProviderWebTokenTitle", "Add {provider} web token", { + provider: providerName, + }); + } + return providerText(t, "addProviderSessionCookieTitle", "Add {provider} session cookie", { + provider: providerName, + }); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index 4345d11962..641fe055a5 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -16,20 +16,32 @@ import { type CodexServiceTier, } from "@/lib/providers/requestDefaults"; import { type CodexGlobalServiceMode } from "@/lib/providers/codexFastTier"; -import { type WebSessionCredentialRequirement } from "./webSessionCredentials"; import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "./providerDetailConstants"; +import { + type ProviderMessageTranslator, + providerText, + getWebSessionCredentialLabel, + getWebSessionCredentialHint, + getWebSessionCredentialCheckLabel, + getAddCredentialModalTitle, +} from "./providerCredentialText"; + +// Re-exported for backward compatibility — these used to be defined here +// (Issue #3501 strangler-fig home), but were extracted to providerCredentialText.ts +// once this leaf hit its frozen file-size cap (#1904 own growth). +export { + type ProviderMessageTranslator, + providerText, + getWebSessionCredentialLabel, + getWebSessionCredentialHint, + getWebSessionCredentialCheckLabel, + getAddCredentialModalTitle, +}; // --------------------------------------------------------------------------- // Types shared between page + modals // --------------------------------------------------------------------------- -export type ProviderMessageTranslator = (( - key: string, - values?: Record -) => string) & { - has?: (key: string) => boolean; -}; - export type LocalProviderMetadata = { name?: string; localDefault?: string; @@ -76,6 +88,9 @@ export type CompatModelRow = { compatByProtocol?: CompatByProtocolMap; /** #2905: per-model upstream wire-format override. */ targetFormat?: string; /** #4125: manual context-window override (tokens), when set. */ contextWindowOverride?: number; + /** #1904: manual vision-capability override for custom models whose upstream + * discovery metadata doesn't self-report an image input modality. */ + supportsVision?: boolean; }; export type CompatModelMap = Map; @@ -98,28 +113,6 @@ export function targetFormatBadgeI18nKey(value: string): string | null { return TARGET_FORMAT_BADGE_I18N_KEYS[value] ?? null; } -// --------------------------------------------------------------------------- -// Utility — message translation with fallback -// --------------------------------------------------------------------------- - -export function providerText( - t: ProviderMessageTranslator, - key: string, - fallback: string, - values?: Record -): string { - if (typeof t.has === "function" && t.has(key)) { - return t(key, values); - } - if (values) { - return Object.entries(values).reduce( - (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), - fallback - ); - } - return fallback; -} - /** #5442 — badge for add-credential validation; unsupported → neutral N/A (not red Invalid). */ export function validationBadgeProps(result: string): { variant: "success" | "error" | "info"; @@ -229,6 +222,7 @@ export const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ "snowflake", "searxng-search", "petals", + "comfyui", ]); export const DEFAULT_PROVIDER_BASE_URLS: Record = { @@ -239,6 +233,7 @@ export const DEFAULT_PROVIDER_BASE_URLS: Record = { siliconflow: "https://api.siliconflow.com/v1", "searxng-search": "http://localhost:8888/search", petals: "https://chat.petals.dev/api/v1/generate", + comfyui: "http://localhost:8188", }; export function getLocalProviderMetadata(providerId?: string | null) { @@ -320,6 +315,7 @@ export function getProviderBaseUrlPlaceholder(providerId?: string | null) { return "https://my-resource.openai.azure.com"; case "bailian-coding-plan": case "xiaomi-mimo": + case "comfyui": return getProviderBaseUrlDefault(providerId); case "siliconflow": return "https://api.siliconflow.cn/v1"; @@ -385,108 +381,10 @@ export function formatExcludedModelsInput(value: unknown): string { } // --------------------------------------------------------------------------- -// Web-session credential label / hint helpers (Phase 2b) +// Web-session credential label / hint helpers (Phase 2b) — moved to +// providerCredentialText.ts (#1904 own growth); re-exported above. // --------------------------------------------------------------------------- -export function getWebSessionCredentialLabel( - t: ProviderMessageTranslator, - requirement: WebSessionCredentialRequirement, - optional: boolean -): string { - if (requirement.kind === "none") { - return providerText(t, "webNoAuthCredentialLabel", "No credential required"); - } - const baseLabel = - requirement.kind === "token" - ? providerText(t, "webTokenCredentialLabel", "Web session token") - : t("sessionCookieLabel"); - return optional ? `${baseLabel} (${t("optional").toLowerCase()})` : baseLabel; -} - -export function getWebSessionCredentialHint( - t: ProviderMessageTranslator, - requirement: WebSessionCredentialRequirement, - providerName: string, - editing: boolean -): string | undefined { - if (requirement.kind === "none") return undefined; - - const values = { provider: providerName, credential: requirement.credentialName }; - if (editing) { - return requirement.kind === "token" - ? providerText( - t, - "webTokenEditHint", - "Leave blank to keep the current web session token. Credential: {credential}.", - values - ) - : providerText( - t, - "webCookieEditHint", - "Leave blank to keep the current session cookie. Required cookie: {credential}.", - values - ); - } - - // #5465 — a provider-specific hint (e.g. t3.chat's step-by-step DevTools copy) - // replaces the generic one-line cookie/token template when that template is - // unclear for the provider (t3.chat needs a localStorage value AND the Cookie - // header, so "Required cookie: convex-session-id + Cookie header…" reads - // circular). The override key ships translated in every locale. - if (requirement.hintKey) { - return providerText( - t, - requirement.hintKey, - requirement.hintFallback ?? - "Open the provider's web session in DevTools, copy the required credential(s), and paste them in the fields below.", - values - ); - } - - return requirement.kind === "token" - ? providerText( - t, - "webTokenCredentialHint", - "Credential: {credential}. Paste the token value from your own signed-in {provider} web session, or a DevTools HAR export if the provider supports it.", - values - ) - : providerText( - t, - "webCookieCredentialHint", - "Required cookie: {credential}. Paste the Cookie header value from your own signed-in {provider} web session. Do not include the Cookie: prefix.", - values - ); -} - -export function getWebSessionCredentialCheckLabel( - t: ProviderMessageTranslator, - requirement: WebSessionCredentialRequirement -): string { - if (requirement.kind === "token") return providerText(t, "checkWebToken", "Check token"); - return providerText(t, "checkCookie", "Check cookie"); -} - -export function getAddCredentialModalTitle( - t: ProviderMessageTranslator, - providerName: string, - requirement: WebSessionCredentialRequirement | null -): string { - if (!requirement) return t("addProviderApiKeyTitle", { provider: providerName }); - if (requirement.kind === "none") { - return providerText(t, "addProviderConnectionTitle", "Add {provider} connection", { - provider: providerName, - }); - } - if (requirement.kind === "token") { - return providerText(t, "addProviderWebTokenTitle", "Add {provider} web token", { - provider: providerName, - }); - } - return providerText(t, "addProviderSessionCookieTitle", "Add {provider} session cookie", { - provider: providerName, - }); -} - // --------------------------------------------------------------------------- // Upstream-headers helpers (Phase 2b) // --------------------------------------------------------------------------- diff --git a/src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts b/src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts new file mode 100644 index 0000000000..86e89abcee --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { LiveModelsByProviderId } from "../providerPageUtils"; + +/** + * useSyncedModelsByProvider — fetch the live/synced model catalog for every + * provider connection via GET /api/synced-available-models, so the Providers + * page model-name filter can match against real upstream models (not just + * the static curated registry). See #7250: aggregator providers (openrouter, + * kilocode, theoldllm...) declare a single-entry static placeholder, so a + * search for a real model name never matched and silently hid the provider. + * + * Fails soft — a fetch error leaves the map empty, and callers fall back to + * the static registry only. + */ +export function useSyncedModelsByProvider(): LiveModelsByProviderId { + const [models, setModels] = useState({}); + + useEffect(() => { + let cancelled = false; + fetch("/api/synced-available-models") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!cancelled && data && typeof data === "object") { + setModels(data as LiveModelsByProviderId); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + return models; +} diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 2393ac6046..f6192498ef 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -17,6 +17,7 @@ import { pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; +import { useSyncedModelsByProvider } from "./hooks/useSyncedModelsByProvider"; import { buildStaticProviderEntries, buildCompatibleProviderGroups, @@ -191,6 +192,7 @@ export default function ProvidersPage() { const [repairingEnv, setRepairingEnv] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [modelSearchQuery, setModelSearchQuery] = useState(""); + const liveModelsByProviderId = useSyncedModelsByProvider(); const [showFreeOnly, setShowFreeOnly] = useState(false); const [activeCategory, setActiveCategory] = useState(null); // #4240: media-category (serviceKind) filter — composes with activeCategory, @@ -497,7 +499,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const rawNoAuthEntriesAll = buildStaticProviderEntries("no-auth", getProviderStats); @@ -514,7 +517,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats); @@ -532,7 +536,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const aggregatorProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId) @@ -543,7 +548,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const imageProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId) @@ -554,7 +560,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const enterpriseProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId) @@ -565,7 +572,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const videoProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId) @@ -576,7 +584,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const embeddingRerankProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId) @@ -587,7 +596,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats); @@ -597,7 +607,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats); @@ -607,7 +618,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats); @@ -617,7 +629,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats); @@ -627,7 +640,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats); @@ -637,7 +651,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats); @@ -647,7 +662,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const compatibleProviderEntriesAll = [ @@ -679,7 +695,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const staticProviderEntriesAll = dedupeProviderEntries([ @@ -704,7 +721,8 @@ export default function ProvidersPage() { searchQuery, undefined, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); // IDE providers: subset of oauth/apikey providers that are editors/IDEs with @@ -719,7 +737,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const oauthOnlyEntriesAll = oauthProviderEntriesAll @@ -739,7 +758,8 @@ export default function ProvidersPage() { searchQuery, showFreeOnly, modelSearchQuery, - activeServiceKind + activeServiceKind, + liveModelsByProviderId ); const compactProviderEntries = buildCompactProviderEntriesForPage({ diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 762a9bb629..3c9edf35f6 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -212,13 +212,35 @@ export function buildCompatibleProviderGroups( return { openai, anthropic, claudeCode }; } +export type LiveModelsByProviderId = Record>; + +/** + * Models to match against for the model-name filter: the static curated + * registry PLUS any live/synced catalog for that provider connection (#7250). + * Aggregator providers (openrouter, kilocode, theoldllm...) declare a + * single-entry static placeholder — matching only that entry means a search + * for any real upstream model name can never match, silently hiding the + * provider. When the live catalog is empty/unavailable we fall back to the + * static-only list so already-correct static providers are unaffected. + */ +function getFilterableModelsForEntry( + providerId: string, + liveModelsByProviderId?: LiveModelsByProviderId +): Array<{ id: string; name?: string }> { + const staticModels = getModelsByProviderId(providerId); + const liveModels = liveModelsByProviderId?.[providerId]; + if (!liveModels || liveModels.length === 0) return staticModels; + return [...staticModels, ...liveModels]; +} + export function filterConfiguredProviderEntries( entries: ProviderEntry[], showConfiguredOnly: boolean, searchQuery?: string, showFreeOnly?: boolean, modelSearchQuery?: string, - serviceKindFilter?: string | null + serviceKindFilter?: string | null, + liveModelsByProviderId?: LiveModelsByProviderId ): ProviderEntry[] { let filtered = entries; @@ -261,8 +283,8 @@ export function filterConfiguredProviderEntries( if (modelSearchQuery && modelSearchQuery.trim()) { const q = modelSearchQuery.trim(); filtered = filtered.filter((entry) => { - const models = getModelsByProviderId(entry.providerId); - return models.some((m) => matchesSearch(m.id, q) || matchesSearch(m.name, q)); + const models = getFilterableModelsForEntry(entry.providerId, liveModelsByProviderId); + return models.some((m) => matchesSearch(m.id, q) || matchesSearch(m.name || "", q)); }); } diff --git a/src/app/(dashboard)/dashboard/settings/ai/page.tsx b/src/app/(dashboard)/dashboard/settings/ai/page.tsx index 561f493749..a640fd356e 100644 --- a/src/app/(dashboard)/dashboard/settings/ai/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/ai/page.tsx @@ -6,6 +6,7 @@ import VisionBridgeSettingsTab from "../components/VisionBridgeSettingsTab"; import SystemPromptTab from "../components/SystemPromptTab"; import ResponsesStatePolicyTab from "../components/ResponsesStatePolicyTab"; import CodexFastTierTab from "../components/CodexFastTierTab"; +import CodexAutoPingTab from "../components/CodexAutoPingTab"; import ClaudeFastModeTab from "../components/ClaudeFastModeTab"; import MemorySkillsTab from "../components/MemorySkillsTab"; import ModelsDevSyncTab from "../components/ModelsDevSyncTab"; @@ -23,6 +24,7 @@ export default function SettingsAiPage() { + diff --git a/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx b/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx index ef35d57e96..f23e91f65d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Card, Toggle } from "@/shared/components"; +import { Card, ModelSelectField, Toggle } from "@/shared/components"; import { useTranslations } from "next-intl"; export default function BackgroundDegradationTab() { @@ -153,21 +153,21 @@ export default function BackgroundDegradationTab() { {/* Add new mapping */}
- setNewFrom(e.target.value)} - className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-sky-500/50 focus:outline-none" - /> +
+ +
- setNewTo(e.target.value)} - className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-sky-500/50 focus:outline-none" - /> +
+ +
diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx index d73bfe60c0..7f4b57aeac 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx @@ -910,6 +910,7 @@ export default function SystemStorageTab() { ["callLogs", t("retentionCallLogs"), 30], ["usageHistory", t("retentionUsageHistory"), 30], ["memoryEntries", t("retentionMemoryEntries"), 30], + ["xpAuditLog", t("retentionXpAuditLog"), 30], ]; return ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 484dbe1763..be1b7a451b 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -193,6 +193,12 @@ export function resolvePlanValue(plan, providerSpecificData) { psd.organizationRateLimitTier, psd.rateLimitTier, psd.organizationType, + // Codex OAuth bootstrap: chatgpt_plan_type is captured at import time + // (src/lib/oauth/services/codexImport.ts) and is the only source of the + // plan when the live Codex usage endpoint omits plan_type/planType (the + // usage service then reports the literal string "unknown" — see + // open-sse/services/usage/codex.ts). + psd.chatgptPlanType, ]; if (livePlan && normalizePlanTier(livePlan).key !== "free") { diff --git a/src/app/api/cli-tools/antigravity-mitm/alias/route.ts b/src/app/api/cli-tools/antigravity-mitm/alias/route.ts index 9233a8cd62..78ea62ee17 100644 --- a/src/app/api/cli-tools/antigravity-mitm/alias/route.ts +++ b/src/app/api/cli-tools/antigravity-mitm/alias/route.ts @@ -5,6 +5,7 @@ import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { getMitmAlias, setMitmAliasAll } from "@/models"; import { cliMitmAliasUpdateSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { hasInvalidReasoningEffort, normalizeAliasMappings } from "@/mitm/aliasConfig"; // GET - Get MITM aliases for a tool export async function GET(request) { @@ -15,7 +16,15 @@ export async function GET(request) { const { searchParams } = new URL(request.url); const toolName = searchParams.get("tool"); const aliases = await getMitmAlias(toolName || undefined); - return NextResponse.json({ aliases }); + // `getMitmAlias(tool)` returns a flat alias→mapping record we can upgrade in place; + // without a `tool` filter it returns one level deeper (`{ [tool]: { [alias]: value } }`), + // which is not this shape — only normalize the single-tool response. Upgrades legacy + // plain-string mappings (every existing install) into the structured + // `{ model?, reasoningEffort? }` shape the UI/consumers expect — no DB migration + // required (ported from upstream decolua/9router#2584). + return NextResponse.json({ + aliases: toolName ? normalizeAliasMappings(aliases) : aliases, + }); } catch (error) { console.log("Error fetching MITM aliases:", (error as any).message); return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 }); @@ -49,13 +58,14 @@ export async function PUT(request) { } const { tool, mappings } = validation.data; - const filtered: Record = {}; - for (const [alias, model] of Object.entries(mappings)) { - if (model && (model as string).trim()) { - filtered[alias] = (model as string).trim(); - } + // Reject an unrecognized reasoning-effort value at the API boundary instead of + // silently dropping it (ported from upstream decolua/9router#2584). + if (hasInvalidReasoningEffort(mappings)) { + return NextResponse.json({ error: "Invalid reasoning effort" }, { status: 400 }); } + const filtered = normalizeAliasMappings(mappings); + await setMitmAliasAll(tool, filtered); return NextResponse.json({ success: true, aliases: filtered }); } catch (error) { diff --git a/src/app/api/cli-tools/grok-build-settings/route.ts b/src/app/api/cli-tools/grok-build-settings/route.ts new file mode 100644 index 0000000000..23fe3db042 --- /dev/null +++ b/src/app/api/cli-tools/grok-build-settings/route.ts @@ -0,0 +1,300 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; +import { + ensureCliConfigWriteAllowed, + getCliPrimaryConfigPath, + getCliRuntimeStatus, +} from "@/shared/services/cliRuntime"; +import { createBackup } from "@/shared/services/backupService"; +import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; +import { cliModelConfigSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +const TOOL_ID = "grok-build"; +const MODEL_SLOT = "omniroute"; +// Grok Build ships with a built-in default model id; restored on Reset when no +// prior custom default was recorded. +const BUILTIN_DEFAULT_MODEL = "grok-build"; + +const getGrokBuildConfigPath = (): string => + getCliPrimaryConfigPath(TOOL_ID) ?? path.join(process.env.HOME ?? "~", ".grok", "config.toml"); + +const getGrokBuildDir = () => path.dirname(getGrokBuildConfigPath()); + +// [model.omniroute] ... until the next [section] header or EOF +const MODEL_SECTION_RE = new RegExp( + `^\\[model\\.${MODEL_SLOT}\\][ \\t]*\\r?\\n(?:(?!\\[)[^\\r\\n]*\\r?\\n?)*`, + "m" +); +const MODELS_SECTION_RE = /^\[models\][ \t]*\r?\n((?:(?!\[)[^\r\n]*\r?\n?)*)/m; +// Marker written on Apply so Reset can restore the previously configured default. +const PREV_DEFAULT_RE = /^# omniroute-prev-default = "([^"]*)"[ \t]*\r?\n?/m; + +const getTomlField = (body: string, key: string): string | null => { + const m = body.match(new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*"([^"]*)"`, "m")); + return m ? m[1] : null; +}; + +type GrokModelSection = { + model: string | null; + base_url: string | null; + name: string | null; + api_key: string | null; + api_backend: string | null; +}; + +/** + * Parse the `~/.grok/config.toml` produced by the Grok Build CLI (a subset of + * TOML — flat `key = "value"` pairs inside `[section]` headers). Grok Build's + * config format is not guaranteed to be quote-escaped or nested, so this reads + * only the flat string fields OmniRoute itself writes. + */ +const parseModelSection = (toml: string): GrokModelSection | null => { + const match = toml.match(MODEL_SECTION_RE); + if (!match) return null; + const body = match[0].replace(/^\[model\.[^\]]+\][ \t]*\r?\n/, ""); + return { + model: getTomlField(body, "model"), + base_url: getTomlField(body, "base_url"), + name: getTomlField(body, "name"), + api_key: getTomlField(body, "api_key"), + api_backend: getTomlField(body, "api_backend"), + }; +}; + +const parseModelsDefault = (toml: string): string | null => { + const match = toml.match(MODELS_SECTION_RE); + if (!match) return null; + return getTomlField(match[1] || "", "default"); +}; + +const escapeTomlString = (value: string): string => value.replace(/["\\]/g, "\\$&"); + +const buildModelSection = (model: string, baseUrl: string, apiKey: string): string => { + const lines = [ + `[model.${MODEL_SLOT}]`, + `model = "${escapeTomlString(model)}"`, + `base_url = "${escapeTomlString(baseUrl)}"`, + `name = "OmniRoute"`, + `description = "Routed via OmniRoute gateway"`, + `api_backend = "chat_completions"`, + ]; + if (apiKey) lines.push(`api_key = "${escapeTomlString(apiKey)}"`); + return `${lines.join("\n")}\n`; +}; + +/** Insert/replace the `[model.omniroute]` section, preserving the rest of the file. */ +const upsertModelSection = (toml: string, section: string): string => { + if (MODEL_SECTION_RE.test(toml)) return toml.replace(MODEL_SECTION_RE, section); + const needsNl = toml.length > 0 && !toml.endsWith("\n"); + return `${toml}${needsNl ? "\n" : ""}\n${section}`; +}; + +const removeModelSection = (toml: string): string => + toml.replace(MODEL_SECTION_RE, "").replace(/\n{3,}/g, "\n\n"); + +/** Set or insert `default = "..."` inside an existing `[models]`, or create the section. */ +const setModelsDefault = (toml: string, value: string): string => { + const match = toml.match(MODELS_SECTION_RE); + if (match) { + const body = match[1] || ""; + const newBody = /^[ \t]*default[ \t]*=/m.test(body) + ? body.replace(/^[ \t]*default[ \t]*=[ \t]*"[^"]*"/m, `default = "${value}"`) + : `default = "${value}"\n${body}`; + return toml.replace(match[0], `[models]\n${newBody}`); + } + const block = `[models]\ndefault = "${value}"\n\n`; + return toml.length > 0 ? block + toml : block; +}; + +/** Remember the previous default once so re-Apply never clobbers it with our own slot. */ +const rememberPrevDefault = (toml: string): string => { + if (PREV_DEFAULT_RE.test(toml)) return toml; + const current = parseModelsDefault(toml); + if (!current || current === MODEL_SLOT) return toml; + const marker = `# omniroute-prev-default = "${current}"\n`; + if (MODEL_SECTION_RE.test(toml)) { + return toml.replace(MODEL_SECTION_RE, (section) => marker + section); + } + const needsNl = toml.length > 0 && !toml.endsWith("\n"); + return `${toml}${needsNl ? "\n" : ""}${marker}`; +}; + +/** If `[models].default` still points at our slot, restore the remembered default. */ +const clearModelsDefaultIfOurs = (toml: string): string => { + const prevMatch = toml.match(PREV_DEFAULT_RE); + const restoreTo = prevMatch?.[1] || BUILTIN_DEFAULT_MODEL; + let next = toml.replace(PREV_DEFAULT_RE, ""); + const current = parseModelsDefault(next); + if (current === MODEL_SLOT) { + next = setModelsDefault(next, restoreTo); + } + return next; +}; + +const hasOmniRouteConfig = (modelCfg: GrokModelSection | null): boolean => + Boolean(modelCfg?.base_url); + +// Read current config.toml +const readConfigToml = async (): Promise => { + try { + return await fs.readFile(getGrokBuildConfigPath(), "utf-8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw err; + } +}; + +// GET — check Grok Build CLI and return current [model.omniroute] config +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + + try { + const runtime = await getCliRuntimeStatus(TOOL_ID); + + if (!runtime.installed || !runtime.runnable) { + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + config: null, + message: + runtime.installed && !runtime.runnable + ? "Grok Build is installed but not runnable" + : "Grok Build is not installed", + }); + } + + const toml = await readConfigToml(); + const model = parseModelSection(toml); + const defaultModel = parseModelsDefault(toml); + + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + config: { model, default: defaultModel }, + hasOmniRoute: hasOmniRouteConfig(model), + configPath: getGrokBuildConfigPath(), + }); + } catch (err) { + return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 }); + } +} + +// POST — write the [model.omniroute] section into ~/.grok/config.toml and set it default +export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + // Extract keyId BEFORE Zod validation — Zod strips unknown fields + const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + + const validation = validateBody(cliModelConfigSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { baseUrl, model } = validation.data; + const apiKey = await resolveApiKey(keyId, validation.data.apiKey); + + const configPath = getGrokBuildConfigPath(); + const grokDir = getGrokBuildDir(); + + await fs.mkdir(grokDir, { recursive: true }); + await createBackup(TOOL_ID, configPath); + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + let toml = await readConfigToml(); + toml = rememberPrevDefault(toml); + toml = upsertModelSection(toml, buildModelSection(model, normalizedBaseUrl, apiKey || "")); + toml = setModelsDefault(toml, MODEL_SLOT); + + await fs.writeFile(configPath, toml, "utf-8"); + + try { + saveCliToolLastConfigured(TOOL_ID); + } catch { + /* non-critical */ + } + + return NextResponse.json({ + success: true, + message: "Grok Build settings applied successfully!", + configPath, + modelSlot: MODEL_SLOT, + }); + } catch (err) { + return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 }); + } +} + +// DELETE — remove the [model.omniroute] section and restore the previous default +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const configPath = getGrokBuildConfigPath(); + + let toml: string; + try { + toml = await fs.readFile(configPath, "utf-8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + throw err; + } + + await createBackup(TOOL_ID, configPath); + + toml = removeModelSection(toml); + toml = clearModelsDefaultIfOurs(toml); + await fs.writeFile(configPath, toml, "utf-8"); + + try { + deleteCliToolLastConfigured(TOOL_ID); + } catch { + /* non-critical */ + } + + return NextResponse.json({ + success: true, + message: "OmniRoute model slot removed from Grok Build", + }); + } catch (err) { + return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 }); + } +} diff --git a/src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts b/src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts new file mode 100644 index 0000000000..e95c74255b --- /dev/null +++ b/src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts @@ -0,0 +1,31 @@ +import { isLoopbackHost } from "@/server/authz/routeGuard"; + +export type RemoteOAuthHint = + | { remoteHost: false } + | { remoteHost: true; tunnelCommand: string; message: string }; + +/** + * #7523: The PKCE callback server binds the SERVER's loopback (localhost:PORT). + * If the operator drives the OAuth flow from a different machine (OmniRoute on + * a remote host/VPS), the provider redirects the browser to the operator's OWN + * localhost:PORT, not the server's — the confirmation screen hangs forever. + * When the request's Host is non-loopback, return the reverse-tunnel hint so + * the UI can show it instead of a silent hang. + * + * The Host header is spoofable, so this drives only a UI hint, never an + * auth/security decision. + */ +export function buildRemoteOAuthHint(hostHeader: string | null, port: number): RemoteOAuthHint { + if (hostHeader == null || isLoopbackHost(hostHeader)) { + return { remoteHost: false }; + } + return { + remoteHost: true, + tunnelCommand: `ssh -L ${port}:127.0.0.1:${port} @`, + message: + `OmniRoute appears to be running on a remote host (${hostHeader}). ` + + `The OAuth callback returns to localhost:${port} on THIS machine, not the server, ` + + `so the login will hang. Open a reverse tunnel first (see tunnelCommand), then retry — ` + + `or use the token import flow instead.`, + }; +} diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 270372eb0e..0774bd8d2c 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -36,6 +36,7 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { keychainImportOnlyGuard } from "./keychainImportOnly"; +import { buildRemoteOAuthHint } from "./remoteOAuthHint"; // Use globalThis to persist callback server state across Next.js HMR reloads if (!globalThis.__codexCallbackState) { @@ -242,7 +243,7 @@ export async function GET( } if (action === "start-callback-server") { - return await handleStartCallbackServer(provider, searchParams); + return await handleStartCallbackServer(provider, searchParams, request); } if (action === "public-link-status") { @@ -268,7 +269,11 @@ export async function GET( * Codex uses fixed port 1455; Windsurf/Devin CLI use a random free port (port 0). * Returns the auth URL and stores codeVerifier for later exchange. */ -async function handleStartCallbackServer(provider: string, searchParams: URLSearchParams) { +async function handleStartCallbackServer( + provider: string, + searchParams: URLSearchParams, + request?: Request +) { if (!PKCE_CALLBACK_PROVIDERS.has(provider)) { return NextResponse.json( { error: `Callback server not supported for provider: ${provider}` }, @@ -323,11 +328,23 @@ async function handleStartCallbackServer(provider: string, searchParams: URLSear } }, 300000); + // #7523: the PKCE callback server listens on the SERVER's loopback + // (localhost:PORT). When the operator drives the OAuth flow from a + // *different* machine (OmniRoute running on a remote host/VPS), the + // provider redirects the browser to the operator's own localhost:PORT, + // not the server's — so the final confirmation screen hangs forever. + // Detect a non-loopback Host and surface the reverse-tunnel instruction + // (or steer to the paste/import flow) instead of a silent hang. + const hostHeader = + request?.headers.get("x-forwarded-host") || request?.headers.get("host") || null; + const remoteHint = buildRemoteOAuthHint(hostHeader, port); + return NextResponse.json({ authUrl: authData.authUrl, codeVerifier: authData.codeVerifier, redirectUri, serverPort: port, + ...remoteHint, }); } catch (error) { console.error("OAuth start-callback-server error:", error); diff --git a/src/app/api/oauth/codex/import/route.ts b/src/app/api/oauth/codex/import/route.ts index 5e37803696..a7302a3a6d 100644 --- a/src/app/api/oauth/codex/import/route.ts +++ b/src/app/api/oauth/codex/import/route.ts @@ -4,6 +4,63 @@ import { normalizeCodexImportRecord, flattenCodexImportPayload } from "@/lib/oau import { createProviderConnection } from "@/models"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { refreshCodexToken, isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts"; + +/** + * Message returned when the imported record's refresh_token is already dead + * (rotated/consumed/expired) — see #7522. Persisting a connection whose + * refresh_token can never succeed leaves an `active` connection that fails + * confusingly on first real use, long after the import looked successful. + */ +const EXPIRED_SESSION_MESSAGE = + "This Codex session has expired — run `codex login` again and re-import. " + + "(Esta sessão do Codex expirou — rode `codex login` novamente e reimporte.)"; + +/** + * Validate a normalized Codex import record's refresh_token against OpenAI's + * OAuth token endpoint before it is persisted as a connection. Reuses + * `refreshCodexToken()` (the same rotating-refresh-token exchange used by the + * runtime token-refresh path) instead of re-implementing the POST — the + * exchange call itself is free (no model/quota usage). + * + * Returns `null` when the token is valid (or the check was inconclusive, e.g. + * a transient network error) — the import proceeds normally in that case, + * optionally with rotated tokens already applied to `payload`. Returns an + * error string when the refresh_token is confirmed dead and the import + * should be rejected. + */ +async function validateCodexRefreshToken( + payload: { accessToken: string; refreshToken: string }, +): Promise { + let refreshResult: unknown; + try { + refreshResult = await refreshCodexToken(payload.refreshToken, undefined, null); + } catch { + // Network/transport failure: inconclusive, do not block the import. + return null; + } + + if (isUnrecoverableRefreshError(refreshResult)) { + return EXPIRED_SESSION_MESSAGE; + } + + if ( + refreshResult && + typeof refreshResult === "object" && + typeof (refreshResult as { accessToken?: unknown }).accessToken === "string" + ) { + const refreshed = refreshResult as { accessToken: string; refreshToken?: string }; + payload.accessToken = refreshed.accessToken; + if (typeof refreshed.refreshToken === "string" && refreshed.refreshToken) { + payload.refreshToken = refreshed.refreshToken; + } + } + + // `refreshResult === null` (transient error already logged inside + // refreshCodexToken) is inconclusive — fall through and import the + // originally-supplied tokens rather than blocking on a network hiccup. + return null; +} /** * POST /api/oauth/codex/import @@ -78,6 +135,14 @@ export async function POST(request: Request) { results.push({ index: i, ok: false, error: norm.error }); continue; } + + const refreshError = await validateCodexRefreshToken(norm.payload); + if (refreshError) { + failed += 1; + results.push({ index: i, ok: false, error: refreshError }); + continue; + } + try { const conn = await createProviderConnection(norm.payload as Record); imported += 1; diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index c3df55477e..d3db085568 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -343,6 +343,34 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{ } } + // Newer kiro-auth-token.json files omit `clientIdHash` and instead carry + // the OIDC `clientId` directly on the token object (#1253). In that case + // find the client-registration file whose own `clientId` matches the + // token's `clientId`, rather than leaving clientId/clientSecret unset. + // Matching by exact clientId (not region/latest-expiry) avoids picking + // an unrelated stale registration on hosts with multiple cached SSO + // client registrations. + if (!clientId && data.clientId) { + for (const candidateFile of files) { + if (candidateFile === file || !candidateFile.endsWith(".json")) continue; + try { + const candidateContent = await readFile(join(cachePath, candidateFile), "utf-8"); + const candidateData = JSON.parse(candidateContent); + if ( + candidateData.clientId === data.clientId && + typeof candidateData.clientSecret === "string" && + candidateData.clientSecret + ) { + clientId = candidateData.clientId; + clientSecret = candidateData.clientSecret; + break; + } + } catch { + // Skip unreadable/malformed candidate files. + } + } + } + // Read profileArn from Kiro IDE's profile.json. The region is preserved // verbatim by readKiroIdeProfileArn() (#2314) — see its docstring for why. const profileArn: string | null = await readKiroIdeProfileArn(); diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index 4e58bfcbd3..ffaa4c9842 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -150,8 +150,11 @@ export async function POST(request: Request) { // Validate and refresh token (through proxy if configured). // validateImportToken also calls registerClient() to obtain a per-connection OIDC // client pair so multiple Kiro accounts do not share a single backend session (#2328). + // When only `clientId` is known (no matching secret was found by auto-import), + // forward it as a hint so the AWS SSO cache lookup matches the token's own + // registration instead of guessing via region/latest-expiry (#1253). tokenData = await runWithProxyContext(proxy, () => - kiroService.validateImportToken(refreshToken.trim(), region) + kiroService.validateImportToken(refreshToken.trim(), region, clientId) ); } diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts index e606a124d0..0bb1ee765b 100644 --- a/src/app/api/provider-models/route.ts +++ b/src/app/api/provider-models/route.ts @@ -129,6 +129,8 @@ export async function POST(request) { // #1294: persist the per-model token limits set in the add-model form. max_input_tokens: maxInputTokens, max_output_tokens: maxOutputTokens, + // #1904: manual vision-capability override set in the add-model form. + supportsVision, } = validation.data; const model = await addCustomModel( @@ -142,7 +144,8 @@ export async function POST(request) { { ...(maxInputTokens != null ? { inputTokenLimit: maxInputTokens } : {}), ...(maxOutputTokens != null ? { outputTokenLimit: maxOutputTokens } : {}), - } + }, + typeof supportsVision === "boolean" ? supportsVision : undefined ); return Response.json({ model }); } catch (error) { @@ -194,6 +197,7 @@ export async function PUT(request) { upstreamHeaders, compatByProtocol, contextWindowOverride, + supportsVision, } = validation.data; const raw = rawBody as Record; @@ -206,6 +210,8 @@ export async function PUT(request) { if ("preserveOpenAIDeveloperRole" in raw) updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole; if ("upstreamHeaders" in raw) updates.upstreamHeaders = upstreamHeaders; + // #1904: manual vision-capability override — null clears back to heuristic. + if ("supportsVision" in raw) updates.supportsVision = supportsVision; if ("compatByProtocol" in raw && compatByProtocol !== undefined) { updates.compatByProtocol = compatByProtocol; } diff --git a/src/app/api/providers/[id]/models/discovery/helpers.ts b/src/app/api/providers/[id]/models/discovery/helpers.ts index 7d22589ca4..c0bb513b6f 100644 --- a/src/app/api/providers/[id]/models/discovery/helpers.ts +++ b/src/app/api/providers/[id]/models/discovery/helpers.ts @@ -1,5 +1,5 @@ import { isSelfHostedChatProvider } from "@/shared/constants/providers"; -import type { LocalCatalogModel } from "@/lib/providers/staticModels"; +import { getStaticModelsForProvider, type LocalCatalogModel } from "@/lib/providers/staticModels"; export type JsonRecord = Record; @@ -51,6 +51,36 @@ export function mergeLocalCatalogModels(["openrouter"]); + +// Fold the embeddings/rerank subset of the static catalog into a successful +// live-discovery response, additively and deduped by id, without touching +// chat/image/video/audio entries — scoped to +// LIVE_DISCOVERY_SPECIALTY_MERGE_PROVIDERS above. +export function mergeSpecialtyCatalogIntoLiveModels( + liveModels: T[], + provider: string +): Array { + if (!LIVE_DISCOVERY_SPECIALTY_MERGE_PROVIDERS.has(provider)) return liveModels; + const specialty = (getStaticModelsForProvider(provider) || []).filter( + (model) => model.apiFormat === "embeddings" || model.apiFormat === "rerank" + ); + if (specialty.length === 0) return liveModels; + return mergeLocalCatalogModels(liveModels, specialty); +} + export function buildOptionalBearerHeaders( token: string | null | undefined ): Record { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index ebca992a15..62fe747189 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -85,6 +85,7 @@ import { getAzureOpenAIApiVersion, isLocalOpenAIStyleProvider, mergeLocalCatalogModels, + mergeSpecialtyCatalogIntoLiveModels, buildOptionalBearerHeaders, buildNamedOpenAiStyleHeaders, } from "./discovery/helpers"; @@ -408,10 +409,15 @@ export async function GET( ) => { const discoveredModels = await persistDiscoveredModels(provider, connectionId, models); if (discoveredModels.length > 0) { + // #6976 — merge curated embedding/rerank specialty entries (e.g. + // OpenRouter's embeddingRegistry catalog) into the live-discovery + // response; the live /v1/models endpoint only lists chat models, and + // the specialty catalog otherwise only reached local_catalog fallback. + const mergedModels = mergeSpecialtyCatalogIntoLiveModels(models, provider); return buildResponse({ provider, connectionId, - models, + models: mergedModels, source: "api", ...(warning ? { warning } : {}), ...extraPayload, diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 0424645209..65e570a10c 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -54,7 +54,12 @@ const OAUTH_TEST_CONFIG = { "User-Agent": "codex-cli/1.0.18 (macOS; arm64)", }, // Minimal invalid body — triggers a fast 400 without consuming quota. - body: JSON.stringify({ model: "gpt-5.3-codex", input: [], stream: false, store: false }), + // #7521: probe with a ChatGPT-account-supported model. "gpt-5.3-codex" is a + // codex-only id that ChatGPT accounts reject with a 400 for the WRONG reason + // (unsupported model, not "auth ok, body invalid") — collapsing the auth signal + // so a bad token looks the same as a good one. "gpt-5.5" is served for + // ChatGPT sessions; `input: []` still yields the intended 400. + body: JSON.stringify({ model: "gpt-5.5", input: [], stream: false, store: false }), // 400 = bad request, but auth was accepted; only 401/403 means the token is bad. acceptStatuses: [400], refreshable: true, diff --git a/src/app/api/providers/bulk/route.ts b/src/app/api/providers/bulk/route.ts index 2929a9ec1d..e2fef1b258 100644 --- a/src/app/api/providers/bulk/route.ts +++ b/src/app/api/providers/bulk/route.ts @@ -4,13 +4,19 @@ import { getProviderAuditTarget, summarizeProviderConnectionForAudit, } from "@/lib/compliance/providerAudit"; -import { createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models"; +import { + createProviderConnection, + getProviderConnections, + getProviderNodeById, + isCloudEnabled, +} from "@/models"; import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider, supportsBulkApiKey, } from "@/shared/constants/providers"; import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { resolveBulkNameCollisions } from "@/shared/utils/bulkApiKeyParser"; import { syncToCloud } from "@/lib/cloudSync"; import { bulkCreateProviderSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -103,11 +109,23 @@ export async function POST(request: Request) { null : null; + // #2587 — createProviderConnection upserts apikey connections BY NAME, so a + // bulk-add name that collides with an already-saved connection (or with + // another entry in the same batch) would silently REPLACE that connection's + // apiKey/priority/testStatus instead of inserting a new one. Resolve every + // collision up front by gap-filling a free " " suffix so each entry + // reaches createProviderConnection as a genuine insert. + const existingConnections = await getProviderConnections({ provider, authType: "apikey" }); + const existingNames = existingConnections + .map((c) => (typeof c.name === "string" ? c.name : null)) + .filter((n): n is string => !!n); + const resolvedEntries = resolveBulkNameCollisions(entries, existingNames); + const created: Array> = []; const errors: Array<{ index: number; name: string; message: string }> = []; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; + for (let i = 0; i < resolvedEntries.length; i++) { + const entry = resolvedEntries[i]; try { // Per-entry copy so each connection gets its own providerSpecificData. Cloudflare // Workers AI carries a per-key accountId (name|accountId|apiKey) that must NOT bleed diff --git a/src/app/api/settings/background-degradation/route.ts b/src/app/api/settings/background-degradation/route.ts index fc7f526add..87744edd98 100644 --- a/src/app/api/settings/background-degradation/route.ts +++ b/src/app/api/settings/background-degradation/route.ts @@ -4,10 +4,28 @@ import { setBackgroundDegradationConfig, resetStats, } from "@omniroute/open-sse/services/backgroundTaskDetector.ts"; -import { updateSettings } from "@/lib/db/settings"; +import { getSettings, updateSettings } from "@/lib/db/settings"; import { jsonObjectSchema, resetStatsActionSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { isPaidModelTarget } from "@/shared/utils/freeModels"; + +/** + * #6540: is any degradation "to" target a paid-only model while hidePaidModels is on? + * Only the "to" side is checked — "from" is a detection trigger key, not an invocation + * target, so a paid "from" is never blocked. Fails open on "unknown" (aliases/combo + * names), mirroring the settings/combo-defaults routes. + */ +async function hasBlockedPaidTarget( + degradationMap: Record | undefined +): Promise { + if (!degradationMap || typeof degradationMap !== "object") return false; + const currentSettings: any = await getSettings(); + if (currentSettings?.hidePaidModels !== true) return false; + return Object.values(degradationMap).some( + (to) => typeof to === "string" && isPaidModelTarget(to) === "paid" + ); +} /** * GET /api/settings/background-degradation @@ -52,7 +70,20 @@ export async function PUT(request: Request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const config = validation.data; + const config = validation.data as { degradationMap?: Record }; + + if (await hasBlockedPaidTarget(config.degradationMap)) { + return NextResponse.json( + { + error: { + code: "PAID_MODEL_TARGET_BLOCKED", + message: + "This field cannot target a paid-only model while 'Hide paid models' is enabled.", + }, + }, + { status: 400 } + ); + } setBackgroundDegradationConfig(config); diff --git a/src/app/api/settings/combo-defaults/route.ts b/src/app/api/settings/combo-defaults/route.ts index 9e6ca358e4..643be54c0e 100644 --- a/src/app/api/settings/combo-defaults/route.ts +++ b/src/app/api/settings/combo-defaults/route.ts @@ -3,6 +3,7 @@ import { getSettings, updateSettings } from "@/lib/localDb"; import { updateComboDefaultsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { isPaidModelTarget } from "@/shared/utils/freeModels"; const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ "timeoutMs", @@ -96,6 +97,30 @@ export async function PATCH(request: Request) { } const body = validation.data; + // #6540: reject a paid-only handoffModel target when hidePaidModels is on. + // Fails open on "unknown" (aliases/combo names) — mirrors the settings + // route's PAID_MODEL_TARGET_BLOCKED check. + if ( + typeof body.comboDefaults?.handoffModel === "string" && + body.comboDefaults.handoffModel.trim() !== "" + ) { + const currentSettings: any = await getSettings(); + if (currentSettings?.hidePaidModels === true) { + if (isPaidModelTarget(body.comboDefaults.handoffModel) === "paid") { + return NextResponse.json( + { + error: { + code: "PAID_MODEL_TARGET_BLOCKED", + message: + "This field cannot target a paid-only model while 'Hide paid models' is enabled.", + }, + }, + { status: 400 } + ); + } + } + } + const updates: Record = {}; if (body.comboDefaults) { diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 00bc0cf398..d4bada962e 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -20,6 +20,7 @@ import { verifyManagementPassword, } from "@/lib/auth/managementPassword"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { isPaidModelTarget } from "@/shared/utils/freeModels"; import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance"; import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; @@ -296,6 +297,30 @@ export async function PATCH(request: Request) { } } + // #6540: reject a paid-only webSearchRouteModel target when hidePaidModels + // is on. Business-rule check (needs an async DB read), so it runs after + // Zod shape validation rather than as a Zod .refine(). Fails open on + // "unknown" (aliases/combo names) — only a positively-identified paid + // catalog entry is blocked. + if (typeof body.webSearchRouteModel === "string" && body.webSearchRouteModel.trim() !== "") { + const currentSettings = await getSettings(); + if ((currentSettings as Record)?.hidePaidModels === true) { + if (isPaidModelTarget(body.webSearchRouteModel) === "paid") { + emitSettingsFailureAudit(request, actor, "PAID_MODEL_TARGET_BLOCKED", attemptedKeys); + return NextResponse.json( + { + error: { + code: "PAID_MODEL_TARGET_BLOCKED", + message: + "This field cannot target a paid-only model while 'Hide paid models' is enabled.", + }, + }, + { status: 400 } + ); + } + } + } + // Password rotation: hash the new value AFTER the gate has accepted the // currentPassword (or the cold-boot exception fired). The gate already // included `newPassword` in SECURITY_IMPACTING_KEYS, so no separate diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 01e0bed7af..ca55440f39 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getProviderById } from "@/shared/constants/providers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getApiKeys } from "@/lib/db/apiKeys"; import { getUserDatabaseSettings } from "@/lib/db/databaseSettings"; @@ -39,6 +40,12 @@ function getRangeStartIso(range: string): string | null { case "90d": start.setDate(start.getDate() - 90); break; + case "180d": + start.setDate(start.getDate() - 180); + break; + case "365d": + start.setDate(start.getDate() - 365); + break; case "ytd": start.setMonth(0, 1); start.setHours(0, 0, 0, 0); @@ -54,6 +61,7 @@ function getRangeStartIso(range: string): string | null { const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; type PricingByProvider = Record>>; +type UsageRows = Array>; type ComputeCostFromPricing = ( pricing: Record | null | undefined, tokens: Record | null | undefined, @@ -413,9 +421,8 @@ export async function GET(request: Request) { const summaryRow = getUsageSummary(unifiedSource, unifiedParams) as Record; - const dailyRows = getDailyUsage(unifiedSource, unifiedParams) as Array>; - - const dailyCostRows = getDailyCostRows(unifiedSource, unifiedParams) as Array>; + const dailyRows = getDailyUsage(unifiedSource, unifiedParams) as UsageRows; + const dailyCostRows = getDailyCostRows(unifiedSource, unifiedParams) as UsageRows; const heatmapStart = new Date(); heatmapStart.setUTCDate(heatmapStart.getUTCDate() - 364); @@ -437,30 +444,30 @@ export async function GET(request: Request) { }); } - const heatmapRows = getHeatmapRows(heatmapConditions, heatmapParams) as Array>; + const heatmapRows = getHeatmapRows(heatmapConditions, heatmapParams) as UsageRows; - const modelRows = getModelUsageRows(unifiedSource, unifiedParams) as Array>; + const modelRows = getModelUsageRows(unifiedSource, unifiedParams) as UsageRows; - const providerCostRows = getProviderCostRows(unifiedSource, unifiedParams) as Array>; + const providerCostRows = getProviderCostRows(unifiedSource, unifiedParams) as UsageRows; - const providerRows = getProviderUsageRows(unifiedSource, unifiedParams) as Array>; + const providerRows = getProviderUsageRows(unifiedSource, unifiedParams) as UsageRows; const accountCostWhereClause = whereClause .replace(/timestamp/g, "usage_history.timestamp") .replace(/api_key_/g, "usage_history.api_key_"); - const accountCostRows = getAccountCostRows(accountCostWhereClause, params) as Array>; + const accountCostRows = getAccountCostRows(accountCostWhereClause, params) as UsageRows; - const accountRows = getAccountUsageRows(accountCostWhereClause, params) as Array>; + const accountRows = getAccountUsageRows(accountCostWhereClause, params) as UsageRows; const apiKeyWhereClause = appendWhereCondition( whereClause, "(api_key_id IS NOT NULL AND api_key_id != '') OR (api_key_name IS NOT NULL AND api_key_name != '')" ); - const apiKeyRows = getApiKeyUsageRows(apiKeyWhereClause, params) as Array>; + const apiKeyRows = getApiKeyUsageRows(apiKeyWhereClause, params) as UsageRows; - const serviceTierRows = getServiceTierUsageRows(unifiedSource, unifiedParams) as Array>; + const serviceTierRows = getServiceTierUsageRows(unifiedSource, unifiedParams) as UsageRows; - const apiKeyMetadataRows = getApiKeyMetadataRows(apiKeyWhereClause, params) as Array>; + const apiKeyMetadataRows = getApiKeyMetadataRows(apiKeyWhereClause, params) as UsageRows; const apiKeyMetadata = new Map }>(); for (const row of apiKeyMetadataRows) { @@ -477,7 +484,7 @@ export async function GET(request: Request) { apiKeyMetadata.set(groupKey, existing); } - const weeklyRows = getWeeklyPatternRows(unifiedSource, unifiedParams) as Array>; + const weeklyRows = getWeeklyPatternRows(unifiedSource, unifiedParams) as UsageRows; const fallbackRow = getFallbackStats(whereClause, params) as Record; @@ -590,7 +597,7 @@ export async function GET(request: Request) { normalizeModelName, computeCostFromPricing ); - const key = `${provider}::${model}`; + const key = `${provider}::${short}`; const existing = modelMap.get(key) || { model: short, provider, @@ -662,7 +669,7 @@ export async function GET(request: Request) { } const byProvider = providerRows.map((row) => ({ - provider: row.provider, + provider: getProviderById(toStringValue(row.provider))?.name ?? toStringValue(row.provider), requests: Number(row.requests), promptTokens: Number(row.promptTokens), completionTokens: Number(row.completionTokens), @@ -897,16 +904,15 @@ export async function GET(request: Request) { } const presetSinceIso = getRangeStartIso(presetRange); - const { unifiedSource: presetUnifiedSource, unifiedParams: presetParams } = - buildPresetUnifiedSource({ - sinceIso: presetSinceIso ?? null, - untilIso: null, - rawCutoffDate, - apiKeyWhere, - apiKeyParams: apiKeyParamEntries, - }); + const { unifiedSource: pSrc, unifiedParams: pParams } = buildPresetUnifiedSource({ + sinceIso: presetSinceIso ?? null, + untilIso: null, + rawCutoffDate, + apiKeyWhere, + apiKeyParams: apiKeyParamEntries, + }); - const presetModelRows = getPresetCostModelRows(presetUnifiedSource, presetParams) as Array>; + const presetModelRows = getPresetCostModelRows(pSrc, pParams) as UsageRows; let presetTotalCost = 0; for (const row of presetModelRows) { diff --git a/src/app/api/usage/model-latency-stats/route.ts b/src/app/api/usage/model-latency-stats/route.ts new file mode 100644 index 0000000000..bd096d9496 --- /dev/null +++ b/src/app/api/usage/model-latency-stats/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getModelLatencyStats } from "@/lib/usageDb"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; + +const querySchema = z.object({ + windowHours: z.coerce + .number() + .positive() + .max(24 * 30) + .optional(), + minSamples: z.coerce.number().int().positive().optional(), + maxRows: z.coerce.number().int().positive().max(50000).optional(), + provider: z.string().trim().min(1).max(64).optional(), + model: z.string().trim().min(1).max(256).optional(), +}); + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { searchParams } = new URL(request.url); + const parsed = querySchema.safeParse({ + windowHours: searchParams.get("windowHours") || undefined, + minSamples: searchParams.get("minSamples") || undefined, + maxRows: searchParams.get("maxRows") || undefined, + provider: searchParams.get("provider") || undefined, + model: searchParams.get("model") || undefined, + }); + + if (!parsed.success) { + return NextResponse.json( + buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid query parameters"), + { status: 400 } + ); + } + + const { windowHours, minSamples, maxRows, provider, model } = parsed.data; + const statsByKey = await getModelLatencyStats({ + windowHours, + minSamples, + maxRows, + provider, + model, + }); + + return NextResponse.json({ + entries: Object.values(statsByKey), + windowHours: windowHours ?? 24, + generatedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("[API] GET /api/usage/model-latency-stats error:", error); + return NextResponse.json(buildErrorBody(500, "Failed to build model latency stats"), { + status: 500, + }); + } +} diff --git a/src/app/api/usage/requests-by-provider-date/route.ts b/src/app/api/usage/requests-by-provider-date/route.ts index 0f1527b1d1..bdab6bf005 100644 --- a/src/app/api/usage/requests-by-provider-date/route.ts +++ b/src/app/api/usage/requests-by-provider-date/route.ts @@ -33,6 +33,12 @@ function getRangeStartIso(range: string): string | null { case "90d": start.setDate(start.getDate() - 90); break; + case "180d": + start.setDate(start.getDate() - 180); + break; + case "365d": + start.setDate(start.getDate() - 365); + break; case "ytd": start.setMonth(0, 1); start.setHours(0, 0, 0, 0); diff --git a/src/app/api/v1/audio/speech/route.ts b/src/app/api/v1/audio/speech/route.ts index 576721f522..80a6dda969 100644 --- a/src/app/api/v1/audio/speech/route.ts +++ b/src/app/api/v1/audio/speech/route.ts @@ -23,6 +23,7 @@ import { import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta"; import { calculateModalCost } from "@/lib/usage/costCalculator"; import { generateRequestId } from "@/shared/utils/requestId"; +import { getClientIpFromRequest } from "@/lib/ipUtils"; /** * Handle CORS preflight @@ -112,6 +113,7 @@ async function postHandler(request, context) { credentials, resolvedProvider: providerConfig, resolvedModel, + clientIp: getClientIpFromRequest(request), }); if (response?.ok) { await clearRecoveredProviderState(credentials); diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 8c5d090349..162bacc1a5 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -13,6 +13,7 @@ import { } from "@omniroute/open-sse/config/imageRegistry.ts"; import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; import * as log from "@/sse/utils/logger"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; @@ -202,6 +203,14 @@ async function postHandler(request, context) { credentials.retryAfterHuman ); } + } else if (providerConfig && providerConfig.authType === "none") { + // #6928: best-effort per-connection base-URL override lookup for local + // no-auth media providers (ComfyUI). A connection is optional here — unlike + // the authType !== "none" branch above, we never 400 when none exists. + const localCredentials = await getProviderCredentialsWithQuotaPreflight(provider); + if (localCredentials && !isAllRateLimitedCredentials(localCredentials)) { + credentials = localCredentials; + } } // Resolve proxy for the connection if credentials exist (#1904) diff --git a/src/app/api/v1/music/generations/route.ts b/src/app/api/v1/music/generations/route.ts index c4ce53077d..033fba2244 100644 --- a/src/app/api/v1/music/generations/route.ts +++ b/src/app/api/v1/music/generations/route.ts @@ -42,6 +42,18 @@ export async function GET(request?: Request) { ); } +/** + * #6928: best-effort per-connection base-URL override lookup for local no-auth + * media providers (ComfyUI). Returns null instead of failing when no connection + * exists — local providers must keep working with zero configuration. + */ +async function resolveLocalOverrideCredentials(provider) { + const localCredentials = await getProviderCredentialsWithQuotaPreflight(provider); + return localCredentials && !isAllRateLimitedCredentials(localCredentials) + ? localCredentials + : null; +} + /** * POST /v1/music/generations — generate music */ @@ -85,6 +97,8 @@ async function postHandler(request, context) { if (isAllRateLimitedCredentials(credentials)) { return rateLimitedProviderResponse(provider, credentials); } + } else if (providerConfig?.authType === "none") { + credentials = await resolveLocalOverrideCredentials(provider); } const result = await handleMusicGeneration({ body, credentials, log }); diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index 27cf3bf414..b92b9cbfe0 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -21,6 +21,7 @@ import { import { getBifrostRoutingConfig, getRoutingFallbackHeader, + getRoutingFallbackReasonHeader, resolveRelayRoutingBackend, shouldTryBifrostForRequest, type BifrostRoutingConfig, @@ -378,6 +379,12 @@ export async function POST(request: Request) { // #5526 helper gates emission (auto + enabled); #5519 dynamic cooldown/error // reason wins as the value when set, else falls back to the static "bifrost". newHeaders.set("X-Routing-Fallback", bifrostFallbackReason ?? routingFallback); + // #6872: stable, machine-readable companion header — one of the 4 enum + // reason codes, or unset when the legacy value has no specific reason. + const fallbackReasonCode = getRoutingFallbackReasonHeader(bifrostFallbackReason); + if (fallbackReasonCode) { + newHeaders.set("X-Routing-Fallback-Reason", fallbackReasonCode); + } } return new Response(response.body, { diff --git a/src/app/api/v1/relay/chat/completions/routingBackend.ts b/src/app/api/v1/relay/chat/completions/routingBackend.ts index ed1459c5d0..e0a3fb66eb 100644 --- a/src/app/api/v1/relay/chat/completions/routingBackend.ts +++ b/src/app/api/v1/relay/chat/completions/routingBackend.ts @@ -102,3 +102,33 @@ export function getRoutingFallbackHeader( ): "bifrost" | undefined { return backend === "auto" && config?.enabled ? "bifrost" : undefined; } + +export type RoutingFallbackReasonCode = + | "bifrost-cooldown" + | "bifrost-error" + | "bifrost-ineligible" + | "bifrost-provider-unknown"; + +const ROUTING_FALLBACK_REASON_CODES = new Set([ + "bifrost-cooldown", + "bifrost-error", + "bifrost-ineligible", + "bifrost-provider-unknown", +]); + +/** + * Derives the stable, machine-readable reason code for X-Routing-Fallback-Reason + * from the existing (possibly parameterized) X-Routing-Fallback detail string. + * #6872: splits the enum token from the legacy ad-hoc detail (e.g. strips the + * "; remaining=" suffix on the cooldown case) without changing the legacy + * X-Routing-Fallback value itself. + */ +export function getRoutingFallbackReasonHeader( + fallbackReason: string | null | undefined +): RoutingFallbackReasonCode | undefined { + if (!fallbackReason) return undefined; + const code = fallbackReason.split(";", 1)[0]?.trim(); + return code && ROUTING_FALLBACK_REASON_CODES.has(code as RoutingFallbackReasonCode) + ? (code as RoutingFallbackReasonCode) + : undefined; +} diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index f1c3693d56..55fc840248 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -43,6 +43,18 @@ export async function GET(request?: Request) { ); } +/** + * #6928: best-effort per-connection base-URL override lookup for local no-auth + * media providers (ComfyUI). Returns null instead of failing when no connection + * exists — local providers must keep working with zero configuration. + */ +async function resolveLocalOverrideCredentials(provider) { + const localCredentials = await getProviderCredentialsWithQuotaPreflight(provider); + return localCredentials && !isAllRateLimitedCredentials(localCredentials) + ? localCredentials + : null; +} + /** * POST /v1/videos/generations — generate videos */ @@ -90,6 +102,8 @@ async function postHandler(request, context) { if (isAllRateLimitedCredentials(credentials)) { return rateLimitedProviderResponse(provider, credentials); } + } else if (providerConfig?.authType === "none") { + credentials = await resolveLocalOverrideCredentials(provider); } const result = await handleVideoGeneration({ body, credentials, log }); diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d138a2be77..d3658c5a4c 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "استدعاء الأداة", "copilotPasteInto": "لصق في:", "wireApiChatCompletions": "مكتملات الدردشة (/chat/مكتملات)", - "wireApiResponses": "واجهة برمجة تطبيقات الاستجابات (/ الاستجابات)" + "wireApiResponses": "واجهة برمجة تطبيقات الاستجابات (/ الاستجابات)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "المجموعات", @@ -2716,6 +2726,8 @@ "range7d": "7 أيام", "range30d": "30 يوما", "range90d": "90 يوما", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "كل الوقت", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "موفري مفاتيح API", "compatibleProviders": "مقدمو خدمات API المتوافقون", "testAll": "اختبار الكل", + "reorderByAvailability": "إعادة الترتيب", + "reorderByAvailabilityTitle": "إعادة ترتيب الاتصالات حسب التوفر", + "reorderByAvailabilityError": "فشل في إعادة ترتيب الاتصالات حسب التوفر", "testAllOAuth": "اختبار كافة اتصالات OAuth", "testAllFree": "اختبار كافة الاتصالات المجانية", "testAllApiKey": "اختبار جميع اتصالات مفتاح API", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "الإعدادات", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 5b4a503883..01be54cf8e 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Alət çağırışı", "copilotPasteInto": "Yapışdırın:", "wireApiChatCompletions": "Söhbət Tamamlamaları (/chat/tamamlamalar)", - "wireApiResponses": "Responses API (/cavablar)" + "wireApiResponses": "Responses API (/cavablar)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2716,6 +2726,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "Yenidən sırala", + "reorderByAvailabilityTitle": "Bağlantıları əlçatanlığa görə yenidən sırala", + "reorderByAvailabilityError": "Bağlantıları əlçatanlığa görə yenidən sıralamaq alınmadı", "testAllOAuth": "Test all OAuth connections", "testAllFree": "Test all Free connections", "testAllApiKey": "Test all API Key connections", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Settings", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 6eaa518743..ec9e90f21d 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Извикване на инструмент", "copilotPasteInto": "Поставете в:", "wireApiChatCompletions": "Завършвания на чат (/chat/completions)", - "wireApiResponses": "API за отговори (/отговори)" + "wireApiResponses": "API за отговори (/отговори)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Комбота", @@ -2716,6 +2726,8 @@ "range7d": "7 дни", "range30d": "30 дни", "range90d": "90 дни", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Всички времена", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "Доставчици на API ключове", "compatibleProviders": "API Key Съвместими доставчици", "testAll": "Тествайте всички", + "reorderByAvailability": "Пренареждане", + "reorderByAvailabilityTitle": "Пренаредете връзките по наличност", + "reorderByAvailabilityError": "Неуспешно пренареждане на връзките по наличност", "testAllOAuth": "Тествайте всички OAuth връзки", "testAllFree": "Тествайте всички безплатни връзки", "testAllApiKey": "Тествайте всички API Key връзки", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Настройки", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index e87464290f..c0798800a7 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "টুল কলিং", "copilotPasteInto": "এতে পেস্ট করুন:", "wireApiChatCompletions": "চ্যাট সমাপ্তি (/চ্যাট/সম্পূর্ণতা)", - "wireApiResponses": "প্রতিক্রিয়া API (/প্রতিক্রিয়া)" + "wireApiResponses": "প্রতিক্রিয়া API (/প্রতিক্রিয়া)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2716,6 +2726,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "পুনর্বিন্যাস", + "reorderByAvailabilityTitle": "প্রাপ্যতা অনুসারে সংযোগ পুনর্বিন্যাস করুন", + "reorderByAvailabilityError": "প্রাপ্যতা অনুসারে সংযোগ পুনর্বিন্যাস করা যায়নি", "testAllOAuth": "Test all OAuth connections", "testAllFree": "Test all Free connections", "testAllApiKey": "Test all API Key connections", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Settings", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index b305250869..62f5576f1c 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Volání nástroje", "copilotPasteInto": "Vložit do:", "wireApiChatCompletions": "Dokončení chatu (/chat/completions)", - "wireApiResponses": "Responses API (/responses)" + "wireApiResponses": "Responses API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Komba", @@ -2716,6 +2726,8 @@ "range7d": "7 dní", "range30d": "30 dní", "range90d": "90 dní", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Celou dobu", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "Poskytovatelé s API Klíči", "compatibleProviders": "Poskytovatelé kompatibilní s API klíči", "testAll": "Test všech", + "reorderByAvailability": "Přeřadit", + "reorderByAvailabilityTitle": "Seřadit připojení podle dostupnosti", + "reorderByAvailabilityError": "Nepodařilo se seřadit připojení podle dostupnosti", "testAllOAuth": "Test všech připojení OAuth", "testAllFree": "Test všech bezplatných připojení", "testAllApiKey": "Test všech připojení API klíči", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Nastavení", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 9c1f4ff93d..2f7318b1ab 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Værktøjsopkald", "copilotPasteInto": "Indsæt i:", "wireApiChatCompletions": "Chatafslutninger (/chat/afslutninger)", - "wireApiResponses": "Responses API (/responses)" + "wireApiResponses": "Responses API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2716,6 +2726,8 @@ "range7d": "7 dage", "range30d": "30 dage", "range90d": "90 dage", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Alle Tider", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API-nøgleudbydere", "compatibleProviders": "API Key-kompatible udbydere", "testAll": "Test alle", + "reorderByAvailability": "Omorganiser", + "reorderByAvailabilityTitle": "Omorganiser forbindelser efter tilgængelighed", + "reorderByAvailabilityError": "Kunne ikke omorganisere forbindelser efter tilgængelighed", "testAllOAuth": "Test alle OAuth-forbindelser", "testAllFree": "Test alle gratis forbindelser", "testAllApiKey": "Test alle API-nøgleforbindelser", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Indstillinger", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index cfe26ff333..a495a662fd 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2223,7 +2223,17 @@ "copilotToolCalling": "Werkzeugaufruf", "copilotPasteInto": "Einfügen in:", "wireApiChatCompletions": "Chat-Abschlüsse (/chat/completions)", - "wireApiResponses": "Antwort-API (/responses)" + "wireApiResponses": "Antwort-API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Kombinationen", @@ -2721,6 +2731,8 @@ "range7d": "7 Tage", "range30d": "30 Tage", "range90d": "90 Tage", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Alle Zeit", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3657,6 +3669,9 @@ "apiKeyProviders": "API-Schlüsselanbieter", "compatibleProviders": "Mit API-Schlüsseln kompatible Anbieter", "testAll": "Alle testen", + "reorderByAvailability": "Neu ordnen", + "reorderByAvailabilityTitle": "Verbindungen nach Verfügbarkeit neu ordnen", + "reorderByAvailabilityError": "Verbindungen konnten nicht nach Verfügbarkeit neu geordnet werden", "testAllOAuth": "Testen Sie alle OAuth-Verbindungen", "testAllFree": "Testen Sie alle kostenlosen Verbindungen", "testAllApiKey": "Testen Sie alle API-Schlüsselverbindungen", @@ -4432,7 +4447,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Einstellungen", @@ -5530,7 +5547,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7636,5 +7658,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 5a8aec9b48..b59a606c49 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2050,6 +2050,16 @@ "saveMappings": "Save Mappings", "mappingsSaved": "Mappings saved!", "failedSaveMappings": "Failed to save mappings", + "reasoningEffort": "Reasoning effort for {model}", + "reasoningEffortDefault": "Default", + "reasoningEffortHint": "Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "None", + "low": "Low", + "medium": "Medium", + "high": "High", + "xhigh": "XHigh" + }, "howItWorks": "How it works:", "antigravityHowWorksDesc": "Antigravity sends requests to Google's endpoint. MITM intercepts and redirects them to OmniRoute.", "antigravityStep1": "1. Start MITM to route requests through OmniRoute.", @@ -2853,6 +2863,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3905,6 +3917,8 @@ "apiKeyHelp": "An API key is a password for AI services. Get one from your provider's website (e.g., platform.openai.com, console.anthropic.com).", "tier": { "subtitle": "OmniRoute organises providers into three tiers so routing prefers the most reliable, lowest-cost path first.", + "flowCaption": "Requests flow through your subscription quotas first, then pay-per-token cheap providers, then free-tier providers — automatic, zero-config.", + "afterSetup": "after setup.", "tier1": { "label": "Premium clients", "description": "First-class CLIs with native auth flows and reasoning models." @@ -3984,6 +3998,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "Reorder", + "reorderByAvailabilityTitle": "Reorder connections by availability", + "reorderByAvailabilityError": "Failed to reorder connections by availability", "distributeProxies": "Distribute Proxies", "distributing": "Distributing...", "selectedCount": "{count, plural, one {# selected} other {# selected}}", @@ -4326,6 +4343,8 @@ "contextWindowOverridePlaceholder": "e.g. 131072", "contextWindowOverrideHint": "Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", "contextWindowOverrideInvalid": "Context window override must be a positive whole number of tokens", + "visionCapableLabel": "Vision capable", + "visionCapableHint": "Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends).", "compatParamFiltersLabel": "Param Filters", "compatBlockedParamsHint": "Blocked params (stripped from requests)", "compatAllowedParamsHint": "Allowed params (re-added after deny)", @@ -5628,7 +5647,8 @@ "echoRequestedModelDesc": "When enabled, the response `model` field echoes the alias or combo name the client requested instead of the upstream model name. Fixes strict clients (e.g. Claude Desktop) that reject a response whose model does not match the request.", "webSearchRouteTitle": "Web search routing", "webSearchRouteDesc": "When a request includes a native web_search tool, route the whole request to this model instead of the default — useful for providers that don't implement Anthropic's web_search server tool. Leave blank to disable.", - "webSearchRoutePlaceholder": "e.g. openrouter,anthropic/claude-3.5-sonnet", + "webSearchRoutePlaceholder": "Search or select a model…", + "paidModelPatternWarning": "This pattern only matches paid models — enable paid models or adjust the pattern.", "clearLkgpCache": "Clear LKGP Cache", "lkgpCacheCleared": "LKGP cache cleared successfully", "lkgpCacheClearFailed": "Failed to clear LKGP cache", @@ -5949,6 +5969,7 @@ "retentionCallLogs": "Call Logs (days)", "retentionUsageHistory": "Usage History (days)", "retentionMemoryEntries": "Memory Entries (days)", + "retentionXpAuditLog": "XP Audit Log (days)", "saveRetentionSettings": "Save retention settings", "storageAutoVacuumMode": "Auto Vacuum Mode", "storageScheduledVacuum": "Scheduled Vacuum", @@ -6046,6 +6067,11 @@ "codexFastTierDesc": "Globally inject service_tier=priority for OpenAI Codex requests.", "codexFastTierHint": "When enabled, OmniRoute adds service_tier=priority to outbound Codex requests for connections that don't already specify a tier. Priority tier requires an OpenAI Enterprise API key or the ChatGPT-auth Codex path; other key types will receive a tier-related error from OpenAI. Per-connection settings on the Codex provider page take precedence.", "codexFastTierSaveError": "Failed to update Codex Fast Tier setting", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}", "codexFastTierTierLabel": "Service tier", "codexFastTierTierPriority": "Priority", "codexFastTierTierFlex": "Flex", @@ -9166,7 +9192,14 @@ "configuredOnly": "Configured Only", "configuredOnlyHint": "Show only providers with active connections", "noConfiguredProviders": "No configured providers found. Add a provider connection first.", - "colConfigured": "Status" + "colConfigured": "Status", + "typeAll": "All Types", + "typeNoauth": "No Signup", + "typeOauth": "OAuth Login", + "typeApikey": "API Key", + "sortTypeFirst": "Easiest first", + "sortTypeFirstHelp": "Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" }, "discovery": { "title": "Provider Discovery", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index a65a6c2ece..60ae848505 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Llamada de herramientas", "copilotPasteInto": "Pegar en:", "wireApiChatCompletions": "Finalizaciones de chat (/chat/completions)", - "wireApiResponses": "API de respuestas (/respuestas)" + "wireApiResponses": "API de respuestas (/respuestas)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "combos", @@ -2716,6 +2726,8 @@ "range7d": "7 dias", "range30d": "30 dias", "range90d": "90 días", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Todo el tiempo", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "Proveedores de claves API", "compatibleProviders": "Proveedores compatibles con claves API", "testAll": "Probar todo", + "reorderByAvailability": "Reordenar", + "reorderByAvailabilityTitle": "Reordenar conexiones por disponibilidad", + "reorderByAvailabilityError": "No se pudieron reordenar las conexiones por disponibilidad", "testAllOAuth": "Pruebe todas las conexiones OAuth", "testAllFree": "Pruebe todas las conexiones gratuitas", "testAllApiKey": "Pruebe todas las conexiones de clave API", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Configuración", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index af9857d109..ff2bbe429e 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "فراخوانی ابزار", "copilotPasteInto": "چسباندن در:", "wireApiChatCompletions": "تکمیل چت (/chat/completions)", - "wireApiResponses": "Responses API (/responses)" + "wireApiResponses": "Responses API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2716,6 +2726,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "ترتیب مجدد", + "reorderByAvailabilityTitle": "ترتیب مجدد اتصالات بر اساس در دسترس بودن", + "reorderByAvailabilityError": "ترتیب مجدد اتصالات بر اساس در دسترس بودن ناموفق بود", "testAllOAuth": "Test all OAuth connections", "testAllFree": "Test all Free connections", "testAllApiKey": "Test all API Key connections", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Settings", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 4136e1e945..5c1bbd5b38 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Työkalun kutsuminen", "copilotPasteInto": "Liitä kohteeseen:", "wireApiChatCompletions": "Keskustelun päättymiset (/chat/completions)", - "wireApiResponses": "Responses API (/responses)" + "wireApiResponses": "Responses API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Yhdistelmät", @@ -2716,6 +2726,8 @@ "range7d": "7 päivää", "range30d": "30 päivää", "range90d": "90 päivää", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Kaikki aika", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API-avainten tarjoajat", "compatibleProviders": "API Key -yhteensopivat palveluntarjoajat", "testAll": "Testaa kaikki", + "reorderByAvailability": "Järjestä uudelleen", + "reorderByAvailabilityTitle": "Järjestä yhteydet uudelleen saatavuuden mukaan", + "reorderByAvailabilityError": "Yhteyksien uudelleenjärjestäminen saatavuuden mukaan epäonnistui", "testAllOAuth": "Testaa kaikki OAuth-yhteydet", "testAllFree": "Testaa kaikki ilmaiset yhteydet", "testAllApiKey": "Testaa kaikki API-avainyhteydet", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Asetukset", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 4dcbebcaf7..8e67e30210 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Appel d'outil", "copilotPasteInto": "Coller dans :", "wireApiChatCompletions": "Achèvements de chat (/chat/completions)", - "wireApiResponses": "API de réponses (/réponses)" + "wireApiResponses": "API de réponses (/réponses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combinaisons", @@ -2716,6 +2726,8 @@ "range7d": "7 jours", "range30d": "30 jours", "range90d": "90 jours", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Tout le temps", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "Fournisseurs de clés API", "compatibleProviders": "Fournisseurs compatibles avec les clés API", "testAll": "Tout tester", + "reorderByAvailability": "Réorganiser", + "reorderByAvailabilityTitle": "Réorganiser les connexions par disponibilité", + "reorderByAvailabilityError": "Échec de la réorganisation des connexions par disponibilité", "testAllOAuth": "Testez toutes les connexions OAuth", "testAllFree": "Testez toutes les connexions gratuites", "testAllApiKey": "Testez toutes les connexions de clé API", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Paramètres", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 38a5da2b30..da657cae8d 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "ટૂલ કૉલિંગ", "copilotPasteInto": "આમાં પેસ્ટ કરો:", "wireApiChatCompletions": "ચેટ પૂર્ણતા (/ચેટ/પૂર્ણતા)", - "wireApiResponses": "પ્રતિભાવ API (/પ્રતિસાદો)" + "wireApiResponses": "પ્રતિભાવ API (/પ્રતિસાદો)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2716,6 +2726,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "ફરીથી ગોઠવો", + "reorderByAvailabilityTitle": "ઉપલબ્ધતા દ્વારા જોડાણો ફરીથી ગોઠવો", + "reorderByAvailabilityError": "ઉપલબ્ધતા દ્વારા જોડાણો ફરીથી ગોઠવવામાં નિષ્ફળ", "testAllOAuth": "Test all OAuth connections", "testAllFree": "Test all Free connections", "testAllApiKey": "Test all API Key connections", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Settings", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 7995a1daf0..0813a226e5 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "כלי שיחות", "copilotPasteInto": "הדבק לתוך:", "wireApiChatCompletions": "השלמות של צ'אט (/chat/completions)", - "wireApiResponses": "ממשק API של תגובות (/תגובות)" + "wireApiResponses": "ממשק API של תגובות (/תגובות)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "שילובים", @@ -2716,6 +2726,8 @@ "range7d": "7 ימים", "range30d": "30 ימים", "range90d": "90 ימים", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "כל הזמן", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "ספקי מפתח API", "compatibleProviders": "ספקים תואמים מפתח API", "testAll": "בדוק הכל", + "reorderByAvailability": "סדר מחדש", + "reorderByAvailabilityTitle": "סדר מחדש חיבורים לפי זמינות", + "reorderByAvailabilityError": "סידור מחדש של החיבורים לפי זמינות נכשל", "testAllOAuth": "בדוק את כל חיבורי OAuth", "testAllFree": "בדוק את כל החיבורים החינמיים", "testAllApiKey": "בדוק את כל חיבורי מפתח ה-API", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "הגדרות", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 1e97841ad8..b3b24e7ada 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "टूल कॉलिंग", "copilotPasteInto": "इसमें चिपकाएँ:", "wireApiChatCompletions": "चैट पूर्णताएँ (/चैट/पूर्णियाँ)", - "wireApiResponses": "प्रतिक्रियाएँ एपीआई (/प्रतिक्रियाएँ)" + "wireApiResponses": "प्रतिक्रियाएँ एपीआई (/प्रतिक्रियाएँ)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "संयोजन", @@ -2716,6 +2726,8 @@ "range7d": "7 दिन", "range30d": "30 दिन", "range90d": "90 दिन", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "हर समय", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "एपीआई कुंजी प्रदाता", "compatibleProviders": "एपीआई कुंजी संगत प्रदाता", "testAll": "सभी का परीक्षण करें", + "reorderByAvailability": "पुनः क्रमबद्ध करें", + "reorderByAvailabilityTitle": "उपलब्धता के अनुसार कनेक्शन पुनः क्रमबद्ध करें", + "reorderByAvailabilityError": "उपलब्धता के अनुसार कनेक्शन पुनः क्रमबद्ध करने में विफल", "testAllOAuth": "सभी OAuth कनेक्शन का परीक्षण करें", "testAllFree": "सभी निःशुल्क कनेक्शनों का परीक्षण करें", "testAllApiKey": "सभी एपीआई कुंजी कनेक्शन का परीक्षण करें", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "सेटिंग्स", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 6377521832..90a3ddd464 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Szerszámhívás", "copilotPasteInto": "Beillesztés ide:", "wireApiChatCompletions": "Csevegés befejezése (/chat/completions)", - "wireApiResponses": "Responses API (/responses)" + "wireApiResponses": "Responses API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Kombók", @@ -2716,6 +2726,8 @@ "range7d": "7 nap", "range30d": "30 nap", "range90d": "90 nap", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Minden idők", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API kulcs szolgáltatók", "compatibleProviders": "API-kulcs-kompatibilis szolgáltatók", "testAll": "Test All", + "reorderByAvailability": "Újrarendezés", + "reorderByAvailabilityTitle": "Kapcsolatok újrarendezése elérhetőség szerint", + "reorderByAvailabilityError": "A kapcsolatok elérhetőség szerinti újrarendezése sikertelen", "testAllOAuth": "Tesztelje az összes OAuth-kapcsolatot", "testAllFree": "Tesztelje az összes ingyenes kapcsolatot", "testAllApiKey": "Tesztelje az összes API-kulcs kapcsolatot", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Beállítások elemre", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 5ec919a3ee..19812e2a87 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Panggilan Alat", "copilotPasteInto": "Tempel ke:", "wireApiChatCompletions": "Penyelesaian Obrolan (/chat/penyelesaian)", - "wireApiResponses": "API Respons (/respons)" + "wireApiResponses": "API Respons (/respons)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "kombo", @@ -2716,6 +2726,8 @@ "range7d": "7 hari", "range30d": "30 Hari", "range90d": "90 Hari", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Sepanjang Waktu", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "Penyedia Kunci API", "compatibleProviders": "Penyedia Kompatibel Kunci API", "testAll": "Uji Semua", + "reorderByAvailability": "Urutkan ulang", + "reorderByAvailabilityTitle": "Urutkan ulang koneksi berdasarkan ketersediaan", + "reorderByAvailabilityError": "Gagal mengurutkan ulang koneksi berdasarkan ketersediaan", "testAllOAuth": "Uji semua koneksi OAuth", "testAllFree": "Uji semua koneksi Gratis", "testAllApiKey": "Uji semua koneksi Kunci API", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Pengaturan", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index c1f3e2f4dd..b5f348135f 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Panggilan Alat", "copilotPasteInto": "Tempel ke:", "wireApiChatCompletions": "Penyelesaian Obrolan (/chat/penyelesaian)", - "wireApiResponses": "API Respons (/respons)" + "wireApiResponses": "API Respons (/respons)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2716,6 +2726,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "पुनः क्रमबद्ध करें", + "reorderByAvailabilityTitle": "उपलब्धता के अनुसार कनेक्शन पुनः क्रमबद्ध करें", + "reorderByAvailabilityError": "उपलब्धता के अनुसार कनेक्शन पुनः क्रमबद्ध करने में विफल", "testAllOAuth": "Test all OAuth connections", "testAllFree": "Test all Free connections", "testAllApiKey": "Test all API Key connections", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Settings", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index d1dcfc4b68..0a238d43aa 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -2328,7 +2328,17 @@ "copilotToolCalling": "Chiamata dello strumento", "copilotPasteInto": "Incolla in:", "wireApiChatCompletions": "Completamenti chat (/chat/completamenti)", - "wireApiResponses": "API delle risposte (/responses)" + "wireApiResponses": "API delle risposte (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combinazioni", @@ -2845,6 +2855,8 @@ "range7d": "7 giorni", "range30d": "30 giorni", "range90d": "90 giorni", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Tutto il tempo", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3976,6 +3988,9 @@ "apiKeyProviders": "Fornitori di chiavi API", "compatibleProviders": "Fornitori compatibili con chiave API", "testAll": "Prova tutto", + "reorderByAvailability": "Riordina", + "reorderByAvailabilityTitle": "Riordina le connessioni in base alla disponibilità", + "reorderByAvailabilityError": "Impossibile riordinare le connessioni in base alla disponibilità", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4854,7 +4869,9 @@ "compatibleDefaultModelHint": "Inserisci l'ID modello esattamente come lo aspetta il tuo endpoint compatibile. Questo modello verrà salvato come default della connessione.", "compatibleDefaultModelLabel": "Modello Predefinito", "iconUrlHint": "Opzionale. URL dell'immagine mostrata come icona di questo provider.", - "iconUrlLabel": "URL Icona" + "iconUrlLabel": "URL Icona", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Impostazioni", @@ -6155,7 +6172,12 @@ "searchProviderAria": "Provider di ricerca", "searchProviderPlaceholder": "Cerca provider...", "selectProviderPlaceholder": "Seleziona provider...", - "update": "Aggiorna" + "update": "Aggiorna", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -9040,7 +9062,14 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" }, "disabled": "Disabilitato", "discovery": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index cffda552d5..ecc9333b0a 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "ツール呼び出し", "copilotPasteInto": "以下に貼り付けます:", "wireApiChatCompletions": "チャットの完了 (/chat/completions)", - "wireApiResponses": "レスポンス API (/responses)" + "wireApiResponses": "レスポンス API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "コンボ", @@ -2822,6 +2832,8 @@ "range7d": "7日間", "range30d": "30日", "range90d": "90日", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "オールタイム", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "APIキープロバイダー", "compatibleProviders": "API キー互換プロバイダー", "testAll": "すべてをテストする", + "reorderByAvailability": "並べ替え", + "reorderByAvailabilityTitle": "可用性で接続を並べ替える", + "reorderByAvailabilityError": "可用性による接続の並べ替えに失敗しました", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "設定", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index cdb62ee3b3..02193fba5f 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "도구 호출", "copilotPasteInto": "다음 위치에 붙여넣습니다.", "wireApiChatCompletions": "채팅 완료(/chat/completions)", - "wireApiResponses": "응답 API(/responses)" + "wireApiResponses": "응답 API(/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "콤보", @@ -2822,6 +2832,8 @@ "range7d": "7일", "range30d": "30일", "range90d": "90일", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "모든 시간", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "API 키 제공자", "compatibleProviders": "API 키 호환 제공자", "testAll": "모두 테스트", + "reorderByAvailability": "재정렬", + "reorderByAvailabilityTitle": "가용성에 따라 연결 재정렬", + "reorderByAvailabilityError": "가용성에 따라 연결을 재정렬하지 못했습니다", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "Dola Web", - "doubaoWebDesc": "dola.com을 통한 ByteDance AI 채팅" + "doubaoWebDesc": "dola.com을 통한 ByteDance AI 채팅", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "설정", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index b749d07970..b7f7ed7424 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "टूल कॉलिंग", "copilotPasteInto": "यामध्ये पेस्ट करा:", "wireApiChatCompletions": "चॅट पूर्णता (/चॅट/पूर्णता)", - "wireApiResponses": "प्रतिसाद API (/प्रतिसाद)" + "wireApiResponses": "प्रतिसाद API (/प्रतिसाद)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2822,6 +2832,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "पुन्हा क्रमवारी लावा", + "reorderByAvailabilityTitle": "उपलब्धतेनुसार कनेक्शन पुन्हा क्रमवारी लावा", + "reorderByAvailabilityError": "उपलब्धतेनुसार कनेक्शन पुन्हा क्रमवारी लावण्यात अयशस्वी", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Settings", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index b4daa3769d..c6b9c4772e 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "Alat Panggilan", "copilotPasteInto": "Tampalkan ke dalam:", "wireApiChatCompletions": "Selesai Sembang (/sembang/penyelesaian)", - "wireApiResponses": "API Respons (/respons)" + "wireApiResponses": "API Respons (/respons)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Kombo", @@ -2822,6 +2832,8 @@ "range7d": "7 Hari", "range30d": "30 Hari", "range90d": "90 Hari", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Sepanjang Masa", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "Pembekal Kunci API", "compatibleProviders": "Pembekal Serasi Kunci API", "testAll": "Uji Semua", + "reorderByAvailability": "Susun semula", + "reorderByAvailabilityTitle": "Susun semula sambungan mengikut ketersediaan", + "reorderByAvailabilityError": "Gagal menyusun semula sambungan mengikut ketersediaan", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "tetapan", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 97abbddca9..419b520a3f 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "Gereedschap bellen", "copilotPasteInto": "Plakken in:", "wireApiChatCompletions": "Chatvoltooiingen (/chat/voltooiingen)", - "wireApiResponses": "Reacties-API (/reacties)" + "wireApiResponses": "Reacties-API (/reacties)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combo's", @@ -2822,6 +2832,8 @@ "range7d": "7 dagen", "range30d": "30 dagen", "range90d": "90 dagen", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Altijd", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "API-sleutelproviders", "compatibleProviders": "API-sleutel-compatibele providers", "testAll": "Alles testen", + "reorderByAvailability": "Herschikken", + "reorderByAvailabilityTitle": "Verbindingen herschikken op beschikbaarheid", + "reorderByAvailabilityError": "Verbindingen herschikken op beschikbaarheid is mislukt", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Instellingen", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 9749123dcc..34abc07b58 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "Verktøyanrop", "copilotPasteInto": "Lim inn i:", "wireApiChatCompletions": "Chatfullføringer (/chat/fullføringer)", - "wireApiResponses": "Responses API (/responses)" + "wireApiResponses": "Responses API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2822,6 +2832,8 @@ "range7d": "7 dager", "range30d": "30 dager", "range90d": "90 dager", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Hele tiden", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "API-nøkkelleverandører", "compatibleProviders": "API-nøkkel-kompatible leverandører", "testAll": "Test alle", + "reorderByAvailability": "Omorganiser", + "reorderByAvailabilityTitle": "Omorganiser tilkoblinger etter tilgjengelighet", + "reorderByAvailabilityError": "Kunne ikke omorganisere tilkoblinger etter tilgjengelighet", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Innstillinger", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 20508d7bb1..e6d508b960 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "Tool Calling", "copilotPasteInto": "Idikit sa:", "wireApiChatCompletions": "Mga Pagkumpleto ng Chat (/chat/pagkumpleto)", - "wireApiResponses": "Responses API (/mga tugon)" + "wireApiResponses": "Responses API (/mga tugon)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Mga combo", @@ -2822,6 +2832,8 @@ "range7d": "7 Araw", "range30d": "30 Araw", "range90d": "90 Araw", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Lahat ng Panahon", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "Mga API Key Provider", "compatibleProviders": "Mga Katugmang Provider ng API Key", "testAll": "Subukan ang Lahat", + "reorderByAvailability": "Ayusin muli", + "reorderByAvailabilityTitle": "Ayusin muli ang mga koneksyon ayon sa availability", + "reorderByAvailabilityError": "Hindi maayos muli ang mga koneksyon ayon sa availability", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Mga setting", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 96f94baf2d..cb0f129a3e 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "Wywołanie narzędzia", "copilotPasteInto": "Wklej do:", "wireApiChatCompletions": "Zakończenia czatu (/chat/uzupełnienia)", - "wireApiResponses": "API odpowiedzi (/response)" + "wireApiResponses": "API odpowiedzi (/response)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Kombinacje", @@ -2822,6 +2832,8 @@ "range7d": "7 dni", "range30d": "30 dni", "range90d": "90 dni", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Cały czas", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "Dostawcy kluczy API", "compatibleProviders": "Dostawcy obsługujący klucz API", "testAll": "Przetestuj wszystko", + "reorderByAvailability": "Zmień kolejność", + "reorderByAvailabilityTitle": "Uporządkuj połączenia według dostępności", + "reorderByAvailabilityError": "Nie udało się uporządkować połączeń według dostępności", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Ustawienia", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 90570b882f..6a1dfae0ed 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -2332,7 +2332,17 @@ "copilotToolCalling": "Chamada de ferramenta", "copilotPasteInto": "Cole em:", "wireApiChatCompletions": "Conclusões de bate-papo (/chat/completions)", - "wireApiResponses": "API de respostas (/respostas)" + "wireApiResponses": "API de respostas (/respostas)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2853,6 +2863,8 @@ "range7d": "7D", "range30d": "30D", "range90d": "90D", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Tudo", "spend30d": "Gasto 30D", "activeModels": "Modelos Ativos", @@ -3917,7 +3929,9 @@ "label": "Reserva e especialidade", "description": "Endpoints hospedados localmente ou especializados usados como substitutos." }, - "configure": "Configurar provedores" + "configure": "Configurar provedores", + "flowCaption": "As requisições passam primeiro pelas suas cotas de assinatura, depois pelos provedores baratos por token e, por fim, pelos provedores gratuitos — automático, sem configuração.", + "afterSetup": "após a configuração." }, "tierFlowDiagramAlt": "Diagrama de fallback de 3 camadas do OmniRoute", "apiKeyMgmt": "Ger. de Chaves API" @@ -3984,6 +3998,9 @@ "apiKeyProviders": "Provedores por Chave de API", "compatibleProviders": "Provedores Compatíveis por Chave de API", "testAll": "Testar Todos", + "reorderByAvailability": "Reordenar", + "reorderByAvailabilityTitle": "Reordenar conexões por disponibilidade", + "reorderByAvailabilityError": "Falha ao reordenar conexões por disponibilidade", "distributeProxies": "Distribuir proxies", "distributing": "Distribuindo...", "selectedCount": "{count, plural, one {# selecionada} other {# selecionadas}}", @@ -4891,7 +4908,9 @@ "overrideBaseUrlAdvanced": "Avançado: sobrescrever URL base", "overrideBaseUrlHint": "Avançado: aponta este provedor embutido para um endpoint personalizado. Deixe em branco para usar o padrão.", "bulkAddFormatHintCloudflare": "Uma chave por linha. Formato: nome|accountId|apiKey (ID de conta Cloudflare + token de API).", - "lmarenaWebCookieHint": "Abra arena.ai, faça login e depois copie o cabeçalho Cookie completo de uma requisição de rede. Inclua arena-auth-prod-v1.0 e arena-auth-prod-v1.1 (e outros fragmentos, se houver), preferencialmente com cf_clearance. Não cole apenas o cookie vazio arena-auth-prod-v1. Opcional: providerSpecificData.recaptchaV3Token se create-evaluation ainda retornar 403." + "lmarenaWebCookieHint": "Abra arena.ai, faça login e depois copie o cabeçalho Cookie completo de uma requisição de rede. Inclua arena-auth-prod-v1.0 e arena-auth-prod-v1.1 (e outros fragmentos, se houver), preferencialmente com cf_clearance. Não cole apenas o cookie vazio arena-auth-prod-v1. Opcional: providerSpecificData.recaptchaV3Token se create-evaluation ainda retornar 403.", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Configurações", @@ -5591,6 +5610,7 @@ "webSearchRouteTitle": "__MISSING__:Web search routing", "webSearchRouteDesc": "__MISSING__:When a request includes a native web_search tool, route the whole request to this model instead of the default — useful for providers that don't implement Anthropic's web_search server tool. Leave blank to disable.", "webSearchRoutePlaceholder": "__MISSING__:e.g. openrouter,anthropic/claude-3.5-sonnet", + "paidModelPatternWarning": "Este padrão corresponde apenas a modelos pagos — habilite modelos pagos ou ajuste o padrão.", "clearLkgpCache": "Clear LKGP Cache", "lkgpCacheCleared": "LKGP cache cleared successfully", "lkgpCacheClearFailed": "Failed to clear LKGP cache", @@ -5911,6 +5931,7 @@ "retentionCallLogs": "Registros de chamadas (dias)", "retentionUsageHistory": "Histórico de uso (dias)", "retentionMemoryEntries": "Entradas de memória (dias)", + "retentionXpAuditLog": "Log de Auditoria de XP (dias)", "saveRetentionSettings": "__MISSING__:Save retention settings", "storageAutoVacuumMode": "Modo de vácuo automático", "storageScheduledVacuum": "Vácuo programado", @@ -6225,6 +6246,11 @@ "routingStrategyTitle": "Estratégia de Roteamento", "searchModelOverrideTargets": "Buscar provedor/modelo...", "selectedModel": "Modelo selecionado", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}", "proxyFreePoolSearchPlaceholder": "Buscar host…", "proxyFreePoolSearchLabel": "Buscar proxies do pool gratuito", "proxyFreePoolSortLabel": "Ordenar por", @@ -9166,7 +9192,14 @@ "configuredOnly": "Somente configurados", "configuredOnlyHint": "Mostrar apenas provedores com conexões ativas", "noConfiguredProviders": "Nenhum provedor configurado encontrado. Adicione uma conexão de provedor primeiro.", - "colConfigured": "Status" + "colConfigured": "Status", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" }, "discovery": { "title": "Descoberta de provedores", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 03b1702b34..d22548f16e 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "Chamada de ferramenta", "copilotPasteInto": "Cole em:", "wireApiChatCompletions": "Conclusões de bate-papo (/chat/completions)", - "wireApiResponses": "API de respostas (/respostas)" + "wireApiResponses": "API de respostas (/respostas)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2822,6 +2832,8 @@ "range7d": "7 dias", "range30d": "30 dias", "range90d": "90 dias", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Todos os tempos", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "Provedores de chaves de API", "compatibleProviders": "Provedores compatíveis com chave de API", "testAll": "Teste tudo", + "reorderByAvailability": "Reordenar", + "reorderByAvailabilityTitle": "Reordenar ligações por disponibilidade", + "reorderByAvailabilityError": "Falha ao reordenar ligações por disponibilidade", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Configurações", @@ -5810,6 +5827,7 @@ "retentionCallLogs": "Registros de chamadas (dias)", "retentionUsageHistory": "Histórico de uso (dias)", "retentionMemoryEntries": "Entradas de memória (dias)", + "retentionXpAuditLog": "Log de Auditoria de XP (dias)", "saveRetentionSettings": "__MISSING__:Save retention settings", "storageAutoVacuumMode": "Modo de vácuo automático", "storageScheduledVacuum": "Vácuo programado", @@ -6066,7 +6084,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8965,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index bc7c66c30a..ad9169c3a9 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "Apelarea instrumentului", "copilotPasteInto": "Lipiți în:", "wireApiChatCompletions": "Finalizări de chat (/chat/completions)", - "wireApiResponses": "API-ul Responses (/responses)" + "wireApiResponses": "API-ul Responses (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combo-uri", @@ -2822,6 +2832,8 @@ "range7d": "7 zile", "range30d": "30 de zile", "range90d": "90 de zile", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Tot timpul", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "Furnizori de chei API", "compatibleProviders": "Furnizori compatibili cu cheile API", "testAll": "Testează toate", + "reorderByAvailability": "Reordonare", + "reorderByAvailabilityTitle": "Reordonează conexiunile după disponibilitate", + "reorderByAvailabilityError": "Reordonarea conexiunilor după disponibilitate a eșuat", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Setări", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 9d5ce7ff83..fb0ca14b05 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "Вызов инструмента", "copilotPasteInto": "Вставить в:", "wireApiChatCompletions": "Завершения чата (/chat/completions)", - "wireApiResponses": "API ответов (/responses)" + "wireApiResponses": "API ответов (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Комбо", @@ -2822,6 +2832,8 @@ "range7d": "7 дней", "range30d": "30 дней", "range90d": "90 дней", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Все время", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "Поставщики ключей API", "compatibleProviders": "Поставщики, совместимые с ключами API", "testAll": "Проверить все", + "reorderByAvailability": "Изменить порядок", + "reorderByAvailabilityTitle": "Изменить порядок подключений по доступности", + "reorderByAvailabilityError": "Не удалось изменить порядок подключений по доступности", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "Kimi Web", "kimiWebDesc": "Чат Moonshot AI через www.kimi.com (международная версия, Connect-RPC API)", "doubaoWebLabel": "Doubao Web", - "doubaoWebDesc": "Чат AI ByteDance через doubao.com" + "doubaoWebDesc": "Чат AI ByteDance через doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Настройки", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index cef7da95ae..26d360bf0b 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "Vyvolanie nástroja", "copilotPasteInto": "Vložiť do:", "wireApiChatCompletions": "Dokončenia rozhovoru (/chat/completions)", - "wireApiResponses": "Responses API (/responses)" + "wireApiResponses": "Responses API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "kombá", @@ -2822,6 +2832,8 @@ "range7d": "7 dní", "range30d": "30 dní", "range90d": "90 dní", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Všetky časy", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "Poskytovatelia kľúčov API", "compatibleProviders": "Poskytovatelia kompatibilných s kľúčom API", "testAll": "Testovať všetko", + "reorderByAvailability": "Preusporiadať", + "reorderByAvailabilityTitle": "Preusporiadať pripojenia podľa dostupnosti", + "reorderByAvailabilityError": "Preusporiadanie pripojení podľa dostupnosti zlyhalo", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Nastavenia", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 5546416c16..0a401e68b6 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -2320,7 +2320,17 @@ "copilotToolCalling": "Verktygsanrop", "copilotPasteInto": "Klistra in i:", "wireApiChatCompletions": "Chattavslut (/chat/kompletteringar)", - "wireApiResponses": "Responses API (/responses)" + "wireApiResponses": "Responses API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2822,6 +2832,8 @@ "range7d": "7 dagar", "range30d": "30 dagar", "range90d": "90 dagar", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Hela tiden", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3948,6 +3960,9 @@ "apiKeyProviders": "API-nyckelleverantörer", "compatibleProviders": "API-nyckelkompatibla leverantörer", "testAll": "Testa alla", + "reorderByAvailability": "Ordna om", + "reorderByAvailabilityTitle": "Ordna om anslutningar efter tillgänglighet", + "reorderByAvailabilityError": "Det gick inte att ordna om anslutningar efter tillgänglighet", "distributeProxies": "__MISSING__:Distribute Proxies", "distributing": "__MISSING__:Distributing...", "selectedCount": "__MISSING__:{count, plural, one {# selected} other {# selected}}", @@ -4820,7 +4835,9 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Inställningar", @@ -6066,7 +6083,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -8942,6 +8964,13 @@ "colScore": "__MISSING__:Score", "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", - "colType": "__MISSING__:Type" + "colType": "__MISSING__:Type", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 0fbc01c4ea..bb44e2e27b 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Kupiga simu kwa zana", "copilotPasteInto": "Bandika kwenye:", "wireApiChatCompletions": "Kukamilika kwa Gumzo (/kuzungumza/kukamilika)", - "wireApiResponses": "API ya majibu (/majibu)" + "wireApiResponses": "API ya majibu (/majibu)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2716,6 +2726,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "Panga upya", + "reorderByAvailabilityTitle": "Panga upya miunganisho kulingana na upatikanaji", + "reorderByAvailabilityError": "Imeshindwa kupanga upya miunganisho kulingana na upatikanaji", "testAllOAuth": "Test all OAuth connections", "testAllFree": "Test all Free connections", "testAllApiKey": "Test all API Key connections", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Settings", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 7ef1611dfc..c1e7eeee29 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "கருவி அழைப்பு", "copilotPasteInto": "இதில் ஒட்டவும்:", "wireApiChatCompletions": "அரட்டை நிறைவுகள் (/அரட்டை/நிறைவுகள்)", - "wireApiResponses": "பதில்கள் API (/பதில்கள்)" + "wireApiResponses": "பதில்கள் API (/பதில்கள்)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2716,6 +2726,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "மறுவரிசைப்படுத்து", + "reorderByAvailabilityTitle": "கிடைக்கும் தன்மையின் அடிப்படையில் இணைப்புகளை மறுவரிசைப்படுத்தவும்", + "reorderByAvailabilityError": "கிடைக்கும் தன்மையின் அடிப்படையில் இணைப்புகளை மறுவரிசைப்படுத்த முடியவில்லை", "testAllOAuth": "Test all OAuth connections", "testAllFree": "Test all Free connections", "testAllApiKey": "Test all API Key connections", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Settings", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index ab3c4529e8..84113a8d1b 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "టూల్ కాలింగ్", "copilotPasteInto": "దీనిలో అతికించండి:", "wireApiChatCompletions": "చాట్ పూర్తిలు (/చాట్/పూర్తి)", - "wireApiResponses": "ప్రతిస్పందనల API (/స్పందనలు)" + "wireApiResponses": "ప్రతిస్పందనల API (/స్పందనలు)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2716,6 +2726,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "మళ్లీ క్రమం చేయండి", + "reorderByAvailabilityTitle": "లభ్యత ఆధారంగా కనెక్షన్‌లను మళ్లీ క్రమం చేయండి", + "reorderByAvailabilityError": "లభ్యత ఆధారంగా కనెక్షన్‌లను మళ్లీ క్రమం చేయడంలో విఫలమైంది", "testAllOAuth": "Test all OAuth connections", "testAllFree": "Test all Free connections", "testAllApiKey": "Test all API Key connections", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Settings", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index d47469ebdc..747d98eb06 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "การเรียกเครื่องมือ", "copilotPasteInto": "วางลงใน:", "wireApiChatCompletions": "เสร็จสิ้นการแชท (/แชท/เสร็จสิ้น)", - "wireApiResponses": "API การตอบกลับ (/การตอบกลับ)" + "wireApiResponses": "API การตอบกลับ (/การตอบกลับ)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "คอมโบ", @@ -2716,6 +2726,8 @@ "range7d": "7 วัน", "range30d": "30 วัน", "range90d": "90 วัน", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "ตลอดเวลา", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "ผู้ให้บริการคีย์ API", "compatibleProviders": "ผู้ให้บริการที่เข้ากันได้กับคีย์ API", "testAll": "ทดสอบทั้งหมด", + "reorderByAvailability": "จัดลำดับใหม่", + "reorderByAvailabilityTitle": "จัดลำดับการเชื่อมต่อใหม่ตามความพร้อมใช้งาน", + "reorderByAvailabilityError": "จัดลำดับการเชื่อมต่อใหม่ตามความพร้อมใช้งานไม่สำเร็จ", "testAllOAuth": "ทดสอบการเชื่อมต่อ OAuth ทั้งหมด", "testAllFree": "ทดสอบการเชื่อมต่อฟรีทั้งหมด", "testAllApiKey": "ทดสอบการเชื่อมต่อคีย์ API ทั้งหมด", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "การตั้งค่า", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index decf8e8161..4f1eed479a 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Takım Çağırma", "copilotPasteInto": "Şuraya yapıştırın:", "wireApiChatCompletions": "Sohbet Tamamlamaları (/sohbet/tamamlamalar)", - "wireApiResponses": "Yanıtlar API'si (/yanıtlar)" + "wireApiResponses": "Yanıtlar API'si (/yanıtlar)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Kombolar", @@ -2716,6 +2726,8 @@ "range7d": "7 Gün", "range30d": "30 Gün", "range90d": "90 Gün", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Tüm Zamanlar", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API Anahtarı Sağlayıcıları", "compatibleProviders": "API Anahtarı Uyumlu Sağlayıcılar", "testAll": "Tümünü Test Et", + "reorderByAvailability": "Yeniden sırala", + "reorderByAvailabilityTitle": "Bağlantıları kullanılabilirliğe göre yeniden sırala", + "reorderByAvailabilityError": "Bağlantılar kullanılabilirliğe göre yeniden sıralanamadı", "testAllOAuth": "Tüm OAuth bağlantılarını test et", "testAllFree": "Tüm ücretsiz bağlantıları test et", "testAllApiKey": "Tüm API anahtarı bağlantılarını test et", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Ayarlar", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index ecf354f028..d199f009f9 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Виклик інструменту", "copilotPasteInto": "Вставити в:", "wireApiChatCompletions": "Завершення чату (/chat/completions)", - "wireApiResponses": "Responses API (/responses)" + "wireApiResponses": "Responses API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Комбо", @@ -2716,6 +2726,8 @@ "range7d": "7 днів", "range30d": "30 днів", "range90d": "90 днів", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "Весь час", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "Постачальники ключів API", "compatibleProviders": "Сумісні постачальники ключів API", "testAll": "Перевірити все", + "reorderByAvailability": "Змінити порядок", + "reorderByAvailabilityTitle": "Змінити порядок підключень за доступністю", + "reorderByAvailabilityError": "Не вдалося змінити порядок підключень за доступністю", "testAllOAuth": "Перевірте всі підключення OAuth", "testAllFree": "Перевірте всі безкоштовні підключення", "testAllApiKey": "Перевірте всі підключення ключів API", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Налаштування", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Швидкий старт", "homeQuickStartDesc": "Показувати панель швидкого старту на головній сторінці.", "homeProviderTopology": "Топологія провайдерів", - "homeProviderTopologyDesc": "Показувати топологію провайдерів на головній сторінці." + "homeProviderTopologyDesc": "Показувати топологію провайдерів на головній сторінці.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "Двигун RTK", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index c951197359..bcde85f1b1 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "ٹول کالنگ", "copilotPasteInto": "اس میں پیسٹ کریں:", "wireApiChatCompletions": "چیٹ کی تکمیل (/چیٹ/کمپلیشنز)", - "wireApiResponses": "Responses API (/responses)" + "wireApiResponses": "Responses API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combos", @@ -2716,6 +2726,8 @@ "range7d": "7 Days", "range30d": "30 Days", "range90d": "90 Days", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "All Time", "spend30d": "Spend 30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "API Key Providers", "compatibleProviders": "API Key Compatible Providers", "testAll": "Test All", + "reorderByAvailability": "دوبارہ ترتیب دیں", + "reorderByAvailabilityTitle": "دستیابی کے مطابق کنکشنز کو دوبارہ ترتیب دیں", + "reorderByAvailabilityError": "دستیابی کے مطابق کنکشنز کو دوبارہ ترتیب دینے میں ناکامی", "testAllOAuth": "Test all OAuth connections", "testAllFree": "Test all Free connections", "testAllApiKey": "Test all API Key connections", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Settings", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 35256b4cf3..76194e44a4 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -2218,7 +2218,17 @@ "copilotToolCalling": "Gọi công cụ", "copilotPasteInto": "Dán vào:", "wireApiChatCompletions": "Số lần hoàn thành trò chuyện (/chat/hoàn thành)", - "wireApiResponses": "API phản hồi (/ phản hồi)" + "wireApiResponses": "API phản hồi (/ phản hồi)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "Combo", @@ -2716,6 +2726,8 @@ "range7d": "7 ngày", "range30d": "30 ngày", "range90d": "90 ngày", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "mọi lúc", "spend30d": "Spend30D", "activeModels": "Active Models", @@ -3652,6 +3664,9 @@ "apiKeyProviders": "Nhà cung cấp khóa API", "compatibleProviders": "Nhà cung cấp tương thích với khóa API", "testAll": "Kiểm tra tất cả", + "reorderByAvailability": "Sắp xếp lại", + "reorderByAvailabilityTitle": "Sắp xếp lại các kết nối theo tình trạng khả dụng", + "reorderByAvailabilityError": "Không thể sắp xếp lại các kết nối theo tình trạng khả dụng", "testAllOAuth": "Kiểm tra tất cả các kết nối OAuth", "testAllFree": "Kiểm tra tất cả các kết nối miễn phí", "testAllApiKey": "Kiểm tra tất cả các kết nối Khóa API", @@ -4427,7 +4442,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "Cài đặt", @@ -5517,7 +5534,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", @@ -7618,5 +7640,14 @@ "enabled": "Enabled", "disabled": "Disabled", "hooks": "Hooks" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 0e9ce6949b..5268cfccf5 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2303,7 +2303,17 @@ "copilotToolCalling": "工具调用", "copilotPasteInto": "粘贴到:", "wireApiChatCompletions": "聊天完成 (/chat/completions)", - "wireApiResponses": "响应 API (/responses)" + "wireApiResponses": "响应 API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "组合", @@ -2801,6 +2811,8 @@ "range7d": "7 天", "range30d": "30 天", "range90d": "90 天", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "全部时间", "spend30d": "30 天支出", "activeModels": "活跃 Model", @@ -3890,6 +3902,9 @@ "apiKeyProviders": "API 密钥提供商", "compatibleProviders": "API 密钥兼容提供商", "testAll": "测试全部", + "reorderByAvailability": "重新排序", + "reorderByAvailabilityTitle": "按可用性重新排序连接", + "reorderByAvailabilityError": "按可用性重新排序连接失败", "testAllOAuth": "测试所有 OAuth 连接", "testAllFree": "测试所有免费连接", "testAllApiKey": "测试所有 API 密钥连接", @@ -4729,7 +4744,9 @@ "contextWindowOverrideLabel": "__MISSING__:Context Window Override", "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", - "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "设置", @@ -5901,7 +5918,12 @@ "responsesStateModeStrip": "剥离", "responsesStateModePreserve": "保留", "responsesStateHint": "自动模式会剥离 previous_response_id,除非连接明确启用 OpenAI 响应存储。对于无状态客户端(如 VS Code 自定义端点),剥离模式最安全;上下文取决于客户端发送完整历史记录。", - "responsesStateSaveError": "更新响应状态设置失败" + "responsesStateSaveError": "更新响应状态设置失败", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK 引擎", @@ -8689,5 +8711,14 @@ "regenerateRunning": "正在重新生成技能…", "regenerateSuccess": "技能成功重生。", "regenerateError": "无法重新生成技能。" + }, + "freeProviderRankingsPage": { + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 67b3bd3048..1f26131863 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2326,7 +2326,17 @@ "copilotToolCalling": "工具呼叫", "copilotPasteInto": "貼上到:", "wireApiChatCompletions": "聊天完成 (/chat/completions)", - "wireApiResponses": "響應 API (/responses)" + "wireApiResponses": "響應 API (/responses)", + "reasoningEffort": "__MISSING__:Reasoning effort for {model}", + "reasoningEffortDefault": "__MISSING__:Default", + "reasoningEffortHint": "__MISSING__:Default preserves the reasoning effort sent by the agent", + "reasoningEffortTier": { + "none": "__MISSING__:None", + "low": "__MISSING__:Low", + "medium": "__MISSING__:Medium", + "high": "__MISSING__:High", + "xhigh": "__MISSING__:XHigh" + } }, "combos": { "title": "組合", @@ -2847,6 +2857,8 @@ "range7d": "7 天", "range30d": "30 天", "range90d": "90 天", + "range180d": "180 Days", + "range365d": "365 Days", "rangeAll": "全部時間", "spend30d": "30 天支出", "activeModels": "活躍 Model", @@ -3978,6 +3990,9 @@ "apiKeyProviders": "API 金鑰提供商", "compatibleProviders": "API 金鑰相容提供商", "testAll": "測試全部", + "reorderByAvailability": "重新排序", + "reorderByAvailabilityTitle": "依可用性重新排序連線", + "reorderByAvailabilityError": "依可用性重新排序連線失敗", "distributeProxies": "分配代理", "distributing": "分配中...", "selectedCount": "已選 {count} 個", @@ -4855,7 +4870,9 @@ "doubaoWebDesc": "通過 dola.com 訪問字節跳動 AI 聊天", "overrideBaseUrlAdvanced": "__MISSING__:Advanced: override base URL", "overrideBaseUrlHint": "__MISSING__:Advanced: point this built-in provider at a custom endpoint. Leave blank to use the default.", - "bulkAddFormatHintCloudflare": "__MISSING__:One key per line. Format: name|accountId|apiKey (Cloudflare account ID + API token)." + "bulkAddFormatHintCloudflare": "__MISSING__:One key per line. Format: name|accountId|apiKey (Cloudflare account ID + API token).", + "visionCapableLabel": "__MISSING__:Vision capable", + "visionCapableHint": "__MISSING__:Manually flag this model as vision-capable when the provider's discovery metadata doesn't report an image input modality (common for self-hosted/local backends)." }, "settings": { "title": "設定", @@ -6158,7 +6175,12 @@ "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", "disableSessionStickiness": "__MISSING__:Disable session stickiness", - "disableSessionStickinessDesc": "__MISSING__: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." + "disableSessionStickinessDesc": "__MISSING__: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.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK 引擎", @@ -9051,7 +9073,14 @@ "configuredOnly": "__MISSING__:Configured Only", "configuredOnlyHint": "__MISSING__:Show only providers with active connections", "noConfiguredProviders": "__MISSING__:No configured providers found. Add a provider connection first.", - "colConfigured": "__MISSING__:Status" + "colConfigured": "__MISSING__:Status", + "typeAll": "__MISSING__:All Types", + "typeNoauth": "__MISSING__:No Signup", + "typeOauth": "__MISSING__:OAuth Login", + "typeApikey": "__MISSING__:API Key", + "sortTypeFirst": "__MISSING__:Easiest first", + "sortTypeFirstHelp": "__MISSING__:Group by signup effort (No Signup → OAuth Login → API Key), keeping quality order within each group", + "typeLegend": "__MISSING__:No Signup = zero setup · OAuth Login = sign in with your own account · API Key = bring your own key or that provider's free tier" }, "discovery": { "title": "__MISSING__:Provider Discovery", diff --git a/src/i18n/request.ts b/src/i18n/request.ts index a144acedbf..19f053ed47 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -5,10 +5,25 @@ import type { Locale } from "./config"; const FALLBACK_LOCALE = "en"; +/** + * Sentinel prefix written by `scripts/i18n/sync-ui-keys.mjs` when backfilling a + * locale file with an untranslated key: `__MISSING__:`. Kept in + * sync manually with the scripts (plain .mjs, no shared TS module) — see + * `scripts/i18n/sync-ui-keys.mjs` and `scripts/i18n/check-ui-keys-coverage.mjs`. + */ +export const PLACEHOLDER_PREFIX = "__MISSING__:"; + +function isUntranslatedPlaceholder(value: unknown): boolean { + return typeof value === "string" && value.startsWith(PLACEHOLDER_PREFIX); +} + /** * Deep merge that mutates `target` with values from `source`. * If both have an object at the same key, recurse. - * Otherwise prefer the existing value in `target` (locale-specific wins). + * Otherwise prefer the existing value in `target` (locale-specific wins) — + * unless the target value is an untranslated `__MISSING__:` sentinel written + * by the i18n sync script, in which case it is treated as absent so the + * clean English fallback value wins instead (#7258). */ export function deepMergeFallback( target: Record, @@ -27,7 +42,7 @@ export function deepMergeFallback( !Array.isArray(targetValue) ) { deepMergeFallback(targetValue as Record, sourceValue as Record); - } else if (targetValue === undefined) { + } else if (targetValue === undefined || isUntranslatedPlaceholder(targetValue)) { target[key] = sourceValue; } } diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 4cc92e1d7f..d32fd67f1a 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -153,6 +153,19 @@ export async function registerNodejs(): Promise { await import("@omniroute/open-sse/index.ts"); console.log("[STARTUP] Global fetch proxy patch initialized"); + // Guarantee the SQLite singleton — including a sql.js WASM pre-init when + // both synchronous drivers (better-sqlite3, node:sqlite) are unavailable — + // is ready before ANY other startup step reaches getDbInstance(). This + // MUST run before ensureSecrets, clearStaleCrashCooldowns, + // getSettings, initAuditLog below: those all reach getDbInstance() + // transitively, and used to run ahead of this call (previously at the end + // of this function), throwing the misleading "sql.js WASM ainda não foi + // pré-inicializado" error for an existing DB file when both sync drivers + // failed (#7288 / #7494). ensureDbInitialized() itself is idempotent and + // caches the singleton, so every later getDbInstance() call below is a + // free no-op re-read of the same connection — no double-init cost. + await ensureDbReadyForBoot(); + await ensureSecrets(); const { enforceWebRuntimeEnv } = await import("@/lib/env/runtimeEnv"); enforceWebRuntimeEnv(); @@ -228,6 +241,9 @@ export async function registerNodejs(): Promise { console.log("[STARTUP] Quota cache background refresh started"); startProviderLimitsSyncScheduler(); console.log("[STARTUP] Provider limits sync scheduler started"); + const { startQuotaAutoPing } = await import("@/lib/services/quotaAutoPing"); + startQuotaAutoPing(); + console.log("[STARTUP] Quota auto-ping scheduler started (opt-in, no-op until enabled)"); const cloudSyncInitialized = await ensureCloudSyncInitialized(); console.log( `[STARTUP] Cloud/model sync background bootstrap ${cloudSyncInitialized ? "initialized" : "skipped"}` @@ -331,8 +347,6 @@ export async function registerNodejs(): Promise { console.warn("[COMPLIANCE] Could not initialize audit log:", msg); } - await ensureDbReadyForBoot(); - // Storage-configured scheduled VACUUM (#4437): registers the timer from // Settings > System & Storage and persists lastVacuumAt for the UI. try { diff --git a/src/lib/cli-helper/tool-detector.ts b/src/lib/cli-helper/tool-detector.ts index b6463132ca..48b03bb555 100644 --- a/src/lib/cli-helper/tool-detector.ts +++ b/src/lib/cli-helper/tool-detector.ts @@ -3,24 +3,24 @@ import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { getCurrentHermesAgentRoles } from "./config-generator/hermes-agent"; -import { getCachedLoginShellPath, mergeShellPath } from "../../shared/services/loginShellPath"; +import { + getLookupEnv, + locateCommand, + shouldUseShellForCommand, +} from "../../shared/services/cliRuntime"; const execFileAsync = promisify(execFile); let execFileImpl = execFileAsync; - -// #3321: macOS GUI/Electron truncates PATH, so `which`/`--version` probes miss Homebrew/ -// nvm/volta CLIs and the doctor reports them "not installed". Build a lookup env enriched -// with the login-shell PATH (darwin-only, cached, fail-safe → returns process.env elsewhere). -function detectorEnv(): NodeJS.ProcessEnv { - const loginShellPath = getCachedLoginShellPath(); - if (!loginShellPath) return process.env; - return { ...process.env, PATH: mergeShellPath(process.env.PATH || "", loginShellPath) }; -} +let locateCommandImpl = locateCommand; export function __setExecFileImpl(fn: typeof execFileAsync): void { execFileImpl = fn; } +export function __setLocateCommandImpl(fn: typeof locateCommand): void { + locateCommandImpl = fn; +} + export interface DetectedTool { id: string; name: string; @@ -79,17 +79,52 @@ function isConfigured(content: string, baseUrl: string): boolean { ); } +// #968/#7279: on native Windows, npm installs CLI wrappers (claude/codex/opencode/…) +// as .cmd/.bat shims. Node's CVE-2024-27980 hardening makes execFile()/spawn() reject +// those without `shell: true`, and the `which` fallback below doesn't exist natively +// on Windows (no WSL/git-bash) — so both probes threw, both were swallowed, and an +// installed CLI was reported as absent. Reuse cliRuntime.ts's `locateCommand` +// (already win32-aware since #968: `where.exe` + `.cmd`/`.exe`/`.bat`/`.com` +// preference) for existence/path, then probe `--version` with `shell: true` when the +// resolved binary needs it. If this drifts again, check cliRuntime.ts first. +async function detectBinaryWindows( + binary: string, + env: NodeJS.ProcessEnv +): Promise<{ installed: boolean; version?: string }> { + const located = await locateCommandImpl(binary, env); + if (!located.installed || !located.commandPath) return { installed: false }; + + try { + const useShell = shouldUseShellForCommand(located.commandPath); + const { stdout } = await execFileImpl(located.commandPath, ["--version"], { + timeout: 5000, + env, + ...(useShell ? { shell: true } : {}), + }); + return { installed: true, version: stdout.trim().replace(/^v/, "") }; + } catch { + // Binary exists on PATH but the --version probe failed (unusual flag, slow + // startup, etc.) — still report it as installed since locateCommand confirmed it. + return { installed: true }; + } +} + async function detectBinary(name: string): Promise<{ installed: boolean; version?: string }> { const binary = BINARY_NAMES[name] || name; - const env = detectorEnv(); + const env = getLookupEnv(); + + if (process.platform === "win32") { + return detectBinaryWindows(binary, env); + } + try { const { stdout } = await execFileImpl(binary, ["--version"], { timeout: 5000, env }); const version = stdout.trim().replace(/^v/, ""); return { installed: true, version }; } catch { try { - // Try `which` as fallback - const { stdout } = await execFileAsync("which", [binary], { timeout: 5000, env }); + // Try `which` as fallback (routed through execFileImpl so it stays mockable) + const { stdout } = await execFileImpl("which", [binary], { timeout: 5000, env }); if (stdout.trim()) { return { installed: true }; } diff --git a/src/lib/combos/builderDraft.ts b/src/lib/combos/builderDraft.ts index 1638f1b35c..18e6a09e31 100644 --- a/src/lib/combos/builderDraft.ts +++ b/src/lib/combos/builderDraft.ts @@ -20,6 +20,33 @@ export function isIntelligentBuilderStrategy(strategy: unknown): boolean { return strategy === "auto" || strategy === "lkgp"; } +export type ComboEligibleConnectionLike = { + isActive?: boolean | null; + testStatus?: string | null; +}; + +/** + * Whether a provider connection should be treated as eligible for the combo + * builder's "active providers" list (used to decide which providers get their + * models fetched/shown when creating or editing a combo). + * + * Newly-created connections default `testStatus` to `null` until someone + * explicitly runs a connection test (`src/lib/db/providers.ts`). Excluding + * those from the combo builder meant a freshly-added custom provider's models + * never populated the combo model picker until an operator manually tested + * the connection — matching the reported symptom (#2057). "Never tested" is + * therefore treated the same as "known good", consistent with + * `deriveConnectionStatus` in `src/lib/combos/builderOptions.ts`, which only + * flags a connection as an error when `testStatus` explicitly matches + * `/error|fail/i`. + */ +export function isEligibleActiveConnection(connection: ComboEligibleConnectionLike): boolean { + if (connection.isActive === false) return false; + const testStatus = connection.testStatus; + if (!testStatus) return true; + return testStatus === "active" || testStatus === "success" || testStatus === "unknown"; +} + export function getComboBuilderStages(options: ComboBuilderStageOptions = {}): ComboBuilderStage[] { if (isIntelligentBuilderStrategy(options.strategy)) { return [...COMBO_BUILDER_STAGES]; diff --git a/src/lib/db/adapters/driverFactory.ts b/src/lib/db/adapters/driverFactory.ts index 2d95bc7306..1e2a29f250 100644 --- a/src/lib/db/adapters/driverFactory.ts +++ b/src/lib/db/adapters/driverFactory.ts @@ -8,9 +8,22 @@ import type { SqliteAdapter } from "./types"; const _require = createRequire(import.meta.url); +/** + * Logs the underlying cause of a swallowed sync-driver failure (#7288 + * secondary finding). tryOpenSync() used to swallow both driver errors in + * empty catch {} blocks, so an ABI mismatch or permission error never + * reached the logs — only the generic "(falhou)"/"(indisponível)" strings + * in core.ts's thrown message survived, making the failure undiagnosable. + */ +function logSwallowedDriverError(driver: string, err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + console.debug(`[DB] Sync driver '${driver}' failed to open, will try next driver: ${message}`); +} + declare global { var __omnirouteSqlJsAdapters: Map | undefined; var __omnirouteSqlJsInitPromises: Map> | undefined; + var __omnirouteSqlJsPreInitErrors: Map | undefined; } function getSqlJsCache(): Map { @@ -20,6 +33,24 @@ function getSqlJsCache(): Map { return globalThis.__omnirouteSqlJsAdapters; } +function getSqlJsPreInitErrorCache(): Map { + if (!globalThis.__omnirouteSqlJsPreInitErrors) { + globalThis.__omnirouteSqlJsPreInitErrors = new Map(); + } + return globalThis.__omnirouteSqlJsPreInitErrors; +} + +/** + * Real cause of the most recent failed preInitSqlJs() attempt for a + * filePath, if any (#7288). Lets callers replace the generic/misleading + * "sql.js WASM ainda não foi pré-inicializado" message with the actual + * reason sql.js itself couldn't open the file, once pre-init was genuinely + * attempted (as opposed to never having run at all). + */ +export function getSqlJsPreInitError(filePath: string): string | undefined { + return getSqlJsPreInitErrorCache().get(filePath); +} + /** * Cache das Promises de inicialização EM VOO (não resolvidas ainda), por filePath. * Separado de getSqlJsCache() (que só guarda o adapter já resolvido) para que @@ -47,8 +78,9 @@ export function tryOpenSync( }; const db = new BetterSqlite(filePath, options); return createBetterSqliteAdapter(db); - } catch { + } catch (err) { // continua para próximo driver + logSwallowedDriverError("better-sqlite3", err); } } @@ -62,8 +94,9 @@ export function tryOpenSync( }; const db = new DatabaseSync(filePath); return createNodeSqliteAdapterFromDatabase(db, filePath); - } catch { + } catch (err) { // continua + logSwallowedDriverError("node:sqlite", err); } } } @@ -102,11 +135,16 @@ export async function preInitSqlJs(filePath: string): Promise { const { createSqlJsAdapter } = await import("./sqljsAdapter"); const adapter = await createSqlJsAdapter(filePath); cache.set(filePath, adapter); + getSqlJsPreInitErrorCache().delete(filePath); return adapter; })(); pending.set(filePath, initPromise); try { return await initPromise; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + getSqlJsPreInitErrorCache().set(filePath, message); + throw err; } finally { pending.delete(filePath); } diff --git a/src/lib/db/cleanup.ts b/src/lib/db/cleanup.ts index 40cebb328c..5855c140bb 100644 --- a/src/lib/db/cleanup.ts +++ b/src/lib/db/cleanup.ts @@ -244,6 +244,36 @@ export async function cleanupMemoryEntries(): Promise { return result; } +/** + * Clean up old xp_audit_log based on retention settings. + */ +export async function cleanupXpAuditLog(): Promise { + const db = getDbInstance(); + const retention = getRetentionSettings(); + + const retentionDays = retention.xpAuditLog; + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + const cutoffISO = cutoffDate.toISOString(); + + const result: CleanupResult = { deleted: 0, errors: 0 }; + + try { + const stmt = db.prepare("DELETE FROM xp_audit_log WHERE created_at < ?"); + const runResult = stmt.run(cutoffISO); + result.deleted = runResult.changes; + + console.log( + `[Cleanup] Deleted ${result.deleted} xp_audit_log older than ${retentionDays} days` + ); + } catch (err: unknown) { + console.error("[Cleanup] Error cleaning xp_audit_log:", err); + result.errors++; + } + + return result; +} + /** * Run all cleanup functions if auto-cleanup is enabled. */ @@ -270,6 +300,7 @@ export async function runAutoCleanup(): Promise<{ mcpAudit: await cleanupMcpAudit(), a2aEvents: await cleanupA2aEvents(), memoryEntries: await cleanupMemoryEntries(), + xpAuditLog: await cleanupXpAuditLog(), proxyLogs: await cleanupProxyLogs(), }; diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index c9004262f8..8207b09a8a 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -9,6 +9,7 @@ import { tryOpenSync, getSqlJsAdapter, preInitSqlJs, + getSqlJsPreInitError, openDatabaseAsync, } from "./adapters/driverFactory"; import path from "path"; @@ -162,6 +163,18 @@ function openSqliteDatabase(sqliteFile: string, options?: Record, + field: string +): void { + if (!Object.prototype.hasOwnProperty.call(updates, field)) return; + if (updates[field] === null) { + delete next[field]; + return; + } + next[field] = Boolean(updates[field]); +} + export async function updateCustomModel( providerId: string, modelId: string, @@ -637,13 +660,10 @@ export async function updateCustomModel( : {}), ...(updates.isHidden !== undefined ? { isHidden: Boolean(updates.isHidden) } : {}), }; - if (Object.prototype.hasOwnProperty.call(updates, "preserveOpenAIDeveloperRole")) { - if (updates.preserveOpenAIDeveloperRole === null) { - delete next.preserveOpenAIDeveloperRole; - } else { - next.preserveOpenAIDeveloperRole = Boolean(updates.preserveOpenAIDeveloperRole); - } - } + applyTriStateBooleanOverride(next, updates, "preserveOpenAIDeveloperRole"); + // #1904: manual vision-capability override — `null` clears back to the + // id-based heuristic in getCustomVisionCapabilityFields(). + applyTriStateBooleanOverride(next, updates, "supportsVision"); if (updates.compatByProtocol !== undefined) { if (mergedCompat && compatByProtocolHasEntries(mergedCompat)) { next.compatByProtocol = mergedCompat; @@ -685,10 +705,22 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu try { const models = JSON.parse(value) as unknown; if (!Array.isArray(models)) return null; - const m = models.find((x: unknown) => { + const isIdMatch = (x: unknown, id: string): boolean => { if (!x || typeof x !== "object" || Array.isArray(x)) return false; - return (x as { id?: string }).id === modelId; - }) as JsonRecord | undefined; + return (x as { id?: string }).id === id; + }; + // #7364: exact match first; case-insensitive fallback so "glm-4.6V" resolves a + // custom model saved as "glm-4.6v" (see lookupCustomModelMeta in + // src/sse/services/model.ts for the sibling lookup this mirrors). + const m = (models.find((x: unknown) => isIdMatch(x, modelId)) ?? + models.find( + (x: unknown) => + x && + typeof x === "object" && + !Array.isArray(x) && + typeof (x as { id?: string }).id === "string" && + ((x as { id: string }).id as string).toLowerCase() === modelId.toLowerCase() + )) as JsonRecord | undefined; return m ?? null; } catch { return null; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 47a18fee9f..1217c860b3 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -495,6 +495,8 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) { proxy_enabled = @proxyEnabled, per_key_proxy_enabled = @perKeyProxyEnabled, rate_limit_overrides_json = @rateLimitOverridesJson, + last_ping_at = @lastPingAt, + last_pinged_reset_key = @lastPingedResetKey, updated_at = @updatedAt WHERE id = @id ` @@ -543,6 +545,8 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) { proxyEnabled: normalizeBooleanColumn(data.proxyEnabled, true) ? 1 : 0, perKeyProxyEnabled: normalizeBooleanColumn(data.perKeyProxyEnabled, false) ? 1 : 0, rateLimitOverridesJson: serializeJsonField(data.rateLimitOverrides), + lastPingAt: data.lastPingAt || null, + lastPingedResetKey: data.lastPingedResetKey || null, updatedAt: now, }); } diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index fa710ed54c..677ab5dbfb 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -166,6 +166,13 @@ export async function getSettings() { // (`:free` suffix, zero-price pricing, or FREE_MODEL_BUDGETS membership). Default // false preserves prior behaviour; opt-in only. hidePaidModels: false, + // #6977: Opt-in per-connection auto-ping that warms a Codex OAuth connection's + // quota window right after it resets, so the first real request doesn't land in + // a cold window. `connections` maps connection id -> enabled. Default empty map + // (nobody opted in) — the scheduler is a no-op until an operator flips a + // connection on, since pinging burns a small amount of real quota (Hard Rule #20 + // spirit: never mutate/consume on the operator's behalf by default). + codexAutoPing: { connections: {} }, }; for (const row of rows) { const record = toRecord(row); diff --git a/src/lib/freeProviderRankings.ts b/src/lib/freeProviderRankings.ts index c5055332b3..133a11ba67 100644 --- a/src/lib/freeProviderRankings.ts +++ b/src/lib/freeProviderRankings.ts @@ -13,6 +13,16 @@ import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry"; import { listModelIntelligence } from "./db/modelIntelligence"; import { getProviderConnections } from "./db/providers"; import { getCustomModels } from "./db/models"; +import type { ProviderAuthType } from "./freeProviderRankingsAuthType"; + +// Re-exported for backward-compat / same-module ergonomics (#6915) — the +// actual implementations live in `freeProviderRankingsAuthType.ts` (DB-free, +// safe to import from "use client" pages; see that file's header comment). +export type { ProviderAuthType } from "./freeProviderRankingsAuthType"; +export { + filterRankingsByAuthType, + sortRankingsAuthTypeFirst, +} from "./freeProviderRankingsAuthType"; export interface ProviderModelScore { modelId: string; @@ -29,7 +39,7 @@ export interface FreeProviderRanking { icon: string; color: string; textIcon?: string; - category: "noauth" | "oauth" | "apikey"; + category: ProviderAuthType; topModel: ProviderModelScore | null; averageScore: number; modelCount: number; @@ -45,7 +55,7 @@ function getFreeProviders() { icon: string; color: string; textIcon?: string; - category: "noauth" | "oauth" | "apikey"; + category: ProviderAuthType; }> = []; // No-auth providers are always free @@ -372,7 +382,11 @@ export async function computeFreeProviderRankings( // limit slice, so `limit` counts providers that survive the filter. let filtered = rankings; if (opts.configuredOnly || opts.availableOnly) { - const connections = (await getProviderConnections({ isActive: true })) as ConnectionState[]; + // `getProviderConnections` returns a loose JsonRecord[]; ConnectionState is a + // structural subset of it, so TS needs the explicit `unknown` hop (TS2352). + const connections = (await getProviderConnections({ + isActive: true, + })) as unknown as ConnectionState[]; filtered = filterFreeProviderRankings(rankings, connections, opts); } diff --git a/src/lib/freeProviderRankingsAuthType.ts b/src/lib/freeProviderRankingsAuthType.ts new file mode 100644 index 0000000000..38502251a1 --- /dev/null +++ b/src/lib/freeProviderRankingsAuthType.ts @@ -0,0 +1,46 @@ +/** + * freeProviderRankingsAuthType.ts — Pure Type-filter/sort helpers for the Free + * Provider Rankings page (#6915). + * + * Deliberately split out of `freeProviderRankings.ts`: that module imports + * DB-touching code (`./db/modelIntelligence`, `./db/providers`, + * `./db/models`) at module scope, so importing a runtime value from it + * (rather than only types) would pull server-only DB wiring into the + * "use client" page's bundle. This module has zero imports beyond a shared + * type, so it is safe to import from client components. + */ + +import type { FreeProviderRanking } from "./freeProviderRankings"; + +export type ProviderAuthType = "noauth" | "oauth" | "apikey"; + +const AUTH_TYPE_ORDER: Record = { + noauth: 0, + oauth: 1, + apikey: 2, +}; + +/** + * Pure filter: keep only rankings whose `category` (auth type) matches `type`. + * `type` falsy/omitted returns the input unchanged (#6915 — "All" filter state). + */ +export function filterRankingsByAuthType( + rankings: FreeProviderRanking[], + type?: ProviderAuthType | "" +): FreeProviderRanking[] { + if (!type) return rankings; + return rankings.filter((r) => r.category === type); +} + +/** + * Pure stable sort: group NOAUTH first, then OAUTH, then APIKEY. Relies on + * `Array.prototype.sort` being stable (guaranteed ES2019+, our Node engine + * range is >=22), so the existing score-descending order from + * `computeFreeProviderRankings` is preserved *within* each auth-type group + * (#6915 — "least effort" and "best quality" compose instead of fighting). + */ +export function sortRankingsAuthTypeFirst( + rankings: FreeProviderRanking[] +): FreeProviderRanking[] { + return [...rankings].sort((a, b) => AUTH_TYPE_ORDER[a.category] - AUTH_TYPE_ORDER[b.category]); +} diff --git a/src/lib/logPayloads.ts b/src/lib/logPayloads.ts index 12bda3c169..3373f18694 100644 --- a/src/lib/logPayloads.ts +++ b/src/lib/logPayloads.ts @@ -20,6 +20,21 @@ const SENSITIVE_KEYS = new Set([ type JsonRecord = Record; +/** + * True for any binary/opaque byte view (Uint8Array, Buffer, DataView, other + * typed arrays). `Array.isArray()` returns false for these, so callers that + * branch on it before recursing would otherwise fall into the generic-object + * branch and enumerate one JS property key per decoded byte (#7297). + */ +function isOpaqueBinary(value: unknown): value is ArrayBufferView { + return ArrayBuffer.isView(value); +} + +function describeOpaqueBinary(value: ArrayBufferView): string { + const byteLength = value.byteLength; + return `[binary ${byteLength} bytes]`; +} + export function cloneLogPayload(value: T): T { if (value === null || value === undefined) return value; if (typeof globalThis.structuredClone === "function") { @@ -43,6 +58,7 @@ export function normalizePayloadForLog(payload: unknown): unknown { export function redactPayload(payload: unknown): unknown { if (!payload || typeof payload !== "object") return payload; + if (isOpaqueBinary(payload)) return describeOpaqueBinary(payload); if (Array.isArray(payload)) return payload.map(redactPayload); const redacted: JsonRecord = {}; @@ -64,12 +80,15 @@ export function sanitizePayloadPII(payload: unknown): unknown { if (typeof payload === "string") { return sanitizePII(payload).text; } - if (Array.isArray(payload)) { - return payload.map(sanitizePayloadPII); - } if (!payload || typeof payload !== "object") { return payload; } + if (isOpaqueBinary(payload)) { + return describeOpaqueBinary(payload); + } + if (Array.isArray(payload)) { + return payload.map(sanitizePayloadPII); + } const sanitized: JsonRecord = {}; for (const [key, value] of Object.entries(payload)) { diff --git a/src/lib/oauth/services/kiro.ts b/src/lib/oauth/services/kiro.ts index 7023ddffa9..e90b0ca1ad 100644 --- a/src/lib/oauth/services/kiro.ts +++ b/src/lib/oauth/services/kiro.ts @@ -321,15 +321,24 @@ export class KiroService { * If that fails or no cached credentials exist, registers a dedicated OIDC client. * If registerClient() also fails, the import falls back to the shared social-auth refresh path. */ - async validateImportToken(refreshToken: string, region: string = "us-east-1") { + async validateImportToken( + refreshToken: string, + region: string = "us-east-1", + clientIdHint?: string + ) { assertValidAwsRegion(region); // Validate token format if (!refreshToken.startsWith("aorAAAAAG")) { throw new Error("Invalid token format. Token should start with aorAAAAAG..."); } - // Try to read cached clientId/clientSecret from AWS SSO cache (Builder ID tokens) - const cachedClient = await this.readCachedClientCredentials(region); + // Try to read cached clientId/clientSecret from AWS SSO cache (Builder ID tokens). + // When the caller knows the token's own clientId (#1253 — e.g. surfaced by + // auto-import from a direct `clientId` field on the token file), pass it + // through so the cache lookup can match it exactly instead of guessing via + // region + latest-expiry, which can silently adopt an unrelated stale + // client registration on hosts with multiple cached SSO sessions. + const cachedClient = await this.readCachedClientCredentials(region, clientIdHint); // Attempt 1: Try Builder ID refresh using cached credentials if (cachedClient) { @@ -397,7 +406,8 @@ export class KiroService { * the OIDC client registration step of the device code flow. */ private async readCachedClientCredentials( - region?: string + region?: string, + clientIdHint?: string ): Promise<{ clientId: string; clientSecret: string } | null> { try { const { readdir, readFile } = await import("fs/promises"); @@ -431,6 +441,18 @@ export class KiroService { } if (candidates.length === 0) return null; + // When the caller knows the token's own clientId (#1253), an exact match + // is authoritative — it identifies the one registration that can actually + // refresh this token, regardless of region or expiry. Falling through to + // the region/latest-expiry heuristic below for an unmatched hint would + // silently adopt an unrelated (and non-working) client pair. + if (clientIdHint) { + const exactMatch = candidates.find((c) => c.clientId === clientIdHint); + if (exactMatch) { + return { clientId: exactMatch.clientId, clientSecret: exactMatch.clientSecret }; + } + } + // A host can cache OIDC client registrations for several SSO sessions; // adopting the wrong pair makes the Builder ID refresh fail. Prefer a // registration whose region matches the requested import region, then — diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index ae6c2a5449..84a9bcd666 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -129,6 +129,54 @@ export function normalizeRequestDefaults( return Object.keys(normalized).length > 0 ? normalized : undefined; } +const CACHE_PASSTHROUGH_VALUES = new Set(["strip", "openai-format", "claude-format"]); + +// #6880 — per-connection prompt-cache capability override: strip unknown keys / invalid +// types, drop the sub-object entirely when nothing valid survives. +export function normalizeCacheOverride(value: unknown): JsonRecord | undefined { + const record = asRecord(value); + if (Object.keys(record).length === 0) return undefined; + + const normalized: JsonRecord = {}; + if (typeof record.supportsPromptCaching === "boolean") { + normalized.supportsPromptCaching = record.supportsPromptCaching; + } + if ( + typeof record.cacheControlPassthrough === "string" && + CACHE_PASSTHROUGH_VALUES.has(record.cacheControlPassthrough) + ) { + normalized.cacheControlPassthrough = record.cacheControlPassthrough; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +// #6880 — extracted so normalizeProviderSpecificData() stays under the +// max-lines-per-function gate: normalizes the two nested-object sub-fields +// (requestDefaults, cache) in one pass. +function normalizeNestedSubObjects( + provider: string | null | undefined, + normalized: JsonRecord +): void { + if ("requestDefaults" in normalized) { + const requestDefaults = normalizeRequestDefaults(provider, normalized.requestDefaults); + if (requestDefaults) { + normalized.requestDefaults = requestDefaults; + } else { + delete normalized.requestDefaults; + } + } + + if ("cache" in normalized) { + const cache = normalizeCacheOverride(normalized.cache); + if (cache) { + normalized.cache = cache; + } else { + delete normalized.cache; + } + } +} + export function normalizeProviderSpecificData( provider: string | null | undefined, value: unknown @@ -138,14 +186,7 @@ export function normalizeProviderSpecificData( const normalized: JsonRecord = { ...record }; - if ("requestDefaults" in normalized) { - const requestDefaults = normalizeRequestDefaults(provider, normalized.requestDefaults); - if (requestDefaults) { - normalized.requestDefaults = requestDefaults; - } else { - delete normalized.requestDefaults; - } - } + normalizeNestedSubObjects(provider, normalized); if ("openaiStoreEnabled" in normalized && typeof normalized.openaiStoreEnabled !== "boolean") { delete normalized.openaiStoreEnabled; diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts index 88bb6d85d8..cb03319d24 100644 --- a/src/lib/providers/staticModels.ts +++ b/src/lib/providers/staticModels.ts @@ -8,6 +8,7 @@ import { } from "@omniroute/open-sse/config/audioRegistry.ts"; import { ANTIGRAVITY_PUBLIC_MODELS } from "@omniroute/open-sse/config/antigravityModelAliases.ts"; import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts"; +import { getSearchProvider } from "@omniroute/open-sse/config/searchRegistry.ts"; import { getModelsByProviderId } from "@/shared/constants/models"; @@ -115,12 +116,48 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str ], }; +const SEARCH_TYPE_LABELS: Record = { + web: "Web Search", + news: "News Search", +}; + +function formatSearchTypeLabel(searchType: string): string { + return ( + SEARCH_TYPE_LABELS[searchType] ?? + `${searchType.charAt(0).toUpperCase()}${searchType.slice(1)} Search` + ); +} + +/** + * Search providers don't have "models" — a provider IS the model (see + * open-sse/config/searchRegistry.ts header doc). Any search provider without a + * dedicated literal entry above (custom depth/engine catalog, e.g. + * "linkup-search") still needs a non-empty static catalog so the "Available + * Models" / model-import UI shows a usable list instead of a 400 "does not + * support models listing" (#7529). Derive it generically from the registry's + * own `searchTypes` so any *future* search provider is covered automatically. + */ +function getSearchProviderFallbackCatalog(provider: string): LocalCatalogModel[] | undefined { + const searchProvider = getSearchProvider(provider); + if (!searchProvider || searchProvider.searchTypes.length === 0) return undefined; + + return searchProvider.searchTypes.map((searchType) => ({ + id: searchType, + name: formatSearchTypeLabel(searchType), + })); +} + export function getStaticModelsForProvider(provider: string): LocalCatalogModel[] | undefined { const staticModelsFn = STATIC_MODEL_PROVIDERS[provider]; if (staticModelsFn) { return staticModelsFn(); } + const searchFallback = getSearchProviderFallbackCatalog(provider); + if (searchFallback) { + return searchFallback; + } + const specialtyModels: LocalCatalogModel[] = []; const appendModels = ( models: Array<{ id: string; name?: string }>, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index aeda6d7545..d2734ea883 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -52,6 +52,7 @@ import { validateJulesProvider, validateDevinCloudAgentProvider, validateInnerAiProvider, + validateNotionWebProvider, } from "./validation/webProvidersB"; import { validateHerokuProvider, @@ -68,6 +69,7 @@ import { import { validateDeepgramProvider, validateAssemblyAIProvider, + validateRevAiProvider, validateElevenLabsProvider, validateInworldProvider, validateKieProvider, @@ -478,6 +480,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi bytez: validateBytezProvider, deepgram: validateDeepgramProvider, assemblyai: validateAssemblyAIProvider, + "rev-ai": validateRevAiProvider, "fal-ai": ({ apiKey, providerSpecificData }: any) => validateImageProviderApiKey({ provider: "fal-ai", apiKey, providerSpecificData }), "stability-ai": ({ apiKey, providerSpecificData }: any) => @@ -530,6 +533,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi "adapta-web": validateAdaptaWebProvider, "claude-web": validateClaudeWebProvider, "gemini-web": validateGeminiWebProvider, + "notion-web": validateNotionWebProvider, "copilot-m365-web": validateCopilotM365WebProvider, "copilot-web": validateCopilotWebProvider, "t3-web": validateT3WebProvider, diff --git a/src/lib/providers/validation/audioMiscProviders.ts b/src/lib/providers/validation/audioMiscProviders.ts index 2672fce0c1..052c1bcbf6 100644 --- a/src/lib/providers/validation/audioMiscProviders.ts +++ b/src/lib/providers/validation/audioMiscProviders.ts @@ -55,6 +55,22 @@ export async function validateAssemblyAIProvider({ apiKey, providerSpecificData } } +export async function validateRevAiProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const response = await validationRead("https://api.rev.ai/speechtotext/v1/jobs?limit=1", { + method: "GET", + headers: buildBearerHeaders(apiKey, providerSpecificData), + }); + if (response.ok) return { valid: true, error: null }; + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + return { valid: false, error: `Validation failed: ${response.status}` }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + export async function validateElevenLabsProvider({ apiKey, providerSpecificData = {} }: any) { try { // Lightweight auth check endpoint diff --git a/src/lib/providers/validation/openaiFormat.ts b/src/lib/providers/validation/openaiFormat.ts index cce3931f1d..9f6efa9203 100644 --- a/src/lib/providers/validation/openaiFormat.ts +++ b/src/lib/providers/validation/openaiFormat.ts @@ -170,6 +170,19 @@ export async function validateOpenAILikeProvider({ return { valid: false, error: `Provider unavailable (${chatRes.status})` }; } + // #7284: A 429 on the chat probe means the key is accepted but this connection + // is rate/concurrency limited (e.g. always-throttled free tiers like opencode-zen). + // Keep valid:true (the key works) but surface a warning so the connection Test + // does not read as an unqualified green when real traffic will hit 429s. + // Mirrors validateBedrockProvider's existing 429 precedent above. + if (chatRes.status === 429) { + return { + valid: true, + error: null, + warning: "Provider accepted the key but is rate limited (429)", + }; + } + return { valid: true, error: null }; } catch (error: any) { return toValidationErrorResult(error); diff --git a/src/lib/providers/validation/webProvidersB.ts b/src/lib/providers/validation/webProvidersB.ts index 9a1afb128b..3d9ddd84d4 100644 --- a/src/lib/providers/validation/webProvidersB.ts +++ b/src/lib/providers/validation/webProvidersB.ts @@ -486,6 +486,57 @@ export async function validateDevinCloudAgentProvider({ apiKey }: { apiKey: stri } } +// ── Notion AI Web (Unofficial/Experimental) cookie validator ── +// #6758: no public Notion inference API exists; validate by probing a stable, +// low-privilege authenticated Notion endpoint (getSpaces) with the session +// cookie rather than the experimental runInferenceTranscript endpoint itself +// (a live inference call is expensive and unnecessary just to confirm the +// session is valid). +export async function validateNotionWebProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const raw = String(apiKey || "").trim(); + if (!raw) { + return { valid: false, error: "Paste your token_v2 cookie value from notion.so" }; + } + + const cookieHeader = raw.includes("=") ? raw : `token_v2=${raw}`; + + const response = await validationWrite("https://www.notion.so/api/v3/getSpaces", { + method: "POST", + headers: applyCustomUserAgent( + { + "Content-Type": "application/json", + Accept: "application/json", + Cookie: cookieHeader, + Origin: "https://www.notion.so", + Referer: "https://www.notion.so/", + }, + providerSpecificData + ), + body: "{}", + }); + + if (response.status === 401 || response.status === 403) { + return { + valid: false, + error: "Invalid or expired token_v2 cookie — re-paste from notion.so DevTools → Cookies", + }; + } + + if (response.status >= 500) { + return { valid: false, error: `Notion unavailable (${response.status})` }; + } + + if (response.ok) { + return { valid: true, error: null }; + } + + return { valid: false, error: `Notion validation failed (${response.status})` }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + export async function validateInnerAiProvider({ apiKey, providerSpecificData = {} }: any) { try { const raw = typeof apiKey === "string" ? apiKey.trim() : ""; diff --git a/src/lib/services/quotaAutoPing.ts b/src/lib/services/quotaAutoPing.ts new file mode 100644 index 0000000000..069ed5a8c2 --- /dev/null +++ b/src/lib/services/quotaAutoPing.ts @@ -0,0 +1,453 @@ +/** + * quotaAutoPing.ts — opt-in Codex quota window warm-up (#6977). + * + * A connection's Codex "session" quota window resets on a rolling 5h basis but + * only starts counting down once a request lands inside it — an idle window + * just keeps sliding forward. That means the FIRST real request after a long + * idle period pays for the whole warm-up latency. This scheduler watches each + * opted-in connection's reported `resetAt` and, once it slides forward (i.e. + * the window rolled while nobody was using it), fires one tiny non-billed-model + * request through the real Codex executor to nudge the window "live" again. + * + * Strictly opt-in (`settings.codexAutoPing.connections[id] === true`, default + * off — see src/lib/db/settings.ts) because every ping consumes a small amount + * of real quota. Reimplemented in TS from the shipped 9router + * src/shared/services/quotaAutoPing.js (Codex half only; Antigravity is a + * follow-up — no upstream reference exists for its 2-bucket reset shape). + * + * All external effects are behind an injectable `deps` object and the tick + * loop takes an injectable `now()` clock, so tests are fully deterministic — + * no real timers, no real DB, no real network. + */ + +import { logger } from "@omniroute/open-sse/utils/logger.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; +import { getCodexUsage } from "@omniroute/open-sse/services/usage/codex.ts"; +import { getSettings, getProviderConnections, updateProviderConnection } from "@/lib/localDb"; +import { refreshAndUpdateCredentials } from "@/lib/usage/providerLimits"; +import { getCircuitBreaker } from "@/shared/utils/circuitBreaker"; +import { + QUOTA_AUTOPING_FAILURE_COOLDOWN_MS, + QUOTA_AUTOPING_PROVIDERS, + QUOTA_AUTOPING_REFRESH_AHEAD_MS, + QUOTA_AUTOPING_TICK_INTERVAL_MS, + type QuotaAutoPingProviderConfig, +} from "@/shared/constants/quotaAutoPing"; + +const log = logger("QuotaAutoPing"); + +type JsonRecord = Record; + +export interface QuotaAutoPingConnection { + id: string; + provider: string; + authType?: string; + accessToken?: string; + refreshToken?: string; + tokenExpiresAt?: string | null; + expiresAt?: string | null; + providerSpecificData?: JsonRecord; + rateLimitedUntil?: string | null; + lastPingAt?: string | null; + lastPingedResetKey?: string | null; +} + +export interface QuotaAutoPingDeps { + getSettings: () => Promise; + getProviderConnections: ( + filter: JsonRecord + ) => Promise; + updateProviderConnection: (id: string, data: JsonRecord) => Promise; + refreshAndUpdateCredentials: ( + connection: QuotaAutoPingConnection + ) => Promise<{ connection: QuotaAutoPingConnection }>; + getCodexUsage: ( + accessToken?: string, + providerSpecificData?: JsonRecord + ) => Promise; + getExecutor: (provider: string) => { execute: (input: JsonRecord) => Promise }; + canExecuteProvider: (provider: string) => boolean; +} + +export interface QuotaAutoPingState { + running: boolean; + resetCache: Record; + failureCache: Record; +} + +export function createQuotaAutoPingState(): QuotaAutoPingState { + return { running: false, resetCache: {}, failureCache: {} }; +} + +export function createDefaultQuotaAutoPingDeps(): QuotaAutoPingDeps { + return { + getSettings, + getProviderConnections, + updateProviderConnection, + refreshAndUpdateCredentials: async (connection) => + refreshAndUpdateCredentials(connection as never), + getCodexUsage, + getExecutor, + canExecuteProvider: (provider) => getCircuitBreaker(provider).canExecute(), + }; +} + +function cacheKey(provider: string, connectionId: string): string { + return `${provider}:${connectionId}`; +} + +function normalizeResetKey(resetAt: string): string { + const ms = new Date(resetAt).getTime(); + if (!Number.isFinite(ms)) return resetAt; + // Round to the minute so a few seconds of clock jitter between reads never + // registers as "a different window" (mirrors 9router parity). + return new Date(Math.floor(ms / 60000) * 60000).toISOString(); +} + +function getResetDriftMs(previousResetAt: string, nextResetAt: string): number { + const previousMs = new Date(previousResetAt).getTime(); + const nextMs = new Date(nextResetAt).getTime(); + if (!Number.isFinite(previousMs) || !Number.isFinite(nextMs)) return 0; + return nextMs - previousMs; +} + +function toFiniteNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function isQuotaExhausted(quota: JsonRecord | undefined): boolean { + if (!quota || quota.unlimited === true) return false; + const remaining = toFiniteNumber(quota.remaining); + if (remaining !== null) return remaining <= 0; + const used = toFiniteNumber(quota.used); + const total = toFiniteNumber(quota.total); + return total !== null && total > 0 && used !== null && used >= total; +} + +function hasExhaustedBlockingQuota(quotas: JsonRecord, sessionKey: string): boolean { + return Object.entries(quotas).some(([name, quota]) => { + if (name === sessionKey) return false; + if (String(name).toLowerCase().includes("session")) return false; + return isQuotaExhausted(quota as JsonRecord); + }); +} + +function wasPingedRecently( + connection: QuotaAutoPingConnection, + intervalMs: number, + nowMs: number +): boolean { + if (!intervalMs || !connection.lastPingAt) return false; + const lastPingAtMs = new Date(connection.lastPingAt).getTime(); + return Number.isFinite(lastPingAtMs) && nowMs - lastPingAtMs < intervalMs; +} + +function shouldSkipAfterFailure( + state: QuotaAutoPingState, + key: string, + nowMs: number +): boolean { + const failedAt = state.failureCache[key]; + return Boolean(failedAt) && nowMs - failedAt < QUOTA_AUTOPING_FAILURE_COOLDOWN_MS; +} + +function isRateLimited(connection: QuotaAutoPingConnection, nowMs: number): boolean { + if (!connection.rateLimitedUntil) return false; + const untilMs = new Date(connection.rateLimitedUntil).getTime(); + return Number.isFinite(untilMs) && untilMs > nowMs; +} + +function buildCodexPingBody(providerConfig: QuotaAutoPingProviderConfig): JsonRecord { + return { + model: providerConfig.pingModel, + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: providerConfig.pingText }], + }, + ], + instructions: providerConfig.pingInstructions, + reasoning: providerConfig.pingReasoningEffort + ? { effort: providerConfig.pingReasoningEffort, summary: "auto" } + : undefined, + store: false, + stream: true, + }; +} + +async function drainResponseBody(response: Response | undefined): Promise { + if (!response) return; + if (typeof response.text === "function") { + await response.text().catch(() => undefined); + return; + } + const reader = (response as { body?: ReadableStream }).body?.getReader?.(); + if (!reader) return; + try { + for (;;) { + const { done } = await reader.read(); + if (done) return; + } + } finally { + reader.releaseLock?.(); + } +} + +async function sendCodexPing( + connection: QuotaAutoPingConnection, + providerConfig: QuotaAutoPingProviderConfig, + deps: QuotaAutoPingDeps +): Promise { + const executor = deps.getExecutor("codex"); + const result = await executor.execute({ + model: providerConfig.pingModel, + stream: true, + credentials: { + accessToken: connection.accessToken, + connectionId: connection.id, + providerSpecificData: connection.providerSpecificData, + }, + log: null, + body: buildCodexPingBody(providerConfig), + }); + const response = (result as { response?: Response }).response; + if (!response || !response.ok) { + try { + await (response as { body?: { cancel?: () => Promise } })?.body?.cancel?.(); + } catch { + // Ignore — best-effort cleanup of an already-failed ping. + } + return false; + } + // Codex only starts the 5h window after the streaming response completes. + await drainResponseBody(response); + return true; +} + +function shouldPingForReset( + providerConfig: QuotaAutoPingProviderConfig, + cachedReset: string | undefined, + resetAt: string, + nowMs: number +): boolean { + void nowMs; + if (!cachedReset) return false; + return getResetDriftMs(cachedReset, resetAt) >= providerConfig.resetAtDriftMs; +} + +/** + * Cheap pre-fetch guards — none of these require a network call. Extracted so + * `pingConnection` reads as a single linear flow instead of a wall of `if`s. + */ +function isPingCandidateBlocked( + connection: QuotaAutoPingConnection, + provider: "codex", + providerConfig: QuotaAutoPingProviderConfig, + deps: QuotaAutoPingDeps, + state: QuotaAutoPingState, + key: string, + cachedReset: string | undefined, + nowMs: number +): boolean { + if (!deps.canExecuteProvider(provider)) return true; // provider circuit breaker OPEN + if (isRateLimited(connection, nowMs)) return true; // connection cooldown active + if (shouldSkipAfterFailure(state, key, nowMs)) return true; + + // Codex has no fixed reset schedule (pingWhenResetAtSlides): resetAt keeps + // sliding forward while the window is idle, so we must re-fetch usage every + // tick to detect the slide. Providers with a real fixed reset (future + // Antigravity buckets) would skip re-fetching until close to the cached + // resetAt — the guard is preserved here for that case. + return Boolean( + !providerConfig.pingWhenResetAtSlides && + cachedReset && + nowMs < new Date(cachedReset).getTime() - QUOTA_AUTOPING_REFRESH_AHEAD_MS + ); +} + +/** Post-fetch decision — everything we know once usage has been read. */ +function shouldSendPing( + providerConfig: QuotaAutoPingProviderConfig, + quotas: JsonRecord, + quota: JsonRecord | undefined, + cachedReset: string | undefined, + resetAt: string, + current: QuotaAutoPingConnection, + resetKey: string, + nowMs: number +): boolean { + if ( + providerConfig.skipWhenBlockingQuotaExhausted && + hasExhaustedBlockingQuota(quotas, providerConfig.quotaKey) + ) { + return false; + } + if (isQuotaExhausted(quota)) return false; + if (!shouldPingForReset(providerConfig, cachedReset, resetAt, nowMs)) return false; + if (wasPingedRecently(current, providerConfig.minPingIntervalMs, nowMs)) return false; + if (current.lastPingedResetKey === resetKey) return false; + return true; +} + +async function refreshConnectionForPing( + connection: QuotaAutoPingConnection, + provider: "codex", + deps: QuotaAutoPingDeps, + state: QuotaAutoPingState, + key: string, + nowMs: number +): Promise { + try { + const refreshed = await deps.refreshAndUpdateCredentials(connection); + return refreshed.connection; + } catch (err) { + state.failureCache[key] = nowMs; + log.warn(`${provider}:${connection.id}: credential refresh failed`, { + error: sanitizeErrorMessage((err as Error)?.message ?? String(err)), + }); + return null; + } +} + +async function pingConnection( + connection: QuotaAutoPingConnection, + provider: "codex", + providerConfig: QuotaAutoPingProviderConfig, + deps: QuotaAutoPingDeps, + state: QuotaAutoPingState, + nowMs: number +): Promise { + const key = cacheKey(provider, connection.id); + const cachedReset = state.resetCache[key]; + if (isPingCandidateBlocked(connection, provider, providerConfig, deps, state, key, cachedReset, nowMs)) { + return; + } + + const current = await refreshConnectionForPing(connection, provider, deps, state, key, nowMs); + if (!current) return; + + const usage = await deps.getCodexUsage(current.accessToken, current.providerSpecificData); + const quotas = (usage.quotas as JsonRecord) || {}; + const quota = quotas[providerConfig.quotaKey] as JsonRecord | undefined; + const resetAt = quota?.resetAt as string | undefined; + if (!resetAt) return; + state.resetCache[key] = resetAt; + + const resetKey = normalizeResetKey(resetAt); + if (!shouldSendPing(providerConfig, quotas, quota, cachedReset, resetAt, current, resetKey, nowMs)) { + return; + } + + const ok = await sendCodexPing(current, providerConfig, deps); + if (!ok) { + state.failureCache[key] = nowMs; + log.warn(`${provider}:${current.id}: ping failed`, { resetAt }); + return; + } + + delete state.failureCache[key]; + await deps.updateProviderConnection(current.id, { + lastPingedResetKey: resetKey, + lastPingAt: new Date(nowMs).toISOString(), + }); + log.info(`${provider}:${current.id}: ping sent`, { resetAt }); +} + +function getEnabledConnectionIds( + settings: JsonRecord, + providerConfig: QuotaAutoPingProviderConfig +): Record { + return ( + ((settings[providerConfig.settingsKey] as JsonRecord | undefined)?.connections as + | Record + | undefined) || {} + ); +} + +async function pingProviderConnections( + provider: "codex", + providerConfig: QuotaAutoPingProviderConfig, + enabledMap: Record, + deps: QuotaAutoPingDeps, + state: QuotaAutoPingState, + nowMs: number +): Promise { + const connections = await deps.getProviderConnections({ provider, isActive: true }); + const targets = connections.filter( + (conn) => conn.authType === "oauth" && enabledMap[conn.id] === true + ); + for (const connection of targets) { + try { + await pingConnection(connection, provider, providerConfig, deps, state, nowMs); + } catch (err) { + state.failureCache[cacheKey(provider, connection.id)] = nowMs; + log.warn(`${provider}:${connection.id}: tick error`, { + error: sanitizeErrorMessage((err as Error)?.message ?? String(err)), + }); + } + } +} + +/** + * Run one scheduler tick: for every provider with at least one opted-in + * connection, ping the connections whose quota window just rolled over. + * Safe to call concurrently — re-entrant calls while already `running` are a + * no-op, matching the reference scheduler's guard. + */ +export async function runQuotaAutoPingTick( + deps: QuotaAutoPingDeps = createDefaultQuotaAutoPingDeps(), + state: QuotaAutoPingState = createQuotaAutoPingState(), + now: () => number = Date.now +): Promise { + if (state.running) return; + state.running = true; + const nowMs = now(); + try { + const settings = await deps.getSettings(); + for (const [provider, providerConfig] of Object.entries(QUOTA_AUTOPING_PROVIDERS)) { + const enabledMap = getEnabledConnectionIds(settings, providerConfig); + if (Object.keys(enabledMap).length === 0) continue; + await pingProviderConnections( + provider as "codex", + providerConfig, + enabledMap, + deps, + state, + nowMs + ); + } + } catch (err) { + log.warn("tick error", { error: sanitizeErrorMessage((err as Error)?.message ?? String(err)) }); + } finally { + state.running = false; + } +} + +let schedulerInterval: ReturnType | null = null; +const schedulerState = createQuotaAutoPingState(); + +/** Start the in-process scheduler. Idempotent — a second call is a no-op. */ +export function startQuotaAutoPing(): void { + if (schedulerInterval) return; + log.info("scheduler started"); + runQuotaAutoPingTick(createDefaultQuotaAutoPingDeps(), schedulerState).catch(() => undefined); + schedulerInterval = setInterval(() => { + runQuotaAutoPingTick(createDefaultQuotaAutoPingDeps(), schedulerState).catch(() => undefined); + }, QUOTA_AUTOPING_TICK_INTERVAL_MS); + schedulerInterval.unref?.(); +} + +/** Stop the in-process scheduler. Idempotent — a second call is a no-op. */ +export function stopQuotaAutoPing(): void { + if (!schedulerInterval) return; + clearInterval(schedulerInterval); + schedulerInterval = null; + log.info("scheduler stopped"); +} diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 6dcafb051f..f8ebe5d7e3 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -35,6 +35,7 @@ import { normalizeUsageQuotasForProvider, sanitizeUsageQuotasForProvider, } from "./providerLimits/quotaNormalize"; +import { syncInChunksWithSpacing } from "./providerLimits/chunkedSpacingSync"; type JsonRecord = Record; type SyncSource = "manual" | "scheduled"; @@ -616,15 +617,18 @@ export function getProviderLimitsSyncIntervalMs(): number { const DEFAULT_PROVIDER_LIMITS_SYNC_SPACING_MS = 1500; /** - * Spacing (ms) between consecutive OAuth provider-limits fetches in a bulk sync. + * Spacing (ms) applied between consecutive provider-limits fetch batches in a + * bulk sync, for BOTH the OAuth and local/API-key paths. * * OAuth providers (Codex/Claude/Kimi-coding/…) are fetched ONE AT A TIME with * this gap so a single host never bursts several simultaneous usage/refresh * requests to the same upstream — bursts read as automated traffic and * contribute to session termination / anomaly flags (and, for rotating-token - * providers, to the Auth0 family-revocation race). Stateless API-key providers - * keep the fast concurrent path. Tunable via `PROVIDER_LIMITS_SYNC_SPACING_MS`; - * set to `"0"` to opt out. + * providers, to the Auth0 family-revocation race). Local/API-key connections + * (e.g. Ollama) keep their fast in-chunk concurrent path, but the gap is now + * also applied BETWEEN concurrency chunks so a local endpoint isn't hit by a + * simultaneous refresh burst either (#6916). Tunable via + * `PROVIDER_LIMITS_SYNC_SPACING_MS`; set to `"0"` to opt out on either path. */ export function getProviderLimitsSyncSpacingMs(): number { const rawEnv = process.env.PROVIDER_LIMITS_SYNC_SPACING_MS; @@ -633,8 +637,6 @@ export function getProviderLimitsSyncSpacingMs(): number { return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_PROVIDER_LIMITS_SYNC_SPACING_MS; } -const syncDelay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - export async function getLastProviderLimitsAutoSyncTime(): Promise { try { const settings = await getSettings(); @@ -955,31 +957,27 @@ export async function syncAllProviderLimits( return { connectionId: connection.id, cache }; }; - // OAuth connections are processed STRICTLY SEQUENTIALLY with a spacing gap so a - // single host never bursts simultaneous usage/refresh requests to the same - // upstream (anomaly/session-termination guard; see getProviderLimitsSyncSpacingMs). - // Stateless API-key connections keep the fast chunked-concurrent path. + // OAuth connections are processed STRICTLY SEQUENTIALLY (chunk size 1) with a + // spacing gap so a single host never bursts simultaneous usage/refresh + // requests to the same upstream (anomaly/session-termination guard; see + // getProviderLimitsSyncSpacingMs). Local/API-key connections keep their fast + // in-chunk concurrent path, spaced BETWEEN chunks (#6916). const oauthConnections = connections.filter((c) => c.authType === "oauth"); const otherConnections = connections.filter((c) => c.authType !== "oauth"); const spacingMs = getProviderLimitsSyncSpacingMs(); - for (let i = 0; i < otherConnections.length; i += concurrency) { - const chunk = otherConnections.slice(i, i + concurrency); - const results = await Promise.allSettled(chunk.map(fetchOne)); + const recordChunk = ( + chunk: ProviderConnectionLike[], + results: PromiseSettledResult<{ connectionId: string; cache: ProviderLimitsCacheEntry }>[] + ) => { results.forEach((result, index) => { const connectionId = chunk[index]?.id; if (connectionId) recordResult(connectionId, result); }); - } + }; - for (let i = 0; i < oauthConnections.length; i++) { - const connection = oauthConnections[i]; - const [result] = await Promise.allSettled([fetchOne(connection)]); - recordResult(connection.id, result); - if (spacingMs > 0 && i < oauthConnections.length - 1) { - await syncDelay(spacingMs); - } - } + await syncInChunksWithSpacing(otherConnections, concurrency, spacingMs, fetchOne, recordChunk); + await syncInChunksWithSpacing(oauthConnections, 1, spacingMs, fetchOne, recordChunk); if (cacheEntries.length > 0) { setProviderLimitsCacheBatch(cacheEntries); diff --git a/src/lib/usage/providerLimits/chunkedSpacingSync.ts b/src/lib/usage/providerLimits/chunkedSpacingSync.ts new file mode 100644 index 0000000000..ad3374d557 --- /dev/null +++ b/src/lib/usage/providerLimits/chunkedSpacingSync.ts @@ -0,0 +1,30 @@ +/** + * Pure, DB-free chunked sync helper shared by both the OAuth and non-OAuth + * (local/API-key) paths in `syncAllProviderLimits()`. + * + * Processes `items` in chunks of `chunkSize`, running each chunk's fetchers + * concurrently (`Promise.allSettled`) but waiting `spacingMs` between chunks + * (never after the last one). `chunkSize=1` reproduces the strictly-sequential + * OAuth behavior; `chunkSize=concurrency` reproduces the previous fast + * chunked-concurrent behavior for local/API-key connections, now with the + * spacing gap applied between chunks so `PROVIDER_LIMITS_SYNC_SPACING_MS` is + * honored on both paths (see #6916). + */ +export async function syncInChunksWithSpacing( + items: T[], + chunkSize: number, + spacingMs: number, + fetcher: (item: T) => Promise, + onChunkResults: (chunk: T[], results: PromiseSettledResult[]) => void +): Promise { + const size = chunkSize > 0 ? chunkSize : 1; + for (let i = 0; i < items.length; i += size) { + const chunk = items.slice(i, i + size); + const results = await Promise.allSettled(chunk.map(fetcher)); + onChunkResults(chunk, results); + const isLastChunk = i + size >= items.length; + if (spacingMs > 0 && !isLastChunk) { + await new Promise((resolve) => setTimeout(resolve, spacingMs)); + } + } +} diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index a13ffb13f6..b6a019df3a 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -792,7 +792,13 @@ export interface ModelLatencyStatsEntry { * Used by auto-combo routing to incorporate real-world latency and reliability. */ export async function getModelLatencyStats( - options: { windowHours?: number; minSamples?: number; maxRows?: number } = {} + options: { + windowHours?: number; + minSamples?: number; + maxRows?: number; + provider?: string; + model?: string; + } = {} ): Promise> { const windowHours = Number.isFinite(Number(options.windowHours)) && Number(options.windowHours) > 0 @@ -817,19 +823,28 @@ export async function getModelLatencyStats( latency_ms: number | null; }; + const conditions = ["timestamp >= @sinceIso", "provider IS NOT NULL", "model IS NOT NULL"]; + const queryParams: Record = { sinceIso, maxRows }; + if (options.provider) { + conditions.push("provider = @provider"); + queryParams.provider = options.provider; + } + if (options.model) { + conditions.push("model = @model"); + queryParams.model = options.model; + } + const rows = db .prepare( ` SELECT provider, model, success, latency_ms FROM usage_history - WHERE timestamp >= @sinceIso - AND provider IS NOT NULL - AND model IS NOT NULL + WHERE ${conditions.join(" AND ")} ORDER BY timestamp DESC LIMIT @maxRows ` ) - .all({ sinceIso, maxRows }) as LatencyRow[]; + .all(queryParams) as LatencyRow[]; const grouped = new Map< string, diff --git a/src/mitm/_internal/aliasConfig.cjs b/src/mitm/_internal/aliasConfig.cjs new file mode 100644 index 0000000000..b3a8e98ff0 --- /dev/null +++ b/src/mitm/_internal/aliasConfig.cjs @@ -0,0 +1,81 @@ +"use strict"; + +// ========================================================================= +// CJS mirror of `src/mitm/aliasConfig.ts` for the standalone proxy process +// (server.cjs, spawned by manager.ts — runs as plain CommonJS, cannot import +// the ESM/TS source tree). Keep the two in sync when the alias-entry shape +// or the reasoning-effort vocabulary changes. +// +// The canonical effort vocabulary mirrors `@/shared/reasoning/effortStandardization.ts` +// (`CANONICAL_EFFORT_VALUES` + the `extra`/`max` → `xhigh` alias). Ported from upstream +// decolua/9router#2584 ("add Antigravity reasoning effort overrides"). +// ========================================================================= + +const CANONICAL_EFFORT_VALUES = ["none", "low", "medium", "high", "xhigh"]; +const EFFORT_TIER_ALIASES = { extra: "xhigh", max: "xhigh" }; + +function normalizeReasoningEffort(value) { + if (typeof value !== "string") return undefined; + const lowered = value.trim().toLowerCase(); + if (!lowered) return undefined; + if (Object.prototype.hasOwnProperty.call(EFFORT_TIER_ALIASES, lowered)) { + return EFFORT_TIER_ALIASES[lowered]; + } + return CANONICAL_EFFORT_VALUES.includes(lowered) ? lowered : undefined; +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeAliasEntry(value) { + if (typeof value === "string") { + const model = value.trim(); + return model ? { model } : null; + } + if (!isPlainObject(value)) return null; + + const model = typeof value.model === "string" ? value.model.trim() : ""; + const reasoningEffort = normalizeReasoningEffort(value.reasoningEffort); + if (!model && !reasoningEffort) return null; + + return { + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + }; +} + +function normalizeAliasMappings(mappings) { + if (!isPlainObject(mappings)) return {}; + const normalized = {}; + for (const [alias, value] of Object.entries(mappings)) { + if (!alias) continue; + const entry = normalizeAliasEntry(value); + if (entry) normalized[alias] = entry; + } + return normalized; +} + +/** + * Apply a normalized alias entry onto the raw (still Gemini/cloudcode-shaped) request body + * the standalone proxy forwards. Mutates nothing on the input — returns a shallow-cloned + * body with `model` swapped (when the override carries one) and `reasoningEffortOverride` + * set at the SAME envelope level as `model` (top-level; the antigravity→openai translator + * reads it there — see `open-sse/translator/request/antigravity-to-openai.ts`). + */ +function applyAntigravityOverride(body, override) { + const result = { ...body }; + if (override && override.model) result.model = override.model; + if (override && override.reasoningEffort) { + result.reasoningEffortOverride = override.reasoningEffort; + } + return result; +} + +module.exports = { + CANONICAL_EFFORT_VALUES, + normalizeReasoningEffort, + normalizeAliasEntry, + normalizeAliasMappings, + applyAntigravityOverride, +}; diff --git a/src/mitm/aliasConfig.ts b/src/mitm/aliasConfig.ts new file mode 100644 index 0000000000..e65013de1a --- /dev/null +++ b/src/mitm/aliasConfig.ts @@ -0,0 +1,83 @@ +/** + * MITM alias-mapping normalization — Antigravity model + reasoning-effort overrides. + * + * Ported from upstream decolua/9router#2584 ("add Antigravity reasoning effort + * overrides"), adapted to OmniRoute's alias storage shape (`src/lib/db/models/mitmAlias.ts`, + * `Record`) and its existing canonical reasoning-effort + * vocabulary (`@/shared/reasoning/effortStandardization.ts`) instead of inventing a new one. + * + * A saved alias entry is either: + * - a legacy plain string — `"provider/model-id"` (model mapping only, no reasoning + * override; this is the shape every existing install already has on disk), or + * - a structured object — `{ model?: string, reasoningEffort?: CanonicalEffort }`, + * allowing a reasoning-effort override to be configured independently of (or without) + * a model remap. + * + * `normalizeAliasMappings` upgrades legacy strings to the structured shape on read, so no + * DB migration is required (mirrors upstream's stated backward-compatibility contract). + */ +import { normalizeEffort, type CanonicalEffort } from "@/shared/reasoning/effortStandardization"; + +export interface MitmAliasEntry { + model?: string; + reasoningEffort?: CanonicalEffort; +} + +export type MitmAliasMappings = Record; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Normalize a single stored alias value (legacy string or structured entry) into the + * canonical `MitmAliasEntry` shape. Returns `null` when the value carries neither a model + * nor a valid reasoning-effort override (i.e. it should be dropped from the mapping). + */ +export function normalizeAliasEntry(value: unknown): MitmAliasEntry | null { + if (typeof value === "string") { + const model = value.trim(); + return model ? { model } : null; + } + if (!isPlainObject(value)) return null; + + const model = typeof value.model === "string" ? value.model.trim() : ""; + const reasoningEffort = normalizeEffort(value.reasoningEffort); + if (!model && !reasoningEffort) return null; + + return { + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + }; +} + +/** + * Normalize a whole alias→mapping record (as stored under `mitmAlias.antigravity` / + * returned by `getMitmAlias()`), upgrading legacy string mappings and dropping empty + * entries. Always returns a well-formed object, even for malformed input. + */ +export function normalizeAliasMappings(mappings: unknown): MitmAliasMappings { + if (!isPlainObject(mappings)) return {}; + const normalized: MitmAliasMappings = {}; + for (const [alias, value] of Object.entries(mappings)) { + if (!alias) continue; + const entry = normalizeAliasEntry(value); + if (entry) normalized[alias] = entry; + } + return normalized; +} + +/** + * True when any entry in the (not-yet-normalized) request payload carries a + * `reasoningEffort` value that fails to normalize onto the canonical vocabulary — used to + * reject the PUT at the API boundary with a 400 instead of silently dropping the override. + */ +export function hasInvalidReasoningEffort(mappings: unknown): boolean { + if (!isPlainObject(mappings)) return false; + return Object.values(mappings).some((value) => { + if (!isPlainObject(value)) return false; + const raw = value.reasoningEffort; + if (raw == null || raw === "") return false; + return normalizeEffort(raw) === undefined; + }); +} diff --git a/src/mitm/cert/install.ts b/src/mitm/cert/install.ts index f6871b0bd2..82fc4a0d91 100644 --- a/src/mitm/cert/install.ts +++ b/src/mitm/cert/install.ts @@ -161,9 +161,25 @@ async function checkCertInstalledLinux(certPath: string): Promise { } } -async function checkCertInstalledWindows(_certPath: string): Promise { +/** + * Windows `certutil -store ` accepts a serial number, a + * SHA-1 thumbprint, or a substring of the subject/friendly name as `certId`. + * Older code passed the literal legacy hostname `daily-cloudcode-pa.googleapis.com` + * here — it only "worked" because that happens to be the CA's own commonName + * today (`generate.ts` derives it from `ANTIGRAVITY_TARGET.hosts[0]`), a + * coincidence with no shared symbol coupling the two (#7275). Deriving the + * thumbprint from the actual `certPath` file — the same identity + * {@link checkCertInstalledMac} already keys off via {@link getCertFingerprint} + * — makes the Windows store lookup match the real generated CA regardless of + * any future rename/reorder in `generate.ts`. + */ +export function certutilThumbprint(certPath: string): string { + return getCertFingerprint(certPath).replace(/:/g, ""); +} + +async function checkCertInstalledWindows(certPath: string): Promise { try { - await execFileText("certutil", ["-store", "Root", "daily-cloudcode-pa.googleapis.com"]); + await execFileText("certutil", ["-store", "Root", certutilThumbprint(certPath)]); return true; } catch { return false; @@ -381,7 +397,7 @@ export async function uninstallCert(sudoPassword: string, certPath: string): Pro } if (IS_WIN) { - await uninstallCertWindows(); + await uninstallCertWindows(certPath); } else if (IS_MAC) { await uninstallCertMac(sudoPassword, certPath); } else { @@ -431,10 +447,20 @@ async function uninstallCertLinux(sudoPassword: string, certPath: string): Promi } } -async function uninstallCertWindows(): Promise { - await runElevatedPowerShell(` - $proc = Start-Process certutil -ArgumentList @('-delstore','Root','daily-cloudcode-pa.googleapis.com') -Verb RunAs -Wait -PassThru; +/** + * Pure builder for the elevated `certutil -delstore` script, extracted so the + * regression test can assert the argv it embeds without spawning a real + * `powershell`/UAC prompt (mirrors {@link buildCertManualGuide} / + * {@link buildElevatedScriptWrapper}, already tested the same way). + */ +export function buildWindowsDelstoreScript(thumbprint: string): string { + return ` + $proc = Start-Process certutil -ArgumentList @('-delstore','Root',${quotePowerShell(thumbprint)}) -Verb RunAs -Wait -PassThru; if ($proc.ExitCode -ne 0) { throw "certutil exited with code $($proc.ExitCode)" } - `); + `; +} + +async function uninstallCertWindows(certPath: string): Promise { + await runElevatedPowerShell(buildWindowsDelstoreScript(certutilThumbprint(certPath))); console.log("✅ Uninstalled certificate from Windows Root store"); } diff --git a/src/mitm/server.cjs b/src/mitm/server.cjs index 8be6ad1b76..9ae165f5d7 100644 --- a/src/mitm/server.cjs +++ b/src/mitm/server.cjs @@ -136,6 +136,7 @@ function sanitizeErrorMessage(message) { const bypassShim = require("./_internal/bypass.cjs"); const ingestShim = require("./_internal/ingest.cjs"); const forwardShim = require("./_internal/forwardTarget.cjs"); +const aliasConfigShim = require("./_internal/aliasConfig.cjs"); // Inspector capture (D4 fallback). The standalone proxy intercepts AgentBridge // traffic inline (no MitmHandlerBase / agentBridgeHook), so it posts captured @@ -337,7 +338,13 @@ function getSqliteDb() { return null; } -function getMappedModel(model) { +/** + * Resolve the stored alias override for a source model: `{ model?, reasoningEffort? }`. + * `normalizeAliasMappings` upgrades legacy plain-string mappings (every existing install) + * into the structured shape, so both old and new saves resolve consistently. Returns + * `null` when there is no override at all for this model (passthrough). + */ +function getMappedOverride(model) { if (!model) return null; // Primary: read from SQLite key_value table @@ -350,7 +357,7 @@ function getMappedModel(model) { ) .get(); if (row) { - const mappings = JSON.parse(row.value); + const mappings = aliasConfigShim.normalizeAliasMappings(JSON.parse(row.value)); return mappings[model] || null; } } @@ -362,7 +369,8 @@ function getMappedModel(model) { try { if (fs.existsSync(DB_FILE)) { const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8")); - return db.mitmAlias?.antigravity?.[model] || null; + const mappings = aliasConfigShim.normalizeAliasMappings(db.mitmAlias?.antigravity); + return mappings[model] || null; } } catch { // Ignore @@ -451,7 +459,7 @@ function captureToInspector(o) { } } -async function intercept(req, res, bodyBuffer, mappedModel, sourceModel) { +async function intercept(req, res, bodyBuffer, override, sourceModel) { // C2 — Inject AgentBridge correlation headers per master plan §3.5. // The OmniRoute router uses these to distinguish AgentBridge traffic from // other inbound clients and to record the originating IDE agent id. @@ -468,8 +476,15 @@ async function intercept(req, res, bodyBuffer, mappedModel, sourceModel) { let captureError; try { - const body = JSON.parse(bodyBuffer.toString()); - body.model = mappedModel; + // `override` is a normalized `{ model?, reasoningEffort? }` alias entry (never null — + // the caller already gated on that). `applyAntigravityOverride` swaps `model` when + // present and, for a reasoning-effort override, sets `reasoningEffortOverride` at the + // same top-level envelope depth so the antigravity→openai translator can read it + // ahead of its thinkingConfig-derived guess (ported from upstream #2584). + const body = aliasConfigShim.applyAntigravityOverride( + JSON.parse(bodyBuffer.toString()), + override + ); // Gap B — the Antigravity IDE speaks cloudcode (the Gemini payload wrapped // under `request`) and expects a cloudcode reply. Forward such envelopes to @@ -545,7 +560,7 @@ async function intercept(req, res, bodyBuffer, mappedModel, sourceModel) { bodyBuffer, agentId, sourceModel, - mappedModel, + mappedModel: (override && override.model) || sourceModel, status: captureStatus, respHeaders, respBody, @@ -587,9 +602,9 @@ const server = https.createServer(sslOptions, async (req, res) => { return passthrough(req, res, bodyBuffer); } - const mappedModel = getMappedModel(model); + const mappedOverride = getMappedOverride(model); - if (!mappedModel) { + if (!mappedOverride) { vlog(1, `[MITM] → PASSTHROUGH (model "${model}" has no MITM alias mapping)`); return passthrough(req, res, bodyBuffer); } @@ -598,8 +613,12 @@ const server = https.createServer(sslOptions, async (req, res) => { stats.lastInterceptAt = new Date().toISOString(); writeStats(); - vlog(1, `[MITM] INTERCEPTED ${model} → ${mappedModel}`); - return intercept(req, res, bodyBuffer, mappedModel, model); + vlog( + 1, + `[MITM] INTERCEPTED ${model} → ${mappedOverride.model || model}` + + (mappedOverride.reasoningEffort ? ` (reasoningEffort=${mappedOverride.reasoningEffort})` : "") + ); + return intercept(req, res, bodyBuffer, mappedOverride, model); }); // ========================================================================= diff --git a/src/server/authz/policies/clientApi.ts b/src/server/authz/policies/clientApi.ts index 6545b11d02..aff9d2d533 100644 --- a/src/server/authz/policies/clientApi.ts +++ b/src/server/authz/policies/clientApi.ts @@ -1,6 +1,7 @@ import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth.ts"; import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; import { extractApiKey } from "@/sse/services/auth.ts"; +import { extractGoogApiKeyHeader } from "@/sse/services/googApiKeyAuth.ts"; import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; import { allow, reject } from "../context"; @@ -20,6 +21,7 @@ function isWsHandshake(ctx: PolicyContext): boolean { function extractBearer(request: Request): string | null { const raw = request.headers.get("authorization") ?? request.headers.get("Authorization"); const xApiKey = request.headers.get("x-api-key") ?? request.headers.get("X-Api-Key"); + const xGoogApiKey = extractGoogApiKeyHeader(request.headers); if (raw) { const trimmed = raw.trim(); if (trimmed.toLowerCase().startsWith("bearer ")) { @@ -37,6 +39,13 @@ function extractBearer(request: Request): string | null { return xApiKey.trim() || null; } + // Issue #7034: gemini-cli (and any @google/genai-based client) sends its + // key via x-goog-api-key exclusively — accept it unconditionally, same + // shape as the x-api-key fallback above. + if (xGoogApiKey) { + return xGoogApiKey; + } + return extractApiKey(request); } diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index d9479e1e89..ff2ec2c7e4 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -31,6 +31,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/cli-tools/runtime/", "/api/cli-tools/omp-settings", // spawns `which omp` to detect the CLI install (Hard Rules #15 + #17, #6318) "/api/cli-tools/letta-settings", // spawns `which letta` to detect the CLI install (Hard Rules #15 + #17, #6318) + "/api/cli-tools/grok-build-settings", // GET calls getCliRuntimeStatus("grok-build"), which spawns a child process to locate + healthcheck the `grok` binary — same transitive-spawn surface that classified /api/skills/collect/ (Hard Rules #15 + #17). Writing ~/.grok/config.toml is inherently a local-machine operation, so loopback-only costs no real capability. "/api/services/", // T-10: embedded service lifecycle (spawn child processes) "/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs "/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass diff --git a/src/server/cors/origins.ts b/src/server/cors/origins.ts index f6a748fb6a..d610297641 100644 --- a/src/server/cors/origins.ts +++ b/src/server/cors/origins.ts @@ -138,6 +138,11 @@ export function getCorsStatus(): CorsStatus { * is returned when there is no `Origin` header. This is NEVER paired with * `Access-Control-Allow-Credentials` (these routes are not cookie-authed), so * the echo/wildcard stays safe. + * + * On that same `relaxForTokenAuth` surface, also appends `Vary: Accept-Encoding` + * to every response with a body (RFC 9110 §12.5.5, issue #6737) — Next's built-in + * compression middleware only appends it conditionally, so shared caches can't + * otherwise reliably tell compressed vs uncompressed variants apart. */ export function applyCorsHeaders( response: Response, @@ -153,6 +158,16 @@ export function applyCorsHeaders( response.headers.set("Access-Control-Allow-Origin", allowed); response.headers.append("Vary", "Origin"); } + // RFC 9110 §12.5.5 (issue #6737): the token-authenticated /v1*/v1beta* surface + // (relaxForTokenAuth) negotiates content-encoding via Next's built-in + // compression middleware, which only appends `Vary: Accept-Encoding` + // conditionally (after its own content-type/threshold filter) — so shared + // caches (CDNs/proxies) can't reliably tell compressed vs uncompressed variants + // apart. Stamp it explicitly here, at the same chokepoint that already appends + // `Vary: Origin`, on every relaxed-CORS response with a body. + if (relaxForTokenAuth && response.status !== 204) { + response.headers.append("Vary", "Accept-Encoding"); + } response.headers.set("Access-Control-Allow-Methods", STANDARD_ALLOW_METHODS); response.headers.set("Access-Control-Allow-Headers", STANDARD_ALLOW_HEADERS); const requestedHeaders = request.headers.get("access-control-request-headers"); diff --git a/src/shared/components/ModelRoutingSection.tsx b/src/shared/components/ModelRoutingSection.tsx index f8f597247a..af29a9045f 100644 --- a/src/shared/components/ModelRoutingSection.tsx +++ b/src/shared/components/ModelRoutingSection.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; import Card from "./Card"; +import { matchesOnlyPaidModels } from "@/shared/utils/freeModels"; export interface ModelMapping { id: string; @@ -26,6 +27,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos const [loading, setLoading] = useState(true); const [adding, setAdding] = useState(false); const [editingId, setEditingId] = useState(null); + const [hidePaidModels, setHidePaidModels] = useState(false); const combos = externalCombos || internalCombos; // Form state @@ -58,6 +60,21 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos }; }, []); + // #6540: read hidePaidModels once so the pattern field can warn (fail-open) + // when it resolves only to paid model families. + useEffect(() => { + let cancelled = false; + fetch("/api/settings") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!cancelled && data) setHidePaidModels(data.hidePaidModels === true); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { if (externalCombos !== undefined) return; let cancelled = false; @@ -141,6 +158,11 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos } catch {} }; + // #6540: fail-open heuristic — only warn/block when the pattern resolves + // to at least one model AND every match is paid. A pattern matching a + // mix of free and paid models (or nothing recognizable) is left alone. + const patternIsPaidOnly = hidePaidModels && matchesOnlyPaidModels(pattern); + return (
@@ -183,6 +205,12 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary" />

{t("patternHint")}

+ {patternIsPaidOnly && ( +

+ {t("paidModelPatternWarning") || + "This pattern only matches paid models — enable paid models or adjust the pattern."} +

+ )}