diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3c641330f1..0913d78cd0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,6 +24,15 @@ updates: update-types: ["version-update:semver-major"] - dependency-name: "eslint-config-next" update-types: ["version-update:semver-major"] + # typescript majors are peer-blocked by typescript-eslint, which pins a hard + # upper bound (8.64.0 → peerDependencies.typescript ">=4.8.4 <6.1.0"). A TS 7 + # bump therefore violates the peer and takes down the whole toolchain at once — + # #7068 grouped it with 6 harmless bumps and turned Build + Lint + Quality Ratchet + # + Unit (6/8, 8/8) + Integration (1/2, 2/2) + dast-smoke red in one shot, blocking + # the innocuous updates riding along with it. Un-ignore once typescript-eslint + # widens the peer, and migrate TS majors intentionally (own PR, own CI run). + - dependency-name: "typescript" + update-types: ["version-update:semver-major"] # jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API). # scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it # parses jscpd-report.json against a frozen baseline). A v5 major would break diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df99b771ae..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. @@ -670,10 +670,16 @@ jobs: if: runner.os == 'Linux' working-directory: electron run: npm run pack + # ADVISORY while the new Windows leg matures (repo convention, dast-smoke + # precedent): its first-ever real run (2026-07-15, run 29457533565) died in + # 0.7s with the error swallowed by pwsh — bash shell captures stderr and + # continue-on-error keeps the heavy gate green while we harden it (#7336). - name: Prepare Electron standalone (Windows ABI rebuild + spawn path) if: runner.os == 'Windows' working-directory: electron - run: npm run prepare:bundle + continue-on-error: true + shell: bash + run: npm run prepare:bundle 2>&1 - name: Smoke packaged Electron app if: runner.os == 'Linux' env: @@ -783,7 +789,10 @@ jobs: test-coverage: name: Coverage runs-on: ubuntu-latest - timeout-minutes: 10 + # 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it); + # merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive + # release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16). + timeout-minutes: 20 needs: test-unit if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }} env: diff --git a/.github/workflows/nightly-release-green.yml b/.github/workflows/nightly-release-green.yml index 9d1d20b565..63d1b4a054 100644 --- a/.github/workflows/nightly-release-green.yml +++ b/.github/workflows/nightly-release-green.yml @@ -22,7 +22,7 @@ name: Release-Green (continuous) on: push: - branches: ["release/v*"] + branches: ["release/v*", "main"] paths: - "src/**" - "open-sse/**" @@ -61,6 +61,9 @@ env: jobs: release-green: name: Validate active release branch + # On a push, only run for release/* pushes — a push to main is handled by the + # main-green job below. Schedule/dispatch always run (they validate the highest release). + if: ${{ github.event_name != 'push' || startsWith(github.ref_name, 'release/') }} # Dynamic runner: with USE_VPS_RUNNER=true (release window / on-demand pre-flight) # this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY, # no local noauth CLIs => zero machine-specific false positives) and no contention. @@ -201,3 +204,100 @@ jobs: release-green.json release-green.log if-no-files-found: ignore + + # Companion arm for `main`. Under the parallel-cycle model, main only receives merged + # work at the release squash — so a gate/infra fix that lands only on release leaves + # main red the whole cycle, and repo-wide gates (CodeQL alert count, ratchet baselines) + # turn EVERY PR into main red on a check unrelated to its diff. This detects that and + # opens a "🔴 main not green" tracking issue. The PREVENTION is the companion-PR reflex + # (Hard Rule #21 area / _shared/merge-gates.md §8); this is the automated backstop. + main-green: + name: Validate main branch + # On a push, only run for a push to main — a push to release/* is handled by + # release-green above. Schedule/dispatch always run (they also sweep main). + if: ${{ github.event_name != 'push' || github.ref_name == 'main' }} + runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }} + env: + JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-nightly-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + + - uses: ./.github/actions/npm-ci-retry + + - name: Main-green validation + id: validate + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set +e + # push (a merge into main) → --quick fast HARD gates; schedule/dispatch → full sweep. + if [ "$EVENT_NAME" = "push" ]; then + MODE="--quick" + else + MODE="--with-build --full-ci" + fi + echo "[main-green] mode: $MODE (event: $EVENT_NAME)" + # shellcheck disable=SC2086 — MODE is an intentional flag list + node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \ + 1> main-green.json 2> main-green.log + echo "exit=$?" >> "$GITHUB_OUTPUT" + echo "------- report -------" + cat main-green.log + + - name: Open / update tracking issue on HARD failure + if: steps.validate.outputs.exit != '0' + env: + GH_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + TITLE="🔴 main branch not green" + { + echo "The **main-green** validation found HARD failures on \`main\`." + echo "" + echo "Because \`main\` only receives merged work at the release squash, a gate/infra" + echo "fix that landed only on the release branch leaves \`main\` broken for the whole" + echo "cycle — and repo-wide gates (CodeQL alert count, ratchet baselines) then turn" + echo "**every open PR into main** red on a check unrelated to its diff. The fix is a" + echo "companion PR \`--base main\` carrying the release-side fix (see" + echo "\`_shared/merge-gates.md\` §8), NOT chasing each contributor PR." + echo "" + echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})" + echo "" + echo '```' + sed -n '/──────── verdict ────────/,$p' main-green.log || tail -40 main-green.log + echo '```' + echo "" + echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) is expected mid-cycle and did NOT, on its own, open this issue._" + } > issue-body.md + + EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md + echo "Updated existing issue #$EXISTING" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md + fi + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: main-green-report + path: | + main-green.json + main-green.log + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index 42209a90d0..92eb830425 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 **251 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 251 · 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..5427c24779 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, 251 LLM providers, auto-fallback. | Layer | Location | Purpose | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/Dockerfile b/Dockerfile index adf3cf5e91..98e0a3215b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -114,6 +114,12 @@ LABEL org.opencontainers.image.title="omniroute" \ ENV NODE_ENV=production ENV PORT=20128 ENV HOSTNAME=0.0.0.0 +# Runtime heap ceiling. 1024MB is enough for normal traffic but can be tight +# for large fusion-combo panels (many models fanned out in parallel, each +# response buffered in full — see open-sse/services/fusion.ts::FUSION_DEFAULTS +# .maxPanel, issue #1905). Override at `docker run` time with +# `-e OMNIROUTE_MEMORY_MB=2048` (or higher) if you raise fusionTuning.maxPanel +# above the default cap. ENV OMNIROUTE_MEMORY_MB=1024 ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}" diff --git a/README.md b/README.md index 0ccfa90a47..d19b353f43 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 **251 providers** — **90+ free** — through one endpoint. **Plug Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini. Auto-fallback.**
@@ -149,11 +149,11 @@ -> One endpoint. **250 providers.** Never stop building — and let OmniRoute pick the cheapest one that works. +> One endpoint. **251 providers.** Never stop building — and let OmniRoute pick the cheapest one that works. - + @@ -314,7 +314,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% | @@ -399,7 +399,7 @@ Result: 4 layers of fallback = zero downtime -> 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: **251 providers**, **90+ with a free tier**, **11 free forever**.
@@ -907,7 +907,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 251 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/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs index 2a0787bd04..1dc442270e 100644 --- a/bin/cli/runtime/nativeDeps.mjs +++ b/bin/cli/runtime/nativeDeps.mjs @@ -52,6 +52,31 @@ export function hasModule(name) { return existsSync(join(runtimeModules(), name, "package.json")); } +/** + * Probe whether a native addon (.node) file can actually be dlopen'd by the Node runtime that + * is going to load it. Runs in a throwaway subprocess so a real ABI mismatch (which can segfault + * the process instead of throwing) never takes down the caller — only the probe subprocess. + */ +function probeNativeBinaryLoadable(binary) { + try { + const res = spawnSync( + process.execPath, + [ + "-e", + "try { require(process.argv[1]); process.exit(0); } catch (e) { process.exit(1); }", + binary, + ], + { timeout: 10_000, stdio: "ignore" } + ); + // status === 0 means require() (and therefore dlopen) succeeded. Anything else — a thrown + // ERR_DLOPEN_FAILED/NODE_MODULE_VERSION mismatch (status 1) or a crash (status null with a + // signal, e.g. SIGSEGV) — means the binary is not safe to load. + return res.status === 0; + } catch { + return false; + } +} + export function isBetterSqliteBinaryValid() { const binary = join( runtimeModules(), @@ -68,10 +93,18 @@ export function isBetterSqliteBinaryValid() { closeSync(fd); const magic = buf.toString("hex"); const os = platform(); - if (os === "linux") return magic.startsWith("7f454c46"); // ELF - if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O - if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ - return true; + let formatOk; + if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF + else if (os === "darwin") + formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O + else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ + else formatOk = true; + if (!formatOk) return false; + // File-format magic bytes alone do not guarantee the binary was built for the Node ABI + // (NODE_MODULE_VERSION) that will load it — a stale/foreign-ABI binary passes the header + // check and then crashes (segfault) on load instead of triggering a rebuild. Actually + // attempt to load it, isolated in a subprocess. + return probeNativeBinaryLoadable(binary); } catch { return false; } 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/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/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/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/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/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/1037-vercel-relay-sso-protection-check.md b/changelog.d/fixes/1037-vercel-relay-sso-protection-check.md new file mode 100644 index 0000000000..e03d00dd02 --- /dev/null +++ b/changelog.d/fixes/1037-vercel-relay-sso-protection-check.md @@ -0,0 +1 @@ +- **fix(api):** Vercel Relay deploy now checks the Deployment Protection (SSO) PATCH response and surfaces `ssoProtectionWarning` when Vercel rejects it, instead of silently activating a relay that later returns an undiagnosed `403 Access denied`. (thanks @ricatix) 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/1382-streaming-empty-content-block.md b/changelog.d/fixes/1382-streaming-empty-content-block.md new file mode 100644 index 0000000000..c224cd2991 --- /dev/null +++ b/changelog.d/fixes/1382-streaming-empty-content-block.md @@ -0,0 +1 @@ +- **fix(combo):** streaming Claude responses whose content block opens (`content_block_start`) and closes with no usable text/tool_use — a shape some upstreams return for tool-heavy requests on HTTP 200 — are now detected by `validateResponseQuality`'s SSE peek and trigger combo failover instead of being forwarded to the client as a silent empty completion (thanks @heishen6). diff --git a/changelog.d/fixes/1556-openai-regex-lookaround.md b/changelog.d/fixes/1556-openai-regex-lookaround.md new file mode 100644 index 0000000000..7045d0917d --- /dev/null +++ b/changelog.d/fixes/1556-openai-regex-lookaround.md @@ -0,0 +1 @@ +- **fix(codex):** strip regex `pattern` lookaround (lookahead/lookbehind) from tool JSON Schemas on the Codex/OpenAI native passthrough path — previously only the translated-request path coerced tool schemas, so a `pattern` like `^(?=.*@).+$` reached OpenAI unmodified and was rejected with `regex lookaround is not supported`. (thanks @evinjohnn) (#7100) diff --git a/changelog.d/fixes/1809-mitm-stop-dns-before-kill.md b/changelog.d/fixes/1809-mitm-stop-dns-before-kill.md new file mode 100644 index 0000000000..06e42e461c --- /dev/null +++ b/changelog.d/fixes/1809-mitm-stop-dns-before-kill.md @@ -0,0 +1 @@ +- **fix(cli):** `stopMitm()` now removes /etc/hosts DNS-spoof entries before killing the MITM server process, closing the window where a client's DNS still resolved a target host to `127.0.0.1` while nothing was listening there — the cause of `connect ECONNREFUSED 127.0.0.1:443` right after stopping the MITM proxy (thanks @dionisius95). diff --git a/changelog.d/fixes/1811-composer-space-sep.md b/changelog.d/fixes/1811-composer-space-sep.md new file mode 100644 index 0000000000..856fab9e17 --- /dev/null +++ b/changelog.d/fixes/1811-composer-space-sep.md @@ -0,0 +1 @@ +- **fix(sse):** Cursor Composer/Auto tool calls that separate the arg name and value with a space instead of a newline (e.g. `path /Users/.../test`) no longer produce empty-valued, malformed argument keys, fixing silent no-op Write/tool calls. (thanks @way-art) 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/1905-fusion-panel-oom.md b/changelog.d/fixes/1905-fusion-panel-oom.md new file mode 100644 index 0000000000..68bc6dbfbc --- /dev/null +++ b/changelog.d/fixes/1905-fusion-panel-oom.md @@ -0,0 +1 @@ +- **fix(combos):** fusion combos now reject an oversized panel (>40 models by default, tunable via `fusionTuning.maxPanel`) with a clean 400 before fanning out, instead of buffering dozens of concurrent full responses in memory and OOM-crashing the whole container. (thanks @fontvu) diff --git a/changelog.d/fixes/2032-openai-compatible-check-404-warning.md b/changelog.d/fixes/2032-openai-compatible-check-404-warning.md new file mode 100644 index 0000000000..5933bf065f --- /dev/null +++ b/changelog.d/fixes/2032-openai-compatible-check-404-warning.md @@ -0,0 +1 @@ +- **fix(providers):** the OpenAI-compatible "Check" validation flow now surfaces a warning when the chat-completions probe returns `404` (e.g. `model_not_found`) instead of silently passing as `Valid` — a bogus/non-standard model id (Featherless/OpenRouter-style `vendor/model` typos) previously went undetected at Check time and only surfaced once a real request tripped the per-model lockout. (thanks @advane204f) 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/2413-preserve-agent-headers.md b/changelog.d/fixes/2413-preserve-agent-headers.md new file mode 100644 index 0000000000..6353f5dd77 --- /dev/null +++ b/changelog.d/fixes/2413-preserve-agent-headers.md @@ -0,0 +1 @@ +- **fix(executors):** forward agent-supplied `X-Session-ID`/`X-Title` metadata headers to upstream providers — previously dropped for every client outside the `x-opencode-*` allowlist. (thanks @chitholian) (#7104) diff --git a/changelog.d/fixes/2461-antigravity-streaming-403-raw-bytes.md b/changelog.d/fixes/2461-antigravity-streaming-403-raw-bytes.md new file mode 100644 index 0000000000..84cf50b3de --- /dev/null +++ b/changelog.d/fixes/2461-antigravity-streaming-403-raw-bytes.md @@ -0,0 +1 @@ +- **fix(sse):** Antigravity streaming requests that hit a non-ok upstream response (e.g. a 403) no longer pipe the raw upstream bytes straight through to the client — a binary/non-UTF8 error body (observed as gzip-magic-byte garbage) is now routed through the same sanitized `buildAntigravityUpstreamError()` path the non-streaming branch already used, instead of corrupting the client-visible error message. Regression guard: `tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts` — thanks @Duongkhanhtool 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/2493-better-sqlite3-abi-validation.md b/changelog.d/fixes/2493-better-sqlite3-abi-validation.md new file mode 100644 index 0000000000..dba246809d --- /dev/null +++ b/changelog.d/fixes/2493-better-sqlite3-abi-validation.md @@ -0,0 +1 @@ +- **fix(cli):** the runtime self-heal now verifies a cached `better-sqlite3` native binary actually loads for the running Node before trusting it — the old check only inspected the file's magic bytes (ELF/Mach-O/PE header), so a binary built for a different Node ABI passed validation and segfaulted the process on first use instead of triggering a rebuild. (thanks @mrprohack) (#7105) 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/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/7098-mimo-thinking-model-reasoning-content.md b/changelog.d/fixes/7098-mimo-thinking-model-reasoning-content.md new file mode 100644 index 0000000000..ddd454f252 --- /dev/null +++ b/changelog.d/fixes/7098-mimo-thinking-model-reasoning-content.md @@ -0,0 +1 @@ +- **fix(sse):** xiaomi-tokenplan `mimo` models (e.g. `mimo-v2.5-pro`) are now recognized as thinking-mode upstreams that require `reasoning_content` echoed back on every assistant turn, fixing a persistent `400 reasoning_content must be passed back` error on multi-turn conversations ([#7098](https://github.com/diegosouzapw/OmniRoute/pull/7098)) — thanks @xxue-z 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/fixes/pack-boot-runtimetimeouts-sibling.md b/changelog.d/fixes/pack-boot-runtimetimeouts-sibling.md new file mode 100644 index 0000000000..3fd32f4f02 --- /dev/null +++ b/changelog.d/fixes/pack-boot-runtimetimeouts-sibling.md @@ -0,0 +1 @@ +- **Build**: the packed tarball boots again — #7191's `../../src/…runtimeTimeouts.ts` import in `standalone-server-ws.mjs` escaped the package after the dist-root copy (`ERR_MODULE_NOT_FOUND` on every boot, #7065 class, caught live by the new `check:pack-boot` gate); the helper now lives in the shipped sibling `main-server-timeouts.mjs` (parity-tested against the canonical TS implementation) and the closure test bans package-escaping `../` imports in npm-shipped wrappers diff --git a/changelog.d/fixes/port-2132-headroom-developer-role.md b/changelog.d/fixes/port-2132-headroom-developer-role.md new file mode 100644 index 0000000000..c9064e43c5 --- /dev/null +++ b/changelog.d/fixes/port-2132-headroom-developer-role.md @@ -0,0 +1 @@ +- **fix(compression):** the Headroom SmartCrusher tabular-compaction guard now also skips `role: "developer"` messages, not just `role: "system"` — Codex CLI sends its instructions/tool-schema turn as `developer` (the Responses-API equivalent of `system`), so an embedded JSON array (e.g. an `update_plan` example) could get tabular-compacted, corrupting the model's tool-calling instructions and breaking Codex CLI plan mode. (thanks @SingCJ) diff --git a/changelog.d/fixes/register-cli-skill-collector.md b/changelog.d/fixes/register-cli-skill-collector.md new file mode 100644 index 0000000000..f7ba19ba92 --- /dev/null +++ b/changelog.d/fixes/register-cli-skill-collector.md @@ -0,0 +1 @@ +- **Skills**: register `cli-skill-collector` in the agent-skills catalog (types union, curated entry, CLI id list) — #6294 shipped the `skills/cli-skill-collector/` directory without the catalog registration, so it was unreachable via the API and Integration CI failed on the catalog-integrity test; counts aligned (44 API+CLI, 45 with config) diff --git a/changelog.d/maintenance/7068-dependabot-ignore-typescript-major.md b/changelog.d/maintenance/7068-dependabot-ignore-typescript-major.md new file mode 100644 index 0000000000..ae20696c5e --- /dev/null +++ b/changelog.d/maintenance/7068-dependabot-ignore-typescript-major.md @@ -0,0 +1 @@ +- **chore(ci):** stop dependabot from proposing `typescript` majors — `typescript-eslint` pins a hard peer upper bound (`>=4.8.4 <6.1.0`), so a TS 7 bump violates the peer and takes the whole toolchain red at once. #7068 grouped it with 6 harmless dev bumps and blocked all of them. TS majors now migrate intentionally, in their own PR. diff --git a/changelog.d/maintenance/7291-quota-card-grid-density-6815-guard.md b/changelog.d/maintenance/7291-quota-card-grid-density-6815-guard.md new file mode 100644 index 0000000000..6652969e7d --- /dev/null +++ b/changelog.d/maintenance/7291-quota-card-grid-density-6815-guard.md @@ -0,0 +1 @@ +- **test(dashboard):** restore dedicated regression coverage for #6815's `QuotaCardGrid` multi-column density guarantee, decoupled from the specific Tailwind token so it survives the #7027 auto-fit migration ([#7291](https://github.com/diegosouzapw/OmniRoute/pull/7291)) 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/7307-rehome-open-prs-script.md b/changelog.d/maintenance/7307-rehome-open-prs-script.md new file mode 100644 index 0000000000..e20161f4bc --- /dev/null +++ b/changelog.d/maintenance/7307-rehome-open-prs-script.md @@ -0,0 +1 @@ +- **chore(release):** add `scripts/release/rehome-open-prs.mjs` — the Phase 0a.0b PR re-home, scripted with a read-back after every retarget. `gh pr edit --base` exits 0 without applying (v3.8.42), `gh pr list` silently caps at 30, and the v3.8.49 freeze had 148 open PRs to move — none of which a hand-run loop survives reliably. 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/changelog.d/maintenance/coverage-job-timeout.md b/changelog.d/maintenance/coverage-job-timeout.md new file mode 100644 index 0000000000..ce36f15f3f --- /dev/null +++ b/changelog.d/maintenance/coverage-job-timeout.md @@ -0,0 +1 @@ +- **CI**: raise the Coverage job timeout 10→20min — the lcov reporter added for Codecov/Sonar (#7114) pushed the 8-shard report merge past the old cap, and three release-tip runs died at exactly 10min as job-timeout "cancelled" diff --git a/changelog.d/maintenance/electron-win-advisory.md b/changelog.d/maintenance/electron-win-advisory.md new file mode 100644 index 0000000000..c5f29e8404 --- /dev/null +++ b/changelog.d/maintenance/electron-win-advisory.md @@ -0,0 +1 @@ +- **CI**: the new Electron Windows prepare-bundle leg (WS1.5) is advisory while it matures — its first real run failed with the error swallowed by pwsh; the step now runs under bash (stderr captured) with `continue-on-error`, tracked for promotion once green diff --git a/changelog.d/maintenance/tighten-coverage-baseline.md b/changelog.d/maintenance/tighten-coverage-baseline.md new file mode 100644 index 0000000000..119eb1c856 --- /dev/null +++ b/changelog.d/maintenance/tighten-coverage-baseline.md @@ -0,0 +1 @@ +- **chore(quality):** tighten the coverage ratchet to the CI's real numbers (branches 73→78.1, statements/lines 76.5→80.8, functions 82→86.44, plus 7 per-module floors). The gate had been asking for this in plain text; the values come from the merged-coverage run on `main`, not a local run (local measures ~68% vs CI's ~80% — the baseline's own note warns about that gap). 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..c6cea123af 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,6 @@ { + "_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 +164,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 +215,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, @@ -267,7 +269,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, diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 225dd45f18..ee2c81c6d3 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -27,22 +27,22 @@ "eps": 0 }, "coverage.statements": { - "value": 76.5, + "value": 80.8, "direction": "up", "tightenSlack": 5 }, "coverage.lines": { - "value": 76.5, + "value": 80.8, "direction": "up", "tightenSlack": 5 }, "coverage.functions": { - "value": 82, + "value": 86.44, "direction": "up", "tightenSlack": 5 }, "coverage.branches": { - "value": 73, + "value": 78.1, "direction": "up", "eps": 1.5, "tightenSlack": 5 @@ -54,49 +54,49 @@ "tightenSlack": 10 }, "coverage.combo.lines": { - "value": 80, + "value": 85.42, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.accountFallback.lines": { - "value": 88, + "value": 96.78, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.auth.lines": { - "value": 90, + "value": 92.55, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.routeGuard.lines": { - "value": 94, + "value": 98.73, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.error.lines": { - "value": 88, + "value": 92.13, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.publicCreds.lines": { - "value": 92, + "value": 99.07, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.circuitBreaker.lines": { - "value": 92, + "value": 95.09, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "openapiCoverage.pct": { - "value": 38.0, + "value": 38, "direction": "up", "eps": 0.5, "_tighten_2026_07_04_v3844_release": "36.9 -> 39.3 (aperto exigido pelo --require-tighten no PR de release #5925). A cobertura OpenAPI melhorou no ciclo (9 rotas documentadas em 8fb020676 + as rotas novas de #5939/#5817/#6034/#5998 documentadas junto das features). 39.3 = valor medido pelo CI Quality Ratchet no run 28708141003 (tip 00c55afcb).", @@ -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/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 59ef2b4fc6..93321a43a5 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 @@ -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/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..f2f861d661 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1077,7 +1077,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..8e9f92350c 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: **251**. See category breakdown below. ## Categories @@ -88,7 +88,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) (168) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -184,6 +184,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 | 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/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/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 41850fe2cf..8c61ef02c1 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -6,6 +6,7 @@ */ import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts"; +import { KIE_IMAGE_MODELS } from "./providers/registry/kie/imageModels.ts"; interface ImageModelEntry { id: string; @@ -311,44 +312,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 +326,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", diff --git a/open-sse/config/providerErrorRules.ts b/open-sse/config/providerErrorRules.ts index d9eb14535b..dde56a53d2 100644 --- a/open-sse/config/providerErrorRules.ts +++ b/open-sse/config/providerErrorRules.ts @@ -130,6 +130,31 @@ function buildMinimaxRules(): ProviderErrorRule[] { ]; } +// ─── Cloudflare Workers AI ───────────────────────────────────────────────────── +// Free tier = 10,000 Neurons/day, shared across the WHOLE account +// (docs/reference/FREE_TIERS.md; official: developers.cloudflare.com/ +// workers-ai/platform/errors/). The exhaustion body doesn't match any +// QUOTA_PATTERNS keyword so it falls through to rate_limit and gets +// retried every ~60s against a budget that only resets at UTC midnight. +// Issue #6980. +function buildCloudflareAiRules(): ProviderErrorRule[] { + return [ + { + id: "cloudflare-ai-daily-neuron-allocation", + match: ({ status, body }) => { + if (status !== 429) return null; + const text = JSON.stringify(body ?? "").toLowerCase(); + // Body: "you have used up your daily free allocation of 10,000 neurons, + // please upgrade to Cloudflare's Workers Paid plan..." + if (!text.includes("daily free allocation")) return null; + // No cooldownMs: recordModelLockoutFailure already sets + // quota_exhausted without one to "next UTC midnight". + return { reason: "quota_exhausted", scope: "connection" }; + }, + }, + ]; +} + /** * Global registry. Provider name → ordered list of rules (first match wins). * Add new providers here; the matcher in classifyError will pick them up @@ -141,6 +166,7 @@ export const providerRuleRegistry = new Map([ ["opencode-cli", buildOpencodeRules()], ["minimax", buildMinimaxRules()], ["minimax-passthrough", buildMinimaxRules()], + ["cloudflare-ai", buildCloudflareAiRules()], ]); /** @@ -194,7 +220,9 @@ export function getProviderErrorRuleMatch( */ export function parseResetCountdownMs(text: string): number | null { if (typeof text !== "string" || text.length === 0) return null; - const match = text.match(/resets?\s+in\s+(\d+)\s+(day|days|hour|hours|minute|minutes|second|seconds)\b/); + const match = text.match( + /resets?\s+in\s+(\d+)\s+(day|days|hour|hours|minute|minutes|second|seconds)\b/ + ); if (!match) return null; const n = Number(match[1]); if (!Number.isFinite(n) || n <= 0) return null; 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..8763f6326f 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -151,6 +151,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"; @@ -336,6 +337,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/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 85a6a05471..dd9c21150c 100644 --- a/open-sse/config/providers/registry/grok-cli/index.ts +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -18,7 +18,13 @@ export const grok_cliProvider: RegistryEntry = { // 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"], + unsupportedParams: [ + "presencePenalty", + "frequencyPenalty", + "logprobs", + "topLogprobs", + "reasoningEffort", + ], }, { id: "grok-composer-2.5-fast", @@ -27,7 +33,13 @@ export const grok_cliProvider: RegistryEntry = { // 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"], + 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/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/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/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/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 442699b047..973240b2ae 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -205,6 +205,19 @@ export const VIDEO_PROVIDERS: Record = { format: "dashscope-video", models: [{ id: "wan2.7-t2v", name: "Wan 2.7 T2V" }], }, + + 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/antigravity.ts b/open-sse/executors/antigravity.ts index 494d750836..49b1d8ff37 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -1591,6 +1591,34 @@ export class AntigravityExecutor extends BaseExecutor { }; } + // #2461: a non-ok upstream response (e.g. 403) must never be piped through the + // streaming pass-through below as if it were an SSE body. Google occasionally + // returns non-UTF8/binary error bodies (observed: gzip-magic-byte payloads) for + // 403s on this endpoint; reading/forwarding those raw bytes corrupts the + // client-visible error message. Mirror the non-streaming branch above and build + // a sanitized JSON error via buildAntigravityUpstreamError (hard rule #12) + // instead of streaming unknown bytes straight through. + if (!response.ok) { + const rawBody = await response + .clone() + .text() + .catch(() => ""); + const errorBody = buildAntigravityUpstreamError( + response.status, + response.statusText, + rawBody + ); + return { + response: new Response(JSON.stringify(errorBody), { + status: response.status, + headers: { "Content-Type": "application/json" }, + }), + url, + headers: finalHeaders, + transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + }; + } + // Streaming path: wrap the response body in a pass-through TransformStream // that extracts remainingCredits from the final SSE chunk(s) without // consuming the stream. The client receives the unmodified SSE data. 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/codex/tools.ts b/open-sse/executors/codex/tools.ts index 3337000359..52d01e9d87 100644 --- a/open-sse/executors/codex/tools.ts +++ b/open-sse/executors/codex/tools.ts @@ -1,6 +1,8 @@ // Codex Responses-API tool normalization (hosted-tool passthrough + free-plan gating). // Extracted verbatim from codex.ts. Self-contained (console.debug only). +import { stripUnsupportedRegexPatterns } from "../../translator/helpers/schemaCoercion.ts"; + // Responses-API hosted tool types that OpenAI/Codex executes server-side. // These arrive shaped as `{ type, ...params }` with no `function` object and no `name` — // e.g. Codex CLI injects `{ type: "image_generation", output_format: "png" }` or @@ -133,6 +135,11 @@ export function normalizeCodexTools( ? functionObject.strict : undefined; + // Codex/OpenAI Responses API rejects `pattern` fields using regex lookaround + // (e.g. `^(?=.*@).+$`) with a 400 "regex lookaround is not supported" error. + // Strip those before the schema reaches upstream (9router#1556). + const sanitizedParameters = stripUnsupportedRegexPatterns(parameters); + // Rewrite in-place to Responses format for (const key of Object.keys(tool)) { delete tool[key]; @@ -140,7 +147,7 @@ export function normalizeCodexTools( tool.type = "function"; tool.name = name.slice(0, 128); if (description) tool.description = description; - tool.parameters = parameters; + tool.parameters = sanitizedParameters; if (strict !== undefined) tool.strict = strict; validToolNames.add(name); 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/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 44d0d6c43a..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,13 +15,61 @@ 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() { super("grok-cli", PROVIDERS["grok-cli"]); @@ -102,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); @@ -112,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), @@ -147,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")); @@ -169,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), 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..3b8cfc6899 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"; @@ -62,6 +63,7 @@ import { CHATGPT_WEB_IMAGE_ID_RE, } from "./imageGeneration/providers/chatgptWeb.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; +import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; interface KieImageOptions { @@ -486,7 +488,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") { @@ -535,6 +546,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/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/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..1bccc54dd5 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -1,24 +1,17 @@ /** * 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 { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts"; import { getExecutor } from "../executors/index.ts"; import { isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { @@ -31,6 +24,7 @@ import { pollComfyResult, fetchComfyOutput, extractComfyOutputFiles, + resolveComfyUiBaseUrl, } from "../utils/comfyuiClient.ts"; import { saveCallLog } from "@/lib/usageDb"; import { sanitizeErrorMessage } from "../utils/error.ts"; @@ -67,7 +61,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") { @@ -112,6 +115,10 @@ export async function handleVideoGeneration({ body, credentials, log }) { }); } + if (providerConfig.format === "xai-video") { + return handleXaiVideoGeneration({ model, provider, providerConfig, body, credentials, log }); + } + return { success: false, status: 400, 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/autoCombo/__tests__/speedRanking.test.ts b/open-sse/services/autoCombo/__tests__/speedRanking.test.ts index 1599e36b82..e3a4ad85f2 100644 --- a/open-sse/services/autoCombo/__tests__/speedRanking.test.ts +++ b/open-sse/services/autoCombo/__tests__/speedRanking.test.ts @@ -165,15 +165,34 @@ describe("rankBySpeed — factor breakdown", () => { }); it("falls back to 0.5 per missing metric so new providers are not crushed", () => { - const ranked = rankBySpeed([candidate({ provider: "fresh", model: "m" })]); + const ranked = rankBySpeed([ + candidate({ + provider: "fresh", + model: "m", + p95LatencyMs: undefined, + latencyStdDev: undefined, + }), + ]); expect(ranked).toHaveLength(1); // No telemetry at all → weighted sum lands near 0.5 with reliability multiplier 1 expect(ranked[0].factors.reliability).toBe(1); expect(ranked[0].factors.health).toBe(1); expect(ranked[0].factors.ttft).toBe(0.5); - expect(ranked[0].factors.tps).toBe(0.5); - }); -}); + expect(ranked[0].factors.tps).toBe(0.5); + }); + + it("uses p95 latency when TTFT and E2E telemetry are unavailable", () => { + const ranked = rankBySpeed([ + candidate({ provider: "slow-tail", model: "m", p95LatencyMs: 4000 }), + candidate({ provider: "fast-tail", model: "m", p95LatencyMs: 1000 }), + ]); + const fast = ranked.find((entry) => entry.provider === "fast-tail"); + const slow = ranked.find((entry) => entry.provider === "slow-tail"); + + expect(fast?.factors.ttft).toBeGreaterThan(slow?.factors.ttft ?? 1); + expect(fast?.factors.e2e).toBeGreaterThan(slow?.factors.e2e ?? 1); + }); +}); describe("rankBySpeed — weight overrides", () => { it("respects caller weight overrides (e.g. heavy TTFT bias)", () => { @@ -223,4 +242,4 @@ describe("pickFastest", () => { const winner = pickFastest([slow, fast]); expect(winner?.provider).toBe("fast"); }); -}); \ No newline at end of file +}); diff --git a/open-sse/services/autoCombo/speedRanking.ts b/open-sse/services/autoCombo/speedRanking.ts index 514420a5a0..a3d7d81027 100644 --- a/open-sse/services/autoCombo/speedRanking.ts +++ b/open-sse/services/autoCombo/speedRanking.ts @@ -211,9 +211,15 @@ function speedFactorsFor( failureRate: number ): SpeedFactors { return { - ttft: lowerIsBetter(positiveFinite(candidate.avgTtftMs), maxima.ttft), + ttft: lowerIsBetter( + positiveFinite(candidate.avgTtftMs) ?? positiveFinite(candidate.p95LatencyMs), + maxima.ttft + ), tps: higherIsBetter(positiveFinite(candidate.avgTokensPerSecond), maxima.tps), - e2e: lowerIsBetter(positiveFinite(candidate.avgE2ELatencyMs), maxima.e2e), + e2e: lowerIsBetter( + positiveFinite(candidate.avgE2ELatencyMs) ?? positiveFinite(candidate.p95LatencyMs), + maxima.e2e + ), p95: lowerIsBetter(positiveFinite(candidate.p95LatencyMs), maxima.p95), health: healthScoreFor(candidate.circuitBreakerState), reliability: clamp01(1 - failureRate), 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 2b56146479..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"; @@ -54,6 +56,114 @@ function extractEnvelopeErrorText(json: Record): string | null return parts.length > 0 ? parts.join(" ") : null; } +/** Mutable lifecycle flags threaded through {@link applySseLifecycleEvent}. */ +interface SseLifecycleFlags { + hasMessageStart: boolean; + hasContentBlock: boolean; + hasRealContent: boolean; + hasLifecycleEnd: boolean; +} + +/** Read `parsed.` as a nested object bag, or null when absent/not an object. */ +function asObject(parsed: Record, key: string): Record | null { + const value = parsed[key]; + return value && typeof value === "object" ? (value as Record) : null; +} + +/** + * A content_block_start is real signal only for tool_use / redacted_thinking — + * a tool call is meaningful even before its input_json_delta arrives. text and + * thinking blocks routinely open empty; keep peeking for a delta instead. + */ +function contentBlockStartIsRealSignal(parsed: Record): boolean { + const blockType = asObject(parsed, "content_block")?.type; + return blockType === "tool_use" || blockType === "redacted_thinking"; +} + +/** + * A content_block_delta is real signal when it carries non-empty text/thinking, + * or any input_json_delta fragment — even an empty-string first chunk proves a + * tool_use block is actively streaming its arguments. + */ +function contentBlockDeltaIsRealSignal(parsed: Record): boolean { + const delta = asObject(parsed, "delta"); + if (!delta) return false; + const deltaType = typeof delta.type === "string" ? delta.type : ""; + if (deltaType === "input_json_delta") return true; + if (deltaType !== "text_delta" && deltaType !== "thinking_delta") return false; + const text = delta.text ?? delta.thinking; + return typeof text === "string" && text.length > 0; +} + +/** A message_delta closes the lifecycle once it carries a stop_reason. */ +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 + * keep that function under the complexity/line ratchets — logic unchanged. + * + * Returns true once REAL content (not just an empty content_block_start) is + * detected — the caller should stop peeking and treat the stream as non-empty. + */ +function applySseLifecycleEvent( + eventType: string, + parsed: Record, + flags: SseLifecycleFlags +): boolean { + switch (eventType) { + case "message_start": + flags.hasMessageStart = true; + return false; + case "content_block_start": + flags.hasContentBlock = true; + if (!contentBlockStartIsRealSignal(parsed)) return false; + flags.hasRealContent = true; + return true; + case "content_block_delta": + flags.hasContentBlock = true; + if (!contentBlockDeltaIsRealSignal(parsed)) return false; + flags.hasRealContent = true; + return true; + case "content_block_stop": + flags.hasContentBlock = true; + return false; + case "message_stop": + flags.hasLifecycleEnd = true; + return false; + case "message_delta": + if (messageDeltaEndsLifecycle(parsed)) flags.hasLifecycleEnd = true; + return false; + default: + return false; + } +} + function responsesApiOutputHasContent(output: unknown): boolean { return ( Array.isArray(output) && @@ -125,11 +235,26 @@ export async function validateResponseQuality( let decodedSoFar = ""; // SSE lifecycle state. - let hasMessageStart = false; - let hasContentBlock = false; - let hasLifecycleEnd = false; + // + // #1382: hasContentBlock only means "a content_block_* event was observed" + // — it does NOT mean the block carried usable content. A content_block_start + // for a text/thinking block routinely opens with empty text (real content + // arrives via subsequent content_block_delta events); some upstreams + // (reported: DeepSeek/GLM via claude→openai translation on tool-heavy + // requests) open and close such a block without ever emitting a delta. + // hasRealContent tracks whether we've actually seen usable output: a + // tool_use/redacted_thinking block start (self-evidently real, even before + // any delta), or a delta carrying non-empty text/thinking/tool-input. + const sse: SseLifecycleFlags = { + hasMessageStart: false, + hasContentBlock: false, + hasRealContent: false, + hasLifecycleEnd: false, + }; 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 = ""; @@ -138,8 +263,9 @@ export async function validateResponseQuality( * flags in the closure. The last (potentially incomplete) line is kept in * `decodedSoFar` for the next iteration. * - * Returns true when a content_block_* event is detected — the caller - * should stop peeking and treat the stream as non-empty. + * Returns true once REAL content (not just an empty content_block_start) + * is detected — the caller should stop peeking and treat the stream as + * non-empty. */ function parseAccumulatedSse(): boolean { const lines = decodedSoFar.split(/\r?\n/); @@ -160,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 { @@ -169,6 +301,8 @@ export async function validateResponseQuality( continue; } + applyOpenAiLifecycleEvent(parsed, openAi); + const eventType = (typeof parsed.type === "string" ? parsed.type : null) || pendingEventType || ""; pendingEventType = ""; @@ -177,32 +311,8 @@ export async function validateResponseQuality( return true; } - switch (eventType) { - case "message_start": - hasMessageStart = true; - break; - case "content_block_start": - case "content_block_delta": - case "content_block_stop": - hasContentBlock = true; - // Signal caller to stop buffering immediately. - return true; - case "message_stop": - hasLifecycleEnd = true; - break; - case "message_delta": { - const delta = parsed.delta; - if ( - delta && - typeof delta === "object" && - (delta as Record).stop_reason != null - ) { - hasLifecycleEnd = true; - } - break; - } - default: - break; + if (applySseLifecycleEvent(eventType, parsed, sse)) { + return true; } } return false; @@ -258,11 +368,17 @@ export async function validateResponseQuality( if (decodedSoFar.trim()) decodedSoFar += "\n\n"; parseAccumulatedSse(); - if (hasMessageStart && hasLifecycleEnd && !hasContentBlock) { - // Complete Claude lifecycle with zero content blocks → failover. + if (sse.hasMessageStart && sse.hasLifecycleEnd && !sse.hasRealContent) { + // Complete Claude lifecycle with zero content blocks, or with + // content_block_start/stop pairs that never carried real text/ + // thinking/tool_use content (#1382 — tool-heavy claude→openai + // requests against upstreams like DeepSeek/GLM can "complete" a + // lifecycle around an empty block) → failover. log.warn?.( "COMBO", - "Streaming Claude response has complete lifecycle but zero content blocks (content_filter?) — marking as invalid for combo failover" + sse.hasContentBlock + ? "Streaming Claude response has complete lifecycle but its content block(s) carried no usable text/tool_use — marking as invalid for combo failover" + : "Streaming Claude response has complete lifecycle but zero content blocks (content_filter?) — marking as invalid for combo failover" ); return { valid: false, reason: "streaming empty content block" }; } @@ -273,7 +389,7 @@ export async function validateResponseQuality( // (an explicit `data: [DONE]`, ping/metadata events, an incomplete // Claude lifecycle) keep the pass-through contract (#3399/#3685): // those are handled by the stream-readiness timeout, not failover. - if (!anyContentFound && !hasContentBlock && !sawAnyBytes) { + if (!anyContentFound && !sse.hasContentBlock && !sawAnyBytes) { log.warn?.( "COMBO", "Streaming response ended with no recognized content — marking as invalid for combo failover" @@ -281,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/headroom/smartcrusher.ts b/open-sse/services/compression/engines/headroom/smartcrusher.ts index c40ea82928..a14c851b6b 100644 --- a/open-sse/services/compression/engines/headroom/smartcrusher.ts +++ b/open-sse/services/compression/engines/headroom/smartcrusher.ts @@ -160,7 +160,7 @@ export function collectCompactableArrays( while ((m = regex.exec(text)) !== null) pushIfCompactable(m[1].trim()); }; for (const msg of messages) { - if (msg.role === "system") continue; + if (msg.role === "system" || msg.role === "developer") continue; if (typeof msg.content === "string") scanText(msg.content); else if (Array.isArray(msg.content)) { for (const part of msg.content) { @@ -218,8 +218,12 @@ export function crushMessages( let changed = false; const result = messages.map((msg): MessageLike => { - // Guard: never touch system messages - if (msg.role === "system") return { ...msg }; + // Guard: never touch system messages. "developer" is the Responses-API equivalent of + // "system" used by newer models (e.g. Codex CLI, see open-sse/executors/codex.ts) and + // carries the same kind of instructions/tool-schema content — compacting a JSON array + // embedded there (e.g. an update_plan example) can corrupt the model's tool-calling + // instructions (9router#2132: broke Codex CLI plan mode). + if (msg.role === "system" || msg.role === "developer") return { ...msg }; if (typeof msg.content === "string") { const crushed = crushText(msg.content, minRows); 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 000f5a21c5..dd92c3bdbc 100644 --- a/open-sse/services/fusion.ts +++ b/open-sse/services/fusion.ts @@ -27,12 +27,20 @@ export const FUSION_DEFAULTS = { minPanel: 2, // answers needed before stragglers get a grace window stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever + // Hard cap on panel size (issue #1905). Every panel member is fanned out in + // parallel and its full response text buffered in memory simultaneously — + // with the runtime heap capped (Dockerfile OMNIROUTE_MEMORY_MB, default + // 1024MB), a large panel (reported: ~73 models) with sizable concurrent + // responses can exceed the heap ceiling and OOM-crash the whole process. + // Reject oversized panels up front with a clean 400 instead. + maxPanel: 40, } as const; export type FusionTuning = { minPanel?: number; stragglerGraceMs?: number; panelHardTimeoutMs?: number; + maxPanel?: number; }; type Body = Record; @@ -136,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. @@ -222,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. * @@ -246,6 +272,21 @@ export async function handleFusionChat({ return handleSingleModel(body, panel[0]); } + // Reject an oversized panel BEFORE fan-out (issue #1905): fanning out N + // parallel calls and buffering N full response bodies at once is what + // drives the process into an OOM crash, not any one call in isolation. + const maxPanel = tuning?.maxPanel ?? FUSION_DEFAULTS.maxPanel; + if (panel.length > maxPanel) { + log.warn( + "FUSION", + `Combo "${comboName ?? ""}" panel=${panel.length} exceeds maxPanel=${maxPanel} — rejecting before fan-out (#1905)` + ); + return errorResponse( + 400, + `Fusion panel too large (${panel.length} models, max ${maxPanel}) — reduce the combo's target count or raise fusionTuning.maxPanel` + ); + } + const cfg = { minPanel: tuning?.minPanel ?? FUSION_DEFAULTS.minPanel, stragglerGraceMs: tuning?.stragglerGraceMs ?? FUSION_DEFAULTS.stragglerGraceMs, @@ -261,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/services/usage.ts b/open-sse/services/usage.ts index 39f8296082..1083748e9b 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -525,6 +525,7 @@ export const USAGE_FETCHER_PROVIDERS = [ "zai", "glmt", "opencode-go", + "ollama-cloud", "minimax", "minimax-cn", "crof", 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 9be3d930b6..3c2aa48bc8 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -24,6 +24,18 @@ const NUMERIC_SCHEMA_FIELDS = [ "multipleOf", ] as const; +// Fix (9router#1556): OpenAI/Codex's Responses API rejects JSON Schema `pattern` +// values that use regex lookaround (lookahead/lookbehind) with +// "Invalid JSON schema: regex lookaround is not supported.". IDE/SDK agent +// harnesses commonly emit lookahead patterns (e.g. `^(?=.*@).+$`), so any +// `pattern` field containing `(?=`, `(?!`, `(?<=`, or `(? [key, stripUnsupportedRegexPatterns(value)]) + ); +} + +/** + * Strip regex `pattern` constraints that use lookaround (lookahead/lookbehind), + * which OpenAI/Codex's Responses API rejects outright with a 400 + * ("Invalid JSON schema: regex lookaround is not supported."). Walks the same + * JSON Schema shape as `coerceSchemaNumericFields` (properties, items, + * anyOf/oneOf/allOf, $defs/definitions, etc). See 9router#1556. + */ +export function stripUnsupportedRegexPatterns(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((entry) => stripUnsupportedRegexPatterns(entry)); + } + if (!isPlainObject(schema)) return schema; + + const result: JsonRecord = { ...schema }; + + if (hasUnsupportedRegexLookaround(result.pattern)) { + delete result.pattern; + } + + for (const field of REGEX_STRIP_OBJECT_MAP_FIELDS) { + if (isPlainObject(result[field])) { + result[field] = stripRegexFromObjectMap(result[field]); + } + } + + for (const field of REGEX_STRIP_ARRAY_MAP_FIELDS) { + if (Array.isArray(result[field])) { + result[field] = (result[field] as unknown[]).map((entry) => + stripUnsupportedRegexPatterns(entry) + ); + } + } + + if (result.items !== undefined) { + result.items = stripUnsupportedRegexPatterns(result.items); + } + if (result.additionalProperties && typeof result.additionalProperties === "object") { + result.additionalProperties = stripUnsupportedRegexPatterns(result.additionalProperties); + } + if (isPlainObject(result.not)) { + result.not = stripUnsupportedRegexPatterns(result.not); + } + + return result; +} + export function sanitizeToolDescription(tool: unknown): unknown { if (!isPlainObject(tool)) return tool; @@ -209,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/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index 9b8b70f3c7..ab50607e75 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -349,7 +349,15 @@ function fixMissingToolResponses(messages) { // Convert single Claude message - returns single message or array of messages function convertClaudeMessage(msg, preserveCacheControl = false) { - const role = msg.role === "user" || msg.role === "tool" ? "user" : "assistant"; + // Preserve system role for mid-conversation system turns (#6954). + // Previously any role that wasn't "user" or "tool" was mapped to "assistant", + // which misattributed system messages as assistant output. + const role = + msg.role === "user" || msg.role === "tool" + ? "user" + : msg.role === "system" + ? "system" + : "assistant"; // Simple string content if (typeof msg.content === "string") { @@ -411,9 +419,7 @@ function convertClaudeMessage(msg, preserveCacheControl = false) { function: { name: block.name, arguments: - typeof block.input === "string" - ? block.input - : JSON.stringify(block.input || {}), + typeof block.input === "string" ? block.input : JSON.stringify(block.input || {}), }, }); break; 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/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index ff5dc4b6d5..1cfa99ddff 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -592,7 +592,24 @@ function getContentBlocksFromMessage( if (part.type === "text" && part.text) { blocks.push({ type: "text", text: part.text }); } else if (part.type === "thinking" || part.type === "redacted_thinking") { - // Preserve thinking blocks with signature + // #6953 — thinking blocks with signature:"" (empty string) come from non-Anthropic + // providers (codex/gpt-5.x). Anthropic rejects replayed `thinking` blocks that + // carry a foreign or fabricated signature with HTTP 400. Fabricating a default + // signature (the old behaviour) made the poisoning permanent: once a codex-served + // turn introduced a `signature:""` thinking block, every subsequent Anthropic leg + // attempt 400'd and the router silently fell back to codex forever. + // + // Fix: strip thinking blocks whose signature is the empty string — that explicit + // empty value is the hallmark of a synthesized block from a non-Anthropic provider. + // Thinking blocks with `signature: undefined` (field absent) are legitimate Claude- + // format messages and fall through to the DEFAULT_THINKING_CLAUDE_SIGNATURE fallback + // as before. + if (part.type === "thinking" && part.signature === "") { + continue; // drop — synthesized by non-Anthropic provider, no valid signature + } + if (part.type === "redacted_thinking" && part.data === "") { + continue; // drop — same: empty data from non-Anthropic provider + } blocks.push({ ...part, signature: part.signature || DEFAULT_THINKING_CLAUDE_SIGNATURE, diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 42fca56462..fdd214d06d 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -12,7 +12,7 @@ import { stripEmptyOptionalToolArgs, normalizeOutputIndex, normalizeUpstreamFailure, - extractResponsesReasoningSummaryText, + getVisibleResponsesReasoningSummaryText, } from "./openai-responses/pureHelpers.ts"; import { createEventEmitter } from "./openai-responses/eventEmitter.ts"; @@ -1070,7 +1070,11 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { !(state.reasoningItemsWithDelta instanceof Set && state.reasoningItemsWithDelta.size > 0); if (emittedForItem || emittedWithoutItemId) return null; - const summaryText = extractResponsesReasoningSummaryText(item); + // #7095/#7176 reconciliation: computed WITHOUT mutating `item`, so an + // encrypted-only reasoning item (and its `encrypted_content`) is never + // rewritten with a fabricated `summary` — the placeholder only feeds this + // synthetic client-facing delta chunk. + const summaryText = getVisibleResponsesReasoningSummaryText(item); if (!summaryText) return null; return buildResponsesReasoningDeltaChunk(state, summaryText); } diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts index 094d7dc58d..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]; } @@ -163,3 +172,28 @@ export function extractResponsesReasoningSummaryText(item) { ) .join(""); } + +// #7095/#7176 — when Codex exposes a reasoning item only as encrypted private +// reasoning (no plaintext summary), chat clients would otherwise see nothing in +// their thinking panel. Reconciles two goals that used to be in tension: +// - #7095 wants a visible placeholder in the chat client. +// - #7176 wants the upstream response item left untouched, so `encrypted_content` +// (needed by Codex for subsequent requests) is never overwritten by a +// fabricated `summary`. +// This function computes the placeholder text WITHOUT mutating `item` — callers +// use the returned text for synthetic client-facing events only. +const ENCRYPTED_REASONING_PLACEHOLDER = + "Codex is reasoning, but the upstream Responses API exposed this reasoning block only as encrypted private reasoning. OmniRoute cannot recover the plaintext."; + +export function getVisibleResponsesReasoningSummaryText(item) { + const existingSummary = extractResponsesReasoningSummaryText(item); + if (existingSummary) return existingSummary; + + const hasEncryptedReasoning = + item && + item.type === "reasoning" && + typeof item.encrypted_content === "string" && + item.encrypted_content.length > 0; + + return hasEncryptedReasoning ? ENCRYPTED_REASONING_PLACEHOLDER : ""; +} 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/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/composerToolCalls.ts b/open-sse/utils/composerToolCalls.ts index d688973ce3..916903a3ca 100644 --- a/open-sse/utils/composerToolCalls.ts +++ b/open-sse/utils/composerToolCalls.ts @@ -126,15 +126,28 @@ function parseInnerCall(body: string): { name: string; arguments: string } | nul const args: Record = {}; for (const seg of segments) { if (!seg) continue; - // Each segment is `arg_name\nvalue\n...`. The arg name is the first - // line; everything after the first newline is the value (verbatim, - // including additional newlines). + // Each segment is normally `arg_name\nvalue\n...`: the arg name is the + // first line, everything after the first newline is the value + // (verbatim, including additional newlines). Some live Composer/Auto + // captures instead separate the arg name and value with a single space + // on the same line (no newline at all in the segment) — fall back to + // splitting on the first whitespace boundary in that case so the value + // isn't swallowed into an empty-valued, space-containing "arg name". const idxNl = seg.indexOf("\n"); let argName: string; let argValue: string; if (idxNl < 0) { - argName = seg.trim(); - argValue = ""; + const idxSp = seg.search(/\s/); + if (idxSp < 0) { + argName = seg.trim(); + argValue = ""; + } else { + argName = seg.slice(0, idxSp).trim(); + // Unlike the newline-delimited form, a space-delimited value has no + // multi-line content to preserve — trim the trailing whitespace left + // over from the boundary with the next `<|tool▁sep|>` marker. + argValue = seg.slice(idxSp + 1).trim(); + } } else { argName = seg.slice(0, idxNl).trim(); argValue = seg.slice(idxNl + 1); 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/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts index c70180f927..729049c9a3 100644 --- a/open-sse/utils/earlyStreamKeepalive.ts +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -28,6 +28,12 @@ const ENCODER = new TextEncoder(); const KEEPALIVE_FRAME = ENCODER.encode(": omniroute-keepalive\n\n"); +// OpenAI-compatible keepalive: a syntactically valid empty streaming chunk. +// Some OpenAI-compatible clients parse every non-empty SSE line as JSON and +// reject legal SSE comments before their first provider chunk arrives. +export const OPENAI_KEEPALIVE_FRAME = ENCODER.encode( + 'data: {"id":"omniroute-keepalive","object":"chat.completion.chunk","created":0,"model":"omniroute","choices":[{"index":0,"delta":{},"finish_reason":null}]}\n\n' +); // Anthropic Messages-format keepalive: a REAL `ping` SSE event, not a comment. // Anthropic clients (Claude Code, the Anthropic SDK) reset their stream/first-token // watchdog on real SSE events but ignore SSE comments (`: ...`), so on a slow first diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts index 4e1221877c..8569c8512c 100644 --- a/open-sse/utils/opencodeHeaders.ts +++ b/open-sse/utils/opencodeHeaders.ts @@ -12,6 +12,15 @@ const OPENCODE_HEADER_KEYS = [ "x-opencode-client", ] as const; +/** + * Common agent-metadata headers used by non-OpenCode clients (custom agents/ + * providers) for upstream request tracking and attribution. Forwarded the same + * way as the x-opencode-* set: case-insensitive lookup, client value wins. + * Added for 9router#2413 — these were previously dropped for every client + * outside the OpenCode allowlist. + */ +const AGENT_METADATA_HEADER_KEYS = ["x-session-id", "x-title"] as const; + /** * Case-insensitive lookup for a header in a headers record. */ @@ -26,6 +35,8 @@ function findHeader(headers: Record, name: string): string | und * 1. Forwards User-Agent from clientHeaders via `setUserAgentHeader()` * 2. Forwards x-opencode-session, x-opencode-request, x-opencode-project, * x-opencode-client headers (case-insensitive match) + * 3. Forwards x-session-id, x-title agent-metadata headers (case-insensitive + * match) — common conventions used by non-OpenCode agent clients (9router#2413) * * @param headers - The outbound headers record to mutate * @param clientHeaders - The client-provided headers to forward from @@ -60,6 +71,14 @@ export function forwardOpencodeClientHeaders( } } + // 2b. Forward agent-metadata headers (x-session-id, x-title) — 9router#2413 + for (const headerName of AGENT_METADATA_HEADER_KEYS) { + const value = findHeader(clientHeaders, headerName); + if (value) { + headers[headerName] = value; + } + } + // 3. OpencodeExecutor-only: synthesize session/request id from fallback headers if (options?.synthesizeRequestId && !headers["x-opencode-session"]) { const sessionAffinity = diff --git a/open-sse/utils/passthroughTailProcessor.ts b/open-sse/utils/passthroughTailProcessor.ts index 82b57da8aa..845bc6e352 100644 --- a/open-sse/utils/passthroughTailProcessor.ts +++ b/open-sse/utils/passthroughTailProcessor.ts @@ -37,7 +37,6 @@ export type PassthroughTailProcessorContext = { appendPassthroughReasoning: (value: string) => void; getResponsesReasoningKey: (payload: Record) => string | null; markResponsesReasoningSummarySeen: (key: string) => void; - ensureVisibleResponsesReasoningSummary: (payload: Record) => boolean; emitSyntheticResponsesReasoningSummary: (payload: Record) => void; passthroughResponsesOutputItems: unknown[]; passthroughResponsesPendingFunctionCalls: Map; @@ -136,12 +135,8 @@ function handleResponsesTailPayload( } } if (parsed.type === "response.output_item.done" && parsed.item) { - const reasoningSummaryInjected = context.ensureVisibleResponsesReasoningSummary(parsed); context.emitSyntheticResponsesReasoningSummary(parsed); pushUniqueResponsesOutputItems(context.passthroughResponsesOutputItems, [parsed.item]); - if (reasoningSummaryInjected) { - output = `data: ${JSON.stringify(parsed)}\n\n`; - } const item = asRecord(parsed.item); if (item.type === "function_call") { const pendingKey = getFunctionCallPendingKey(item); 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/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts index 8fd9b13bd4..c2e8318be4 100644 --- a/open-sse/utils/reasoningContentInjector.ts +++ b/open-sse/utils/reasoningContentInjector.ts @@ -1,5 +1,6 @@ /** - * Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, ...) require + * Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, xiaomi-tokenplan + * mimo, ...) require * `reasoning_content` to be echoed back on every assistant message in the * conversation history. Standard OpenAI clients do not preserve that field * across turns, so we inject a non-empty placeholder before forwarding. @@ -26,6 +27,7 @@ const THINKING_MODEL_PATTERNS: RegExp[] = [ /\bkimi\b/i, /\bk2\b/i, // moonshot kimi k2 family alias /\bminimax\b/i, + /\bmimo\b/i, // xiaomi-tokenplan mimo family (e.g. xiaomi-tokenplan/mimo-v2.5-pro) ]; export function isThinkingMessageModel(model: string | undefined | null): boolean { 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/stream.ts b/open-sse/utils/stream.ts index 581f1f6342..974d532e9e 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -52,6 +52,7 @@ import { stripResponsesLifecycleEcho, } from "./responsesStreamHelpers.ts"; import { processBufferedPassthroughLine } from "./passthroughTailProcessor.ts"; +import { getVisibleResponsesReasoningSummaryText } from "../translator/response/openai-responses/pureHelpers.ts"; import { getAnyReasoningValue, getReadableReasoningValue, @@ -1006,49 +1007,6 @@ export function createSSEStream(options: StreamOptions = {}) { return responseId !== null && outputIndex !== null ? `${responseId}:${outputIndex}` : null; }; - const getResponsesReasoningSummaryText = (item: Record): string => { - return Array.isArray(item.summary) - ? item.summary - .map((part) => { - if (!part || typeof part !== "object" || Array.isArray(part)) { - return ""; - } - return typeof (part as Record).text === "string" - ? ((part as Record).text as string) - : ""; - }) - .join("") - : ""; - }; - - const ensureVisibleResponsesReasoningSummary = (payload: Record): boolean => { - const item = - payload.item && typeof payload.item === "object" && !Array.isArray(payload.item) - ? (payload.item as Record) - : null; - if (!item || item.type !== "reasoning") { - return false; - } - - if (getResponsesReasoningSummaryText(item)) { - return false; - } - - const hasEncryptedReasoning = - typeof item.encrypted_content === "string" && item.encrypted_content.length > 0; - if (!hasEncryptedReasoning) { - return false; - } - - item.summary = [ - { - type: "summary_text", - text: "Codex is reasoning, but the upstream Responses API exposed this reasoning block only as encrypted state. OmniRoute cannot recover the private reasoning text.", - }, - ]; - return true; - }; - const emitSyntheticResponsesReasoningSummary = ( controller: TransformStreamDefaultController, payload: Record @@ -1061,8 +1019,10 @@ export function createSSEStream(options: StreamOptions = {}) { return; } - ensureVisibleResponsesReasoningSummary(payload); - const visibleSummary = getResponsesReasoningSummaryText(item); + // #7095/#7176 reconciliation: compute the visible placeholder WITHOUT + // mutating `item` — the encrypted reasoning item (and its `encrypted_content`, + // required by Codex for subsequent requests) is forwarded to the client intact. + const visibleSummary = getVisibleResponsesReasoningSummaryText(item); if (!visibleSummary) { return; @@ -1485,13 +1445,8 @@ export function createSSEStream(options: StreamOptions = {}) { // response.completed snapshot can be backfilled when upstream // returns an empty `output` (happens with store: false). if (parsed.type === "response.output_item.done" && parsed.item) { - const reasoningSummaryInjected = ensureVisibleResponsesReasoningSummary(parsed); emitSyntheticResponsesReasoningSummary(controller, parsed); pushUniqueResponsesOutputItems(passthroughResponsesOutputItems, [parsed.item]); - if (reasoningSummaryInjected) { - output = `data: ${JSON.stringify(parsed)}\n\n`; - injectedUsage = true; - } if (parsed.item?.type === "function_call") { const pendingKey = typeof parsed.item.id === "string" @@ -2181,7 +2136,6 @@ export function createSSEStream(options: StreamOptions = {}) { markResponsesReasoningSummarySeen: (key: string) => { passthroughResponsesReasoningSummarySeen.add(key); }, - ensureVisibleResponsesReasoningSummary, emitSyntheticResponsesReasoningSummary: (payload: Record) => emitSyntheticResponsesReasoningSummary(controller, payload), passthroughResponsesOutputItems, 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 b0d8892b27..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) */ @@ -135,6 +136,11 @@ const EXTRA_MODULE_ENTRIES = [ src: ["scripts", "dev", "peer-stamp.mjs"], dest: ["peer-stamp.mjs"], }, + { + label: "main-server timeouts (server-ws.mjs dependency, #7003/#7065-class)", + src: ["scripts", "dev", "main-server-timeouts.mjs"], + dest: ["main-server-timeouts.mjs"], + }, { label: "HTTP method guard (server-ws.mjs dependency)", src: ["scripts", "dev", "http-method-guard.cjs"], @@ -464,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. * @@ -480,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({ @@ -489,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"); @@ -545,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 9adde22c1f..90e9216dd2 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -46,6 +46,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ "open-sse/services/compression/engines/llmlingua/onnxWorker.js", "package.json", "peer-stamp.mjs", + "main-server-timeouts.mjs", "responses-ws-proxy.mjs", "scripts/dev/sync-env.mjs", "scripts/dev/tls-options.mjs", @@ -152,6 +153,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "dist/server-ws.mjs", "dist/responses-ws-proxy.mjs", "dist/peer-stamp.mjs", + "dist/main-server-timeouts.mjs", "dist/http-method-guard.cjs", // #5452: regression guard — make check:pack-artifact fail loudly if the TLS // opt-in sidecar (imported by dist/server-ws.mjs) ever vanishes from the tarball. @@ -166,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/main-server-timeouts.mjs b/scripts/dev/main-server-timeouts.mjs new file mode 100644 index 0000000000..a0019fca2d --- /dev/null +++ b/scripts/dev/main-server-timeouts.mjs @@ -0,0 +1,47 @@ +// Main-server keepAlive/headers timeouts (#7003) — SIBLING module of +// standalone-server-ws.mjs. The shipped server-ws.mjs may only import +// siblings copied next to it by assembleStandalone (peer-stamp, tls-options, +// the guards): a ../../src/... import resolves OUTSIDE the package after the +// copy to the dist root and crashes boot with ERR_MODULE_NOT_FOUND (caught +// live by check:pack-boot on 2026-07-15 — the #7065 class). +// Parity with src/shared/utils/runtimeTimeouts.ts#getMainServerTimeoutConfig +// is enforced by tests/unit/main-server-timeouts-parity.test.ts. + +export const DEFAULT_MAIN_SERVER_KEEPALIVE_TIMEOUT_MS = 65_000; +export const DEFAULT_MAIN_SERVER_HEADERS_TIMEOUT_MS = 66_000; + +function readTimeoutMs(env, name, defaultValue, { allowZero = false, logger } = {}) { + const raw = env[name]; + if (raw == null || raw.trim() === "") return defaultValue; + const parsed = Number(raw); + const isValid = Number.isFinite(parsed) && (allowZero ? parsed >= 0 : parsed > 0); + if (!isValid) { + logger?.(`Invalid ${name}="${raw}". Using default ${defaultValue}ms.`); + return defaultValue; + } + return Math.floor(parsed); +} + +export function getMainServerTimeoutConfig(env = process.env, logger) { + const keepAliveTimeoutMs = readTimeoutMs( + env, + "MAIN_SERVER_KEEPALIVE_TIMEOUT_MS", + DEFAULT_MAIN_SERVER_KEEPALIVE_TIMEOUT_MS, + { allowZero: true, logger } + ); + const headersTimeoutMs = readTimeoutMs( + env, + "MAIN_SERVER_HEADERS_TIMEOUT_MS", + DEFAULT_MAIN_SERVER_HEADERS_TIMEOUT_MS, + { allowZero: true, logger } + ); + return { + keepAliveTimeoutMs, + // Node requires headersTimeout > keepAliveTimeout; keep both configurable + // but always coherent (mirrors the canonical TS implementation). + headersTimeoutMs: + headersTimeoutMs > 0 && keepAliveTimeoutMs > 0 + ? Math.max(headersTimeoutMs, keepAliveTimeoutMs + 1_000) + : headersTimeoutMs, + }; +} 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/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index fffdb06cfb..54c33e56df 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -14,7 +14,7 @@ import headResponseGuard from "./head-response-guard.cjs"; import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { isTurbopackCacheCorruption, purgeAllTurbopackCaches } from "./turbopackCacheHeal.mjs"; import { randomUUID } from "node:crypto"; -import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts"; +import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs"; const { maybeHandleDisallowedMethod } = methodGuard; const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index ebbca99936..439a9c5171 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -7,7 +7,7 @@ import { maybeHandleWebdav } from "./webdav-handler.mjs"; import methodGuard from "./http-method-guard.cjs"; import headResponseGuard from "./head-response-guard.cjs"; import { resolveTlsOptions, createServerListener } from "./tls-options.mjs"; -import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts"; +import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs"; const originalCreateServer = http.createServer.bind(http); const proxiesByPort = new Map(); diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index 13ffdb8ff0..a14b80051d 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -126,7 +126,14 @@ export function parseEslintJson(out) { /** Pull the cognitive-complexity violation count from the gate's output. */ export function parseCognitiveCount(out) { - const m = String(out || "").match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i); + const s = String(out || ""); + // `check:complexity-ratchets` runs ONE shared ESLint walk and prints BOTH ratchets, with the + // cyclomatic "N violações" summary emitted FIRST — so a bare `\d+ violações` regex would grab + // the cyclomatic count. Prefer the unambiguous machine-readable `cognitiveComplexity=N` line + // (mirrors the cyclomatic `complexity=N` parse used for cycCurrent below). + const machine = s.match(/(?:^|\n)cognitiveComplexity=(\d+)/); + if (machine) return Number(machine[1]); + const m = s.match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i); return m ? Number(m[1]) : null; } diff --git a/scripts/release/rehome-open-prs.mjs b/scripts/release/rehome-open-prs.mjs new file mode 100644 index 0000000000..934a0987b2 --- /dev/null +++ b/scripts/release/rehome-open-prs.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +// scripts/release/rehome-open-prs.mjs +// +// Parallel-cycle PR re-home (generate-release Phase 0a.0b, step 3). +// Retargets every open PR whose base is the FROZEN release/v onto the +// freshly cut release/v, so development keeps flowing while the captain +// owns the frozen branch. Design: _tasks/release-flow/2026-07-04_proposta-ciclo-paralelo-v2.md +// +// Usage: +// node scripts/release/rehome-open-prs.mjs [--dry-run] +// e.g. node scripts/release/rehome-open-prs.mjs 3.8.49 3.8.50 +// +// WHY THIS EXISTS AS A SCRIPT AND NOT A `gh pr edit` LOOP IN THE SKILL: +// +// 1. `gh pr edit --base` FAILS SILENTLY (v3.8.42 lesson). It exits 0 while +// leaving the base untouched — so every edit MUST be read back with +// `gh pr view --json baseRefName`. A hand-run loop skips that under +// fatigue; this does not. +// 2. Volume. At the v3.8.49 freeze there were 148 open PRs on the release +// branch — ~450 API calls between edit, verify and comment. That is not a +// thing a human does reliably at 2am mid-release. +// 3. `gh pr list` defaults to **30 results**. A loop written without +// `--limit` silently re-homes the first 30 and reports success. +// +// Idempotent: a PR already based on release/v is skipped, so a resumed +// release re-runs this safely. +// +// NOT covered here (by design): PRs opened AFTER this runs. Those are handled +// by flipping the repo's default_branch to release/v at 0a.0b — see the +// skill. Contributors open PRs against the default branch; if that still points +// at `main`, they never target a release branch at all. + +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const REPO = "diegosouzapw/OmniRoute"; + +function gh(args, { allowFail = false } = {}) { + try { + return execFileSync("gh", args, { encoding: "utf8" }).trim(); + } catch (err) { + if (allowFail) return null; + throw new Error(`gh ${args.join(" ")} failed: ${err.stderr || err.message}`); + } +} + +/** + * Pure: classify what should happen to a PR given its current base. + * Split out so the decision is unit-testable without touching the network. + */ +export function classify(pr, currentBase, nextBase) { + if (pr.baseRefName === nextBase) return { action: "skip", reason: "already re-homed" }; + if (pr.baseRefName !== currentBase) { + return { action: "skip", reason: `base is ${pr.baseRefName}, not the frozen branch` }; + } + if (pr.isDraft) return { action: "retarget", reason: "draft — retarget anyway, it still needs a home" }; + return { action: "retarget", reason: "open PR on the frozen branch" }; +} + +function main(argv) { + const dryRun = argv.includes("--dry-run"); + const [current, next] = argv.filter((a) => !a.startsWith("--")); + + if (!current || !next) { + console.error("Usage: node scripts/release/rehome-open-prs.mjs [--dry-run]"); + console.error(" e.g. node scripts/release/rehome-open-prs.mjs 3.8.49 3.8.50"); + process.exit(2); + } + + const currentBase = `release/v${current}`; + const nextBase = `release/v${next}`; + + // The next branch MUST exist before we point anything at it, or every edit + // 422s and we have re-homed nothing while reporting progress. + const exists = gh(["api", `repos/${REPO}/branches/${nextBase}`, "--jq", ".name"], { + allowFail: true, + }); + if (!exists) { + console.error(`✖ ${nextBase} does not exist on origin — cut it first (0a.0b step 1).`); + process.exit(1); + } + + // --limit 300: `gh pr list` returns 30 by default. Without this the loop + // silently re-homes a third of the queue and exits 0. + const raw = gh([ + "pr", "list", "--repo", REPO, "--state", "open", "--limit", "300", + "--base", currentBase, "--json", "number,title,isDraft,baseRefName", + ]); + const prs = JSON.parse(raw); + + console.log(`${prs.length} open PR(s) on ${currentBase} → ${nextBase}${dryRun ? " [DRY RUN]" : ""}\n`); + + const failed = []; + let moved = 0; + let skipped = 0; + + for (const pr of prs) { + const { action, reason } = classify(pr, currentBase, nextBase); + if (action === "skip") { + console.log(` · #${pr.number} skipped — ${reason}`); + skipped++; + continue; + } + if (dryRun) { + console.log(` → #${pr.number} would retarget — ${reason}`); + moved++; + continue; + } + + gh(["pr", "edit", String(pr.number), "--repo", REPO, "--base", nextBase], { allowFail: true }); + + // The read-back is the whole point: `gh pr edit --base` exits 0 on failure. + const actual = gh( + ["pr", "view", String(pr.number), "--repo", REPO, "--json", "baseRefName", "--jq", ".baseRefName"], + { allowFail: true } + ); + + if (actual !== nextBase) { + console.error(` ✖ #${pr.number} STILL on ${actual ?? "?"} — retarget did not take`); + failed.push({ number: pr.number, actual }); + continue; + } + + gh([ + "pr", "comment", String(pr.number), "--repo", REPO, + "--body", + `Re-homed to \`${nextBase}\`: v${current} entered its release freeze, so the branch now belongs ` + + `to the release captain and development continues on the next cycle. Nothing is wrong with this ` + + `PR — it just needed a live base. No action needed from you; CI will re-run against the new base.`, + ], { allowFail: true }); + + console.log(` ✔ #${pr.number} → ${nextBase}`); + moved++; + } + + console.log(`\n${moved} re-homed, ${skipped} skipped, ${failed.length} failed`); + + if (failed.length) { + console.error( + `\n✖ ${failed.length} PR(s) did not take the retarget: ${failed.map((f) => `#${f.number}`).join(", ")}\n` + + ` Re-run this script (it is idempotent) or retarget those by hand and verify with\n` + + ` gh pr view --json baseRefName` + ); + process.exit(1); + } + + if (!dryRun && moved > 0) { + console.log( + `\nReminder (0a.0b): flip the repo default_branch so PRs opened from now on are born on the\n` + + `right base — this script cannot reach PRs that do not exist yet:\n` + + ` gh api -X PATCH repos/${REPO} -f default_branch="${nextBase}"` + ); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)); +} diff --git a/skills/cli-skill-collector/SKILL.md b/skills/cli-skill-collector/SKILL.md index 2e3cec2776..add237ad11 100644 --- a/skills/cli-skill-collector/SKILL.md +++ b/skills/cli-skill-collector/SKILL.md @@ -1,7 +1,275 @@ --- name: cli-skill-collector -description: "Agent workflow: detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.), search GitHub for matching agent skills, and install them to the detected tools. Replaces the standalone Skill Collector Python app." +description: "Detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline and more), search GitHub for matching agent skills, and install them to the detected tools via OmniRoute's built-in APIs." --- + + +## Overview + +Detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline and more), search GitHub for matching agent skills, and install them to the detected tools via OmniRoute's built-in APIs. + +## Quick install + +```bash +npm install -g omniroute # or: npx omniroute +omniroute --version +``` + +## Subcommands + +### `autostart` + +**Example:** + +```bash +omniroute autostart +``` + +### `autostart enable` + +**Example:** + +```bash +omniroute autostart enable +``` + +### `autostart disable` + +**Example:** + +```bash +omniroute autostart disable +``` + +### `autostart toggle` + +**Example:** + +```bash +omniroute autostart toggle +``` + +### `autostart status` + +**Example:** + +```bash +omniroute autostart status +``` + +### `config` + +Show or update CLI tool configuration + +**Example:** + +```bash +omniroute config +``` + +### `config list` + +List all CLI tools and config status + +**Flags:** + +- `--json` + +**Example:** + +```bash +omniroute config list +``` + +### `config get ` + +Show current config for a tool + +**Flags:** + +- `--json` + +**Example:** + +```bash +omniroute config get +``` + +### `config set ` + +Write config for a tool + +**Flags:** + +- `--model ` +- `--non-interactive` +- `--yes` + +**Example:** + +```bash +omniroute config set +``` + +### `config validate ` + +Validate config format without writing + +**Flags:** + +- `--model ` +- `--json` + +**Example:** + +```bash +omniroute config validate +``` + +### `config opencode` + +Generate OpenCode config (alias for + +**Flags:** + +- `--model ` +- `--non-interactive` +- `--yes` + +**Example:** + +```bash +omniroute config opencode +``` + +### `config lang` + +**Example:** + +```bash +omniroute config lang +``` + +### `config get` + +**Flags:** + +- `--json` + +**Example:** + +```bash +omniroute config get +``` + +### `config set ` + +**Flags:** + +- `--force` + +**Example:** + +```bash +omniroute config set +``` + +### `config list` + +**Flags:** + +- `--json` + +**Example:** + +```bash +omniroute config list +``` + +### `env` + +Show and manage environment variables + +**Example:** + +```bash +omniroute env +``` + +### `env show` + +Show current environment variables + +**Flags:** + +- `--json` + +**Example:** + +```bash +omniroute env show +``` + +### `env get ` + +Get a single environment variable + +**Example:** + +```bash +omniroute env get +``` + +### `env set ` + +Set an environment variable (current session only) + +**Example:** + +```bash +omniroute env set +``` + +### `setup` + +**Flags:** + +- `--password ` +- `--add-provider` +- `--provider ` +- `--provider-name ` +- `--api-key ` +- `--default-model ` +- `--provider-base-url ` +- `--test-provider` +- `--non-interactive` +- `--list` + +**Example:** + +```bash +omniroute setup +``` + +### `update` + +**Flags:** + +- `--check` +- `--apply` +- `--changelog` +- `--dry-run` +- `--no-backup` +- `--yes` + +**Example:** + +```bash +omniroute update +``` + + + # /cli-skill-collector — Agent Skill Collector @@ -150,3 +418,4 @@ fi - OmniRoute must be running locally on port 20128 (default) — see `docs/frameworks/SKILLS.md` for custom-port setups. - The `/api/skills/collect/*` and `/api/github-skills` endpoints require **management-scoped authentication** the same way every other `/api/skills/*` route does: a dashboard session, the loopback CLI token, or an API key with the `manage` scope (`requireManagementAuth()`). Auth is only bypassed when the server has no login/API-key requirement configured at all. - This replaces the standalone Skill Collector Python app — all logic is now inside OmniRoute. + 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 251 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/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 77058cdc82..bba6ead776 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/proxy/vercel-deploy/route.ts b/src/app/api/settings/proxy/vercel-deploy/route.ts index 42f432f2c6..6d6b859396 100644 --- a/src/app/api/settings/proxy/vercel-deploy/route.ts +++ b/src/app/api/settings/proxy/vercel-deploy/route.ts @@ -98,6 +98,92 @@ export default async function handler(req) { */ export const __buildRelayFunctionForTest = buildRelayFunction; +/** + * Disable Vercel project SSO/Deployment Protection so the relay is publicly + * reachable. The PATCH response was previously fired-and-forgotten + * (`.catch(() => {})`, no `res.ok` check) — if Vercel rejects or no-ops the + * request (plan does not allow disabling protection, an under-scoped token, + * etc.), the relay still got saved and activated as a healthy proxy pool, + * and later requests through it failed with an undiagnosed + * `403 Access denied` from Vercel's own deployment protection. Callers must + * now check `.ok` and surface the failure instead of assuming success. + */ +async function disableSsoProtection( + vercelApiBase: string, + projectId: string, + token: string +): Promise<{ ok: boolean; status?: number }> { + try { + const res = await fetch(`${vercelApiBase}/v9/projects/${projectId}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ssoProtection: null }), + }); + return { ok: res.ok, status: res.status }; + } catch { + return { ok: false }; + } +} + +/** + * Test-only hook exposing `disableSsoProtection` so the regression test can + * assert the PATCH response is checked instead of silently swallowed. Not + * part of the route contract. + */ +export const __disableSsoProtectionForTest = disableSsoProtection; + +/** + * Builds the sanitized error response for a rejected Vercel deployment + * request. Extracted from POST to keep the handler's cognitive complexity + * within the ratchet — parses the canonical `{ error: { message } } }` shape + * and never forwards raw upstream error text (may contain project IDs, team + * slugs, deployment hashes or internal Vercel error strings). + */ +async function buildDeployErrorResponse(deployRes: Response) { + let upstreamMessage = "Vercel API rejected the deployment"; + try { + const parsed = (await deployRes.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + const candidate = parsed?.error?.message; + if (typeof candidate === "string" && candidate.trim()) { + upstreamMessage = candidate.trim().slice(0, 200); + } + } catch { + /* fall through to generic message */ + } + return createErrorResponse({ + status: deployRes.status, + message: `Vercel deployment failed: ${upstreamMessage}`, + type: "upstream_error", + }); +} + +/** + * Disables Vercel SSO/Deployment Protection for the deployed project and + * returns a caller-facing warning when it could not be disabled. Extracted + * from POST to keep the handler's cognitive complexity within the ratchet. + * See `disableSsoProtection` doc comment for the bug this guards against. + */ +async function resolveSsoProtectionWarning( + projectId: string | undefined, + vercelApiBase: string, + token: string +): Promise { + if (!projectId) return undefined; + const ssoResult = await disableSsoProtection(vercelApiBase, projectId, token); + if (ssoResult.ok) return undefined; + return ( + "Could not disable Vercel Deployment Protection (SSO) for this project" + + (ssoResult.status ? ` (status ${ssoResult.status})` : "") + + ". Requests through this relay may fail with a 403 Access denied from " + + "Vercel until protection is disabled manually in the Vercel dashboard." + ); +} + async function pollDeployment(deploymentApiUrl: string, token: string): Promise<"READY" | "ERROR"> { for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); @@ -171,27 +257,9 @@ export async function POST(request: Request) { }); if (!deployRes.ok) { - // Avoid forwarding 200 bytes of raw Vercel error text — it may contain - // project IDs, team slugs, deployment hashes or internal Vercel error - // strings. Parse the canonical { error: { message } } shape and surface - // only the human-readable message (or a generic fallback). - let upstreamMessage = "Vercel API rejected the deployment"; - try { - const parsed = (await deployRes.json().catch(() => null)) as { - error?: { message?: string }; - } | null; - const candidate = parsed?.error?.message; - if (typeof candidate === "string" && candidate.trim()) { - upstreamMessage = candidate.trim().slice(0, 200); - } - } catch { - /* fall through to generic message */ - } - return createErrorResponse({ - status: deployRes.status, - message: `Vercel deployment failed: ${upstreamMessage}`, - type: "upstream_error", - }); + // Avoid forwarding raw Vercel error text — it may contain project IDs, + // team slugs, deployment hashes or internal Vercel error strings. + return buildDeployErrorResponse(deployRes); } const deployment = (await deployRes.json()) as { @@ -208,17 +276,17 @@ export async function POST(request: Request) { }); } - // Disable Vercel SSO protection so the relay is publicly accessible - if (deployment.projectId) { - await fetch(`${VERCEL_API_BASE}/v9/projects/${deployment.projectId}`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ ssoProtection: null }), - }).catch(() => {}); - } + // Disable Vercel SSO protection so the relay is publicly accessible. + // The PATCH response is checked — if Vercel rejects/no-ops it (plan + // doesn't allow disabling protection, under-scoped token, etc.) the + // relay is still deployed and saved, but the caller is warned so a + // later `403 Access denied` can be diagnosed as Vercel-side deployment + // protection rather than an upstream provider rejection. + const ssoProtectionWarning = await resolveSsoProtectionWarning( + deployment.projectId, + VERCEL_API_BASE, + token + ); // Poll until READY const deploymentApiUrl = `${VERCEL_API_BASE}/v13/deployments/${deployment.id}`; @@ -254,6 +322,7 @@ export async function POST(request: Request) { success: true, relayUrl: `https://${deployment.url}`, poolProxyId: poolProxy?.id, + ...(ssoProtectionWarning ? { ssoProtectionWarning } : {}), }); } catch (error) { return createErrorResponseFromUnknown(error, "Vercel deploy failed"); 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..beac6d426f 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"; @@ -54,6 +55,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 +415,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 +438,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 +478,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 +591,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 +663,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 +898,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/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index bddf765d0f..2915d4730b 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -5,7 +5,10 @@ import { generateRequestId } from "@/shared/utils/requestId"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { acceptHeaderForcesStream } from "@omniroute/open-sse/utils/aiSdkCompat.ts"; -import { withEarlyStreamKeepalive } from "@omniroute/open-sse/utils/earlyStreamKeepalive"; +import { + OPENAI_KEEPALIVE_FRAME, + withEarlyStreamKeepalive, +} from "@omniroute/open-sse/utils/earlyStreamKeepalive"; import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold"; import { checkChatAdmission } from "@/shared/middleware/chatBodyAdmission"; import { @@ -132,6 +135,7 @@ export async function POST(request) { { signal: request.signal, thresholdMs: resolveKeepaliveThreshold(parsedBody?.model), + keepaliveFrame: OPENAI_KEEPALIVE_FRAME, extraHeaders: { "X-Correlation-Id": reqId }, } ); 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/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 380827f11a..f4419da282 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -860,12 +860,17 @@ async function buildUnifiedModelsResponseCore( // #6457: some upstream discovery catalogs (e.g. HuggingFace's live // `/v1/models`) return image/diffusion models with no modality info, // so `endpoints` below would default to ["chat"] and misrepresent - // them as chat-capable. Skip any synced model that is already a - // registered image model for this provider — getAllImageModels() - // below adds the correctly-typed `type: "image"` entry instead. + // them as chat-capable. Skip a registered image model only when its + // synced metadata does not explicitly advertise a chat endpoint. + // Multi-capability models may intentionally share an id between the + // chat and image catalogs; getAllImageModels() adds the image entry. + const explicitlySupportsChat = sm.supportedEndpoints?.some( + (endpoint) => endpoint === "chat" || endpoint === "responses" + ); if ( - isRegisteredImageModel(canonicalProviderId, sm.id) || - isRegisteredImageModel(providerId, sm.id) + !explicitlySupportsChat && + (isRegisteredImageModel(canonicalProviderId, sm.id) || + isRegisteredImageModel(providerId, sm.id)) ) { continue; } 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/bifrost/route.ts b/src/app/api/v1/relay/chat/completions/bifrost/route.ts index b0df931b0e..31b007959f 100644 --- a/src/app/api/v1/relay/chat/completions/bifrost/route.ts +++ b/src/app/api/v1/relay/chat/completions/bifrost/route.ts @@ -260,6 +260,8 @@ export async function POST(request: Request) { "x-relay-client-ip": clientIp, ...getProviderPluginManifestHeader(new URL(request.url).origin), }; + const requestId = request.headers.get("x-request-id"); + if (requestId) upstreamHeaders["x-request-id"] = requestId; if (BIFROST_API_KEY) { upstreamHeaders["Authorization"] = `Bearer ${BIFROST_API_KEY}`; } diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index 8ee4a25a2d..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, @@ -65,6 +66,7 @@ async function forwardToBifrost( body: unknown, token: RelayToken, config: BifrostRoutingConfig, + backend: ReturnType, startTime: number, clientIp: string, userAgent: string | null @@ -77,6 +79,8 @@ async function forwardToBifrost( "x-relay-client-ip": clientIp, ...getProviderPluginManifestHeader(new URL(request.url).origin), }; + const requestId = request.headers.get("x-request-id"); + if (requestId) upstreamHeaders["x-request-id"] = requestId; if (config.apiKey) { upstreamHeaders.Authorization = `Bearer ${config.apiKey}`; } @@ -95,7 +99,6 @@ async function forwardToBifrost( body: JSON.stringify(body), signal: ac.signal, }); - clearTimeout(tid); const headers = new Headers(upstream.headers); headers.set("X-Routed-By", "bifrost"); @@ -107,14 +110,24 @@ async function forwardToBifrost( if (wantsStream && upstream.body) { const stream = finalizeReadableStream(upstream.body, (error) => { + clearTimeout(tid); + const statusCode = timedOut ? 504 : upstream.status; + if (error && backend === "auto") { + recordBifrostFailure( + config.baseUrl, + timedOut + ? `Bifrost sidecar stream timed out after ${config.timeoutMs}ms` + : "bifrost-stream-error" + ); + } recordUsage( token.id, request, startTime, clientIp, userAgent, - error || upstream.status >= 500 ? "error" : "success", - upstream.status + error || statusCode >= 500 ? "error" : "success", + statusCode ); }); @@ -124,6 +137,7 @@ async function forwardToBifrost( }); } + clearTimeout(tid); recordUsage( token.id, request, @@ -313,6 +327,7 @@ export async function POST(request: Request) { parsedBody, token, bifrostConfig, + backend, startTime, clientIp, userAgent @@ -364,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 de049135af..0e168dea1c 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": "المجموعات", @@ -3652,6 +3662,9 @@ "apiKeyProviders": "موفري مفاتيح API", "compatibleProviders": "مقدمو خدمات API المتوافقون", "testAll": "اختبار الكل", + "reorderByAvailability": "إعادة الترتيب", + "reorderByAvailabilityTitle": "إعادة ترتيب الاتصالات حسب التوفر", + "reorderByAvailabilityError": "فشل في إعادة ترتيب الاتصالات حسب التوفر", "testAllOAuth": "اختبار كافة اتصالات OAuth", "testAllFree": "اختبار كافة الاتصالات المجانية", "testAllApiKey": "اختبار جميع اتصالات مفتاح API", @@ -4427,7 +4440,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": "الإعدادات", @@ -7614,5 +7629,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 e0694b225d..9c6fa70496 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 b6e9952e85..26a1d163cf 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": "Комбота", @@ -3652,6 +3662,9 @@ "apiKeyProviders": "Доставчици на API ключове", "compatibleProviders": "API Key Съвместими доставчици", "testAll": "Тествайте всички", + "reorderByAvailability": "Пренареждане", + "reorderByAvailabilityTitle": "Пренаредете връзките по наличност", + "reorderByAvailabilityError": "Неуспешно пренареждане на връзките по наличност", "testAllOAuth": "Тествайте всички OAuth връзки", "testAllFree": "Тествайте всички безплатни връзки", "testAllApiKey": "Тествайте всички API Key връзки", @@ -4427,7 +4440,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": "Настройки", @@ -7614,5 +7629,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 27a35531b4..7acf3cd262 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 0f2db58994..d126a5f2fd 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", @@ -3652,6 +3662,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 +4440,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í", @@ -7614,5 +7629,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 3db278c319..8aee18ac77 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 f9b37487c5..36f2b74f49 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", @@ -3657,6 +3667,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 +4445,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", @@ -7632,5 +7647,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 1e172158a6..d59d99eed0 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.", @@ -3905,6 +3915,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 +3996,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 +4341,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 +5645,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 +5967,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", @@ -9162,7 +9181,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 84c20d31b9..85afdd2ee0 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 2e85c028be..221645981f 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 dff90e6c94..c73ea66b59 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 39188d982a..bb86076209 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 4b79943447..0297114832 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 5740d5cd97..0be3e6522f 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": "שילובים", @@ -3652,6 +3662,9 @@ "apiKeyProviders": "ספקי מפתח API", "compatibleProviders": "ספקים תואמים מפתח API", "testAll": "בדוק הכל", + "reorderByAvailability": "סדר מחדש", + "reorderByAvailabilityTitle": "סדר מחדש חיבורים לפי זמינות", + "reorderByAvailabilityError": "סידור מחדש של החיבורים לפי זמינות נכשל", "testAllOAuth": "בדוק את כל חיבורי OAuth", "testAllFree": "בדוק את כל החיבורים החינמיים", "testAllApiKey": "בדוק את כל חיבורי מפתח ה-API", @@ -4427,7 +4440,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": "הגדרות", @@ -7614,5 +7629,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 11bee167d9..5711806051 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": "संयोजन", @@ -3652,6 +3662,9 @@ "apiKeyProviders": "एपीआई कुंजी प्रदाता", "compatibleProviders": "एपीआई कुंजी संगत प्रदाता", "testAll": "सभी का परीक्षण करें", + "reorderByAvailability": "पुनः क्रमबद्ध करें", + "reorderByAvailabilityTitle": "उपलब्धता के अनुसार कनेक्शन पुनः क्रमबद्ध करें", + "reorderByAvailabilityError": "उपलब्धता के अनुसार कनेक्शन पुनः क्रमबद्ध करने में विफल", "testAllOAuth": "सभी OAuth कनेक्शन का परीक्षण करें", "testAllFree": "सभी निःशुल्क कनेक्शनों का परीक्षण करें", "testAllApiKey": "सभी एपीआई कुंजी कनेक्शन का परीक्षण करें", @@ -4427,7 +4440,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": "सेटिंग्स", @@ -7614,5 +7629,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 1dd27e618e..e7d80ee10e 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 4f2c76c499..ea0cceeb8c 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 ace72ac9ca..d6003d5861 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 1e1f70cf43..5b815aac4f 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", @@ -3976,6 +3986,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 +4867,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", @@ -9036,7 +9051,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 c052fed1d7..faf3580a9a 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": "コンボ", @@ -3948,6 +3958,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 +4833,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": "設定", @@ -8938,6 +8953,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 e300c36890..bfc39f5b2d 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": "콤보", @@ -3948,6 +3958,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 +4833,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": "설정", @@ -8938,6 +8953,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 1ad40bb3ea..d7c003a3bf 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", @@ -3948,6 +3958,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 +4833,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", @@ -8938,6 +8953,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 f01ceac27e..45a45a35cb 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", @@ -3948,6 +3958,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 +4833,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", @@ -8938,6 +8953,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 560df3cb73..77735063a2 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", @@ -3948,6 +3958,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 +4833,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", @@ -8938,6 +8953,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 47b6584623..fd3f4d09f9 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", @@ -3948,6 +3958,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 +4833,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", @@ -8938,6 +8953,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 7e6afb8e3d..a49f2180cd 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", @@ -3948,6 +3958,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 +4833,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", @@ -8938,6 +8953,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 6ea76ab160..a7bc4342d9 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", @@ -3948,6 +3958,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 +4833,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", @@ -8938,6 +8953,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 758dd533a2..5f62319c7d 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", @@ -3917,7 +3927,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 +3996,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 +4906,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 +5608,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 +5929,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", @@ -9162,7 +9181,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 0b01ff143f..f713c89c30 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", @@ -3948,6 +3958,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 +4833,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 +5825,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", @@ -8938,6 +8954,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 635ce8f982..d889f148ac 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", @@ -3948,6 +3958,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 +4833,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", @@ -8938,6 +8953,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 a3d025301a..e804afcf27 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": "Комбо", @@ -3948,6 +3958,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 +4833,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": "Настройки", @@ -8938,6 +8953,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 e65ced31e9..fa2d5a90cb 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á", @@ -3948,6 +3958,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 +4833,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", @@ -8938,6 +8953,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 2a22581414..67208ff3ae 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", @@ -3948,6 +3958,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 +4833,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", @@ -8938,6 +8953,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 8d6c40741e..f2fbd0234f 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 8fa80330a8..c67eb7ed76 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 2147b8325c..297378de64 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 c7e7cec0e8..2a38911bfb 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": "คอมโบ", @@ -3652,6 +3662,9 @@ "apiKeyProviders": "ผู้ให้บริการคีย์ API", "compatibleProviders": "ผู้ให้บริการที่เข้ากันได้กับคีย์ API", "testAll": "ทดสอบทั้งหมด", + "reorderByAvailability": "จัดลำดับใหม่", + "reorderByAvailabilityTitle": "จัดลำดับการเชื่อมต่อใหม่ตามความพร้อมใช้งาน", + "reorderByAvailabilityError": "จัดลำดับการเชื่อมต่อใหม่ตามความพร้อมใช้งานไม่สำเร็จ", "testAllOAuth": "ทดสอบการเชื่อมต่อ OAuth ทั้งหมด", "testAllFree": "ทดสอบการเชื่อมต่อฟรีทั้งหมด", "testAllApiKey": "ทดสอบการเชื่อมต่อคีย์ API ทั้งหมด", @@ -4427,7 +4440,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": "การตั้งค่า", @@ -7614,5 +7629,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 13dc5b2267..203b710c88 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 3e3310ff63..e6321eadf3 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": "Комбо", @@ -3652,6 +3662,9 @@ "apiKeyProviders": "Постачальники ключів API", "compatibleProviders": "Сумісні постачальники ключів API", "testAll": "Перевірити все", + "reorderByAvailability": "Змінити порядок", + "reorderByAvailabilityTitle": "Змінити порядок підключень за доступністю", + "reorderByAvailabilityError": "Не вдалося змінити порядок підключень за доступністю", "testAllOAuth": "Перевірте всі підключення OAuth", "testAllFree": "Перевірте всі безкоштовні підключення", "testAllApiKey": "Перевірте всі підключення ключів API", @@ -4427,7 +4440,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": "Налаштування", @@ -7614,5 +7629,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 23bb2cd677..05a7affead 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 f67c186783..4a4308153c 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", @@ -3652,6 +3662,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 +4440,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", @@ -7614,5 +7629,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 5c5a3bbd13..b5d42e3fae 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": "组合", @@ -3890,6 +3900,9 @@ "apiKeyProviders": "API 密钥提供商", "compatibleProviders": "API 密钥兼容提供商", "testAll": "测试全部", + "reorderByAvailability": "重新排序", + "reorderByAvailabilityTitle": "按可用性重新排序连接", + "reorderByAvailabilityError": "按可用性重新排序连接失败", "testAllOAuth": "测试所有 OAuth 连接", "testAllFree": "测试所有免费连接", "testAllApiKey": "测试所有 API 密钥连接", @@ -4729,7 +4742,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": "设置", @@ -8685,5 +8700,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 b879139a9f..29ca3aae8b 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": "組合", @@ -3978,6 +3988,9 @@ "apiKeyProviders": "API 金鑰提供商", "compatibleProviders": "API 金鑰相容提供商", "testAll": "測試全部", + "reorderByAvailability": "重新排序", + "reorderByAvailabilityTitle": "依可用性重新排序連線", + "reorderByAvailabilityError": "依可用性重新排序連線失敗", "distributeProxies": "分配代理", "distributing": "分配中...", "selectedCount": "已選 {count} 個", @@ -4855,7 +4868,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": "設定", @@ -9047,7 +9062,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..eaaa629b03 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(); @@ -331,8 +344,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/agentSkills/catalog.ts b/src/lib/agentSkills/catalog.ts index af1a9be0d1..cefef715bd 100644 --- a/src/lib/agentSkills/catalog.ts +++ b/src/lib/agentSkills/catalog.ts @@ -68,6 +68,7 @@ export const CLI_SKILL_IDS: readonly string[] = [ "cli-eval", "cli-plugins-skills", "cli-setup", + "cli-skill-collector", ] as const; // ── Module-scope cache ────────────────────────────────────────────────────── @@ -148,8 +149,10 @@ export function computeCoverage(): SkillCoverage { const configHave = catalog.filter((s) => s.category === "config" && presentIds.has(s.id)).length; return { - api: { have: apiHave, total: 23 }, - cli: { have: cliHave, total: 20 }, + // Totals derive from the id lists — hardcoded 23/20 went stale the first + // time the catalog grew (cli-skill-collector registration, 2026-07-15). + api: { have: apiHave, total: API_SKILL_IDS.length }, + cli: { have: cliHave, total: CLI_SKILL_IDS.length }, config: { have: configHave, total: configTotal }, totalSkills: apiHave + cliHave + configHave, generatedAt: new Date().toISOString(), diff --git a/src/lib/agentSkills/types.ts b/src/lib/agentSkills/types.ts index 61aa865bae..9f9c3e10a7 100644 --- a/src/lib/agentSkills/types.ts +++ b/src/lib/agentSkills/types.ts @@ -48,7 +48,8 @@ export type SkillArea = | "cli-batches" | "cli-eval" | "cli-plugins-skills" - | "cli-setup"; + | "cli-setup" + | "cli-skill-collector"; export interface AgentSkill { id: string; // canonical id (e.g. "omni-providers", "cli-serve") @@ -66,8 +67,10 @@ export interface AgentSkill { } export interface SkillCoverage { - api: { have: number; total: 23 }; - cli: { have: number; total: 20 }; + // Totals are derived from the catalog id lists (literal types went stale the + // first time the catalog grew — cli-skill-collector, 2026-07-15). + api: { have: number; total: number }; + cli: { have: number; total: number }; config: { have: number; total: number }; totalSkills: number; // sum generatedAt: string; // ISO datetime 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/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/openaiFormat.ts b/src/lib/providers/validation/openaiFormat.ts index c9cc575ef8..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); @@ -459,6 +472,31 @@ export async function validateOpenAICompatibleProvider({ apiKey, providerSpecifi }; } + // #2032: a 404 on the chat probe commonly means the requested model id + // does not exist at this provider (OpenAI-compatible `model_not_found`, + // e.g. Featherless/OpenRouter-style `vendor/model` typos). Credentials + // are still valid (the endpoint responded), but silently passing this + // hides the bad model id from the user until a real request later trips + // the per-model lockout — surface it as a warning at Check time instead. + if (chatRes.status === 404) { + let modelNotFoundDetail = ""; + try { + const body: any = await chatRes.json(); + const err = body?.error; + if (typeof err?.message === "string" && err.message.trim()) { + modelNotFoundDetail = `: ${err.message.trim()}`; + } + } catch { + // Non-JSON or unreadable body — fall through with the generic warning. + } + return { + valid: true, + error: null, + method: "inference_available", + warning: `Model ID may not exist at this provider (404)${modelNotFoundDetail}`, + }; + } + // 4xx other than auth (e.g. 400 bad model, 422) usually means auth passed if (chatRes.status >= 400 && chatRes.status < 500) { return { 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/manager.ts b/src/mitm/manager.ts index 47765bf515..94fcf33b69 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -5,15 +5,22 @@ import { resolveMitmDataDir } from "./dataDir.ts"; import { removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts"; import { provisionDnsEntries } from "./dns/provision.ts"; import { generateCert } from "./cert/generate.ts"; -import { installCertResult, uninstallCert } from "./cert/install.ts"; +import { installCertResult } from "./cert/install.ts"; import { ALL_TARGETS } from "./targets/index.ts"; import { detectAgent } from "./detection/index.ts"; import type { AgentId, DetectionResult, MitmTarget } from "./types.ts"; import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState.ts"; -import { listCustomHosts } from "@/lib/db/inspectorCustomHosts.ts"; import { getUserBypassPatterns } from "@/lib/db/agentBridgeBypass.ts"; import { configureUpstreamCa } from "./upstreamTrust.ts"; import { createLogger } from "@/shared/utils/logger.ts"; +import { + buildRepairPlan, + collectManagedHosts, + performRepairSteps, + type RepairPlan, +} from "./repair.ts"; + +export { buildRepairPlan, collectManagedHosts, type RepairPlan }; const log = createLogger("mitm-manager"); @@ -57,6 +64,17 @@ export function interpretMitmStartupError(stderr: string, port: number): string let serverProcess: ChildProcess | null = null; let serverPid: number | null = null; +/** + * Test-only seam: install a fake server process (and pid) so stopMitm() can be + * exercised without spawning a real MITM child. Not part of the public API — + * only intended for unit tests that need to assert stopMitm()'s DNS/kill + * ordering (#1809). No-op in production code paths. + */ +export function __setServerProcessForTest(proc: ChildProcess | null, pid: number | null): void { + serverProcess = proc; + serverPid = pid; +} + // Set while startMitm() is in flight, from the guard check through spawn. // Guards a TOCTOU race: the "already running" check above only trips once // `serverProcess` is assigned by spawn() — ~130 lines and several awaits @@ -219,108 +237,20 @@ function isProcessAlive(pid: number): boolean { } } -/** - * Enumerate every hostname OmniRoute may have written to /etc/hosts during - * startMitm(): the full agent-target registry plus all custom hosts. Removal - * via removeDNSEntries() is idempotent (absent entries are skipped), so this - * set is intentionally over-inclusive — a host that was never spoofed costs - * nothing to "remove", but a host we forget to list leaks machine-wide. - * (Gap 8 — clean-stop DNS leak.) - */ -export function collectManagedHosts(): string[] { - const hosts = new Set(); - for (const target of ALL_TARGETS) { - for (const h of target.hosts) hosts.add(h); - } - try { - for (const ch of listCustomHosts()) hosts.add(ch.host); - } catch (err) { - log.error({ err }, "collectManagedHosts: failed to read custom hosts (continuing)"); - } - return [...hosts]; -} - -export interface RepairPlan { - dnsHostsToRemove: string[]; - removeCert: boolean; - revertSystemProxy: boolean; -} - -/** - * Pure description of what a repair must undo. Separated from repairMitm() so - * the enumeration is unit-testable without touching the OS or requiring sudo. - * (Gap 7.) - */ -export function buildRepairPlan(): RepairPlan { - return { - dnsHostsToRemove: collectManagedHosts(), - removeCert: true, - revertSystemProxy: true, - }; -} - -/** - * Best-effort revert of an applied system proxy. The applied state lives - * in-memory (captureState), so this only succeeds within the same process that - * applied it; after a crash the previousState is gone and this is a no-op. DNS - * + cert teardown are always reversible because they read on-disk state. - */ -async function revertSystemProxyIfApplied(): Promise { - try { - const { getSystemProxyState, clearSystemProxy } = await import("@/lib/inspector/captureState"); - const state = getSystemProxyState(); - if (!state.applied || !state.previousState) return false; - const { revert } = await import("./inspector/systemProxyConfig.ts"); - await revert(state.previousState); - clearSystemProxy(); - return true; - } catch (err) { - log.error({ err }, "revertSystemProxyIfApplied failed (continuing)"); - return false; - } -} - /** * Undo every system mutation startMitm() may have made, WITHOUT requiring the * MITM server to be running. Safe to call when state is already clean (every * step is idempotent). Used by: the /repair route, the CLI cleanup subcommand, * and the stale-PID auto-repair on app startup. (Gap 7 — the application-layer - * analogue of ProxyBridge's destructor + `--cleanup`.) + * analogue of ProxyBridge's destructor + `--cleanup`.) Steps 1-3 (DNS, cert, + * system-proxy) are delegated to `./repair.ts::performRepairSteps()`; the PID + * file + in-memory session cleanup below stays here since it touches this + * module's private state. */ export async function repairMitm(sudoPassword: string): Promise<{ repaired: string[] }> { - const plan = buildRepairPlan(); - const repaired: string[] = []; + const repaired = await performRepairSteps(sudoPassword); - // 1. DNS — remove every host we may have spoofed (idempotent, reads /etc/hosts). - try { - await removeDNSEntry(sudoPassword); - if (plan.dnsHostsToRemove.length > 0) { - await removeDNSEntries(plan.dnsHostsToRemove, sudoPassword); - } - repaired.push("dns"); - } catch (err) { - log.error({ err }, "repairMitm: DNS cleanup failed (continuing)"); - } - - // 2. Certificate — uninstall the MITM root CA from the trust store. - if (plan.removeCert) { - try { - const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt"); - if (fs.existsSync(certPath)) { - await uninstallCert(sudoPassword, certPath); - repaired.push("cert"); - } - } catch (err) { - log.error({ err }, "repairMitm: cert removal failed (continuing)"); - } - } - - // 3. System proxy — best-effort revert if applied in this process. - if (plan.revertSystemProxy) { - if (await revertSystemProxyIfApplied()) repaired.push("system-proxy"); - } - - // 4. Stale PID file. + // Stale PID file. try { if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); } catch { @@ -709,11 +639,38 @@ async function startMitmInternal( } /** - * Stop MITM proxy - * @param {string} sudoPassword - Sudo password for DNS cleanup + * DNS teardown step of stopMitm() (#1809) — split out purely to keep + * stopMitm()'s own cyclomatic complexity under the repo's ratchet; behavior + * is unchanged from the original inline implementation. */ -export async function stopMitm(sudoPassword: string): Promise<{ running: false; pid: null }> { - // 1. Kill server process (in-memory or from PID file) +async function removeStopDnsEntries( + deps: { + removeDNSEntry: (sudoPassword: string) => Promise; + removeDNSEntries: (hosts: string[], sudoPassword: string) => Promise; + collectManagedHosts: () => string[]; + }, + sudoPassword: string +): Promise { + log.info("Removing DNS entries..."); + await deps.removeDNSEntry(sudoPassword); + try { + const managed = deps.collectManagedHosts(); + if (managed.length > 0) { + await deps.removeDNSEntries(managed, sudoPassword); + } + } catch (err) { + log.error({ err }, "Failed to remove managed DNS entries during stop (continuing)"); + } +} + +/** + * Kill the MITM server process during stop — either the in-memory + * `serverProcess` handle or, if that's gone, the PID recorded in `PID_FILE`. + * Split out of stopMitm() purely to keep that function's complexity under + * the repo's ratchet; behavior is unchanged from the original inline + * implementation. + */ +async function killMitmServerProcessOnStop(): Promise { const proc = serverProcess; if (proc && !proc.killed) { log.info("Stopping MITM server..."); @@ -724,41 +681,64 @@ export async function stopMitm(sudoPassword: string): Promise<{ running: false; } serverProcess = null; serverPid = null; - } else { - // Fallback: kill by PID file - try { - if (fs.existsSync(PID_FILE)) { - const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); - if (savedPid && isProcessAlive(savedPid)) { - log.info({ pid: savedPid }, "Killing MITM server by PID..."); - process.kill(savedPid, "SIGTERM"); - await new Promise((resolve) => setTimeout(resolve, 1000)); - if (isProcessAlive(savedPid)) { - process.kill(savedPid, "SIGKILL"); - } - } - } - } catch { - // Ignore - } - serverProcess = null; - serverPid = null; + return; } - // 2. Remove DNS entries — Antigravity defaults PLUS every agent + custom host - // that startMitm() may have spoofed. removeDNSEntries is idempotent, so - // over-inclusion is safe; under-inclusion leaks /etc/hosts lines that - // hijack resolution machine-wide after stop (Gap 8). - log.info("Removing DNS entries..."); - await removeDNSEntry(sudoPassword); + // Fallback: kill by PID file try { - const managed = collectManagedHosts(); - if (managed.length > 0) { - await removeDNSEntries(managed, sudoPassword); + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + log.info({ pid: savedPid }, "Killing MITM server by PID..."); + process.kill(savedPid, "SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (isProcessAlive(savedPid)) { + process.kill(savedPid, "SIGKILL"); + } + } } - } catch (err) { - log.error({ err }, "Failed to remove managed DNS entries during stop (continuing)"); + } catch { + // Ignore } + serverProcess = null; + serverPid = null; +} + +/** + * Stop MITM proxy + * + * Ordering is deliberate and load-bearing (#1809 — "connect ECONNREFUSED + * 127.0.0.1:443" after stop). DNS entries MUST be removed BEFORE the server + * process is killed: if the process dies first, any client whose DNS still + * resolves the target host to 127.0.0.1 (from startMitm()'s spoof) but whose + * MITM listener is already dead gets ECONNREFUSED against a dead port for the + * whole window between the two steps. Removing DNS first closes that window — + * once /etc/hosts no longer points at 127.0.0.1, clients fall back to real + * resolution regardless of when the listener actually goes away. This mirrors + * the DNS-first ordering already used by repairMitm() and handleExitCleanup(). + * @param {string} sudoPassword - Sudo password for DNS cleanup + * @param _depsOverride - optional dependency override, used in tests for DI. + */ +export async function stopMitm( + sudoPassword: string, + _depsOverride?: { + removeDNSEntry?: (sudoPassword: string) => Promise; + removeDNSEntries?: (hosts: string[], sudoPassword: string) => Promise; + collectManagedHosts?: () => string[]; + } +): Promise<{ running: false; pid: null }> { + const deps = { + removeDNSEntry: _depsOverride?.removeDNSEntry ?? removeDNSEntry, + removeDNSEntries: _depsOverride?.removeDNSEntries ?? removeDNSEntries, + collectManagedHosts: _depsOverride?.collectManagedHosts ?? collectManagedHosts, + }; + + // 1. Remove DNS entries FIRST — see function doc + module doc above for why + // this must happen before the process kill (#1809, Gap 8). + await removeStopDnsEntries(deps, sudoPassword); + + // 2. Kill server process (in-memory or from PID file) + await killMitmServerProcessOnStop(); // 3. Clean up clearCachedPassword(); // Clear password from memory when proxy stops diff --git a/src/mitm/repair.ts b/src/mitm/repair.ts new file mode 100644 index 0000000000..4ee2f160f2 --- /dev/null +++ b/src/mitm/repair.ts @@ -0,0 +1,115 @@ +import path from "path"; +import fs from "fs"; +import { resolveMitmDataDir } from "./dataDir.ts"; +import { removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts"; +import { uninstallCert } from "./cert/install.ts"; +import { ALL_TARGETS } from "./targets/index.ts"; +import { listCustomHosts } from "@/lib/db/inspectorCustomHosts.ts"; +import { createLogger } from "@/shared/utils/logger.ts"; + +const log = createLogger("mitm-repair"); + +/** + * Enumerate every hostname OmniRoute may have written to /etc/hosts during + * startMitm(): the full agent-target registry plus all custom hosts. Removal + * via removeDNSEntries() is idempotent (absent entries are skipped), so this + * set is intentionally over-inclusive — a host that was never spoofed costs + * nothing to "remove", but a host we forget to list leaks machine-wide. + * (Gap 8 — clean-stop DNS leak.) + */ +export function collectManagedHosts(): string[] { + const hosts = new Set(); + for (const target of ALL_TARGETS) { + for (const h of target.hosts) hosts.add(h); + } + try { + for (const ch of listCustomHosts()) hosts.add(ch.host); + } catch (err) { + log.error({ err }, "collectManagedHosts: failed to read custom hosts (continuing)"); + } + return [...hosts]; +} + +export interface RepairPlan { + dnsHostsToRemove: string[]; + removeCert: boolean; + revertSystemProxy: boolean; +} + +/** + * Pure description of what a repair must undo. Separated from repairMitm() so + * the enumeration is unit-testable without touching the OS or requiring sudo. + * (Gap 7.) + */ +export function buildRepairPlan(): RepairPlan { + return { + dnsHostsToRemove: collectManagedHosts(), + removeCert: true, + revertSystemProxy: true, + }; +} + +/** + * Best-effort revert of an applied system proxy. The applied state lives + * in-memory (captureState), so this only succeeds within the same process that + * applied it; after a crash the previousState is gone and this is a no-op. DNS + * + cert teardown are always reversible because they read on-disk state. + */ +async function revertSystemProxyIfApplied(): Promise { + try { + const { getSystemProxyState, clearSystemProxy } = await import("@/lib/inspector/captureState"); + const state = getSystemProxyState(); + if (!state.applied || !state.previousState) return false; + const { revert } = await import("./inspector/systemProxyConfig.ts"); + await revert(state.previousState); + clearSystemProxy(); + return true; + } catch (err) { + log.error({ err }, "revertSystemProxyIfApplied failed (continuing)"); + return false; + } +} + +/** + * Run the DNS/cert/system-proxy teardown steps of a repair, WITHOUT touching + * any of `manager.ts`'s in-memory session state (cached password, orphaned + * flag, PID file) — that bookkeeping stays in `manager.ts::repairMitm()`, + * which calls this as its first step. Split out purely to keep + * `src/mitm/manager.ts` under the repo's file-size cap; behavior is + * unchanged from the original inline implementation. (Gap 7.) + */ +export async function performRepairSteps(sudoPassword: string): Promise { + const plan = buildRepairPlan(); + const repaired: string[] = []; + + // 1. DNS — remove every host we may have spoofed (idempotent, reads /etc/hosts). + try { + await removeDNSEntry(sudoPassword); + if (plan.dnsHostsToRemove.length > 0) { + await removeDNSEntries(plan.dnsHostsToRemove, sudoPassword); + } + repaired.push("dns"); + } catch (err) { + log.error({ err }, "repairMitm: DNS cleanup failed (continuing)"); + } + + // 2. Certificate — uninstall the MITM root CA from the trust store. + if (plan.removeCert) { + try { + const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt"); + if (fs.existsSync(certPath)) { + await uninstallCert(sudoPassword, certPath); + repaired.push("cert"); + } + } catch (err) { + log.error({ err }, "repairMitm: cert removal failed (continuing)"); + } + } + + // 3. System proxy — best-effort revert if applied in this process. + if (plan.revertSystemProxy) { + if (await revertSystemProxyIfApplied()) repaired.push("system-proxy"); + } + + return repaired; +} 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."} +

+ )}