From 5d08c3fd67e36e6f7aaddd77e17297648a09767a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 3 Jul 2026 02:27:21 -0300 Subject: [PATCH 1/2] fix(oauth): graceful 400 for keychain-import-only providers (zed) instead of Unknown-provider 500 (#6041) --- CHANGELOG.md | 2 + .../api/oauth/[provider]/[action]/route.ts | 49 +++++++++++++ .../oauth-keychain-import-only-6041.test.ts | 71 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 tests/unit/oauth-keychain-import-only-6041.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8069afabe76..969dc00887f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ ### 🔧 Bug Fixes +- **oauth (Zed "Unknown provider" crash):** adding **Zed** from the providers dashboard threw an unhandled `OAuth GET error: Unknown provider: zed` (500) ([#6041](https://github.com/diegosouzapw/OmniRoute/issues/6041)). Zed is a **keychain-import-only** provider — it's listed in the OAuth catalog so the UI shows it, but has no OAuth handler, so the generic `/api/oauth/[provider]/[action]` route hit `getProvider("zed")` and crashed. The route now recognizes keychain-import-only providers and returns a clear **400** pointing users at the **Import** button (for both GET and POST OAuth actions), instead of a 500. Regression guard: `tests/unit/oauth-keychain-import-only-6041.test.ts`. (thanks @imblowsnow) + - **dashboard ("Update now" → Internal Server Error):** clicking **Update now** on the dashboard home could crash the page with a blank "Internal Server Error" screen (`Minified React error #31`). The handler POSTs the loopback-only `/api/system/version` auto-update endpoint and, on a non-OK JSON response (e.g. a `403` when the dashboard is reached through a reverse proxy / non-loopback origin), passed the raw error envelope object `{ error: { code, message, correlation_id } }` straight to `notify.error()`, which rendered the object as a React child and threw #31. The update-error path now funnels the body through `extractApiErrorMessage()` (the same safe extractor added in #5340), so a readable string always reaches the toast. Regression guard: `tests/unit/ui/home-update-error-render-5991.test.ts`. ([#5991](https://github.com/diegosouzapw/OmniRoute/issues/5991)) ### 📝 Maintenance diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 4bf2a1ba24b..373f572b018 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -68,6 +68,40 @@ const RETIRED_PKCE_PROVIDERS = new Set(["windsurf", "devin-cli"]); /** Providers that allow direct import of a raw API token (no OAuth exchange). */ const IMPORT_TOKEN_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]); +/** + * Providers that have NO browser OAuth flow at all — their credentials are read + * from the OS keychain via a dedicated Import button, not an OAuth + * authorize/exchange. They are listed in the OAuth provider *catalog* + * (so the dashboard shows them) but have no entry in the OAuth provider + * *handler* registry, so hitting the generic OAuth route for them threw an + * unhandled `Unknown provider: ` 500 (#6041). Return a clear, actionable + * response pointing at the Import flow instead. + */ +const KEYCHAIN_IMPORT_ONLY_PROVIDERS = new Set(["zed"]); + +/** GET/POST OAuth actions that don't apply to keychain-import-only providers. */ +const OAUTH_FLOW_ACTIONS = new Set([ + "authorize", + "device-code", + "start-callback-server", + "poll-callback", + "exchange", + "poll", + "device-complete", +]); + +function keychainImportOnlyResponse(provider: string) { + return NextResponse.json( + { + error: + `${provider} has no browser OAuth flow — it imports LLM credentials from the ` + + `OS keychain. Use the "Import" button on the ${provider} provider card in the ` + + `dashboard to discover and import them automatically.`, + }, + { status: 400 } + ); +} + /** * Constant-time string comparison to prevent timing-oracle attacks (CWE-208). * Handles null/undefined safely and different-length strings. @@ -136,6 +170,14 @@ export async function GET( { status: 410 } ); } + // Keychain-import-only providers (e.g. zed) have no OAuth flow — return a + // clear 400 pointing at the Import button instead of a 500 (#6041). + if ( + KEYCHAIN_IMPORT_ONLY_PROVIDERS.has(earlyParams.provider) && + OAUTH_FLOW_ACTIONS.has(earlyParams.action) + ) { + return keychainImportOnlyResponse(earlyParams.provider); + } } catch { /* fall through to normal handling */ } @@ -356,6 +398,13 @@ export async function POST( { status: 410 } ); } + // Keychain-import-only providers (e.g. zed) have no OAuth flow (#6041). + if ( + KEYCHAIN_IMPORT_ONLY_PROVIDERS.has(earlyParams.provider) && + OAUTH_FLOW_ACTIONS.has(earlyParams.action) + ) { + return keychainImportOnlyResponse(earlyParams.provider); + } } catch { /* fall through to normal handling */ } diff --git a/tests/unit/oauth-keychain-import-only-6041.test.ts b/tests/unit/oauth-keychain-import-only-6041.test.ts new file mode 100644 index 00000000000..d2363ea2251 --- /dev/null +++ b/tests/unit/oauth-keychain-import-only-6041.test.ts @@ -0,0 +1,71 @@ +// Regression guard for #6041 — the generic OAuth route threw an unhandled +// `Unknown provider: zed` 500 when the dashboard hit /api/oauth/zed/authorize. +// Zed is a keychain-import-only provider (listed in the OAuth catalog so the UI +// shows it, but with no OAuth handler), so the route now returns a clear 400 +// pointing at the Import flow instead of crashing. +// +// DB handles released in test.after (CLAUDE.md learning: unreleased SQLite +// handles hang node:test). + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-oauth-6041-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const route = await import("../../src/app/api/oauth/[provider]/[action]/route.ts"); + +test.before(async () => { + // The guard runs BEFORE the auth check, but disable login so any fall-through + // path is exercised without a 401 masking the result. + await settingsDb.updateSettings({ requireLogin: false }); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function get(provider: string, action: string) { + const request = new Request(`http://localhost:20128/api/oauth/${provider}/${action}`); + return route.GET(request, { params: Promise.resolve({ provider, action }) }); +} + +test("#6041 GET /oauth/zed/authorize returns a graceful 400, not a 500 'Unknown provider'", async () => { + const res = await get("zed", "authorize"); + assert.equal(res.status, 400, "must be a clean 400, not a 500 crash"); + const body = await res.json(); + assert.ok(body.error, "error message present"); + assert.match(body.error, /Import/i, "must point the user at the Import flow"); + assert.doesNotMatch(body.error, /Unknown provider/i, "must not leak the raw 'Unknown provider' error"); + // Never leak a stack trace (ERROR_SANITIZATION). + assert.doesNotMatch(body.error, /at \//, "must not leak a stack trace"); +}); + +test("#6041 other keychain OAuth actions for zed are also handled gracefully", async () => { + for (const action of ["device-code", "exchange", "poll"]) { + const res = await get("zed", action); + assert.equal(res.status, 400, `zed/${action} must be a clean 400`); + const body = await res.json(); + assert.match(body.error, /Import/i, `zed/${action} points at Import`); + } +}); + +test("#6041 POST /oauth/zed/exchange is also guarded (no 500)", async () => { + const request = new Request("http://localhost:20128/api/oauth/zed/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await route.POST(request, { + params: Promise.resolve({ provider: "zed", action: "exchange" }), + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match(body.error, /Import/i); +}); From 881e6c51d5ad3e47d58ad9e678c7e56a7658bba9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 4 Jul 2026 01:00:51 -0300 Subject: [PATCH 2/2] docs(changelog): restore release bullets eaten by stale-branch merge; keep only the zed-oauth-400 entry --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 969dc00887f..44b4802e866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ### ✨ New Features +- **feat(resilience):** throttle upstream quota fetches on the per-request preflight path ([#6009](https://github.com/diegosouzapw/OmniRoute/issues/6009)) — a new global min-interval gate (`open-sse/services/quotaFetchThrottle.ts`) spaces the actual network calls made by the Codex quota fetcher so that many accounts on one IP no longer fetch quota in the same second (which, per `router-for-me/CLIProxyAPI#2385`, can get a Codex OAuth token revoked). Complements the existing bulk-sync spacing (`PROVIDER_LIMITS_SYNC_SPACING_MS`) which already serialized the periodic provider-limits sync — this covers the concurrent combo/preflight path it didn't. Cache hits are never delayed; fail-open (only ever awaits a timer). Configurable via `OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS` (default 250ms, clamped 0..5000; `0` disables). Regression guard: `tests/unit/quota-fetch-throttle-6009.test.ts` (5). (thanks @powellnorma) +- **feat(autoCombo):** add **per-request Auto-Combo controls** via two headers ([#6024](https://github.com/diegosouzapw/OmniRoute/issues/6024) / [#6025](https://github.com/diegosouzapw/OmniRoute/issues/6025) / [#6023](https://github.com/diegosouzapw/OmniRoute/issues/6023)) — `X-OmniRoute-Mode` steers an `auto` combo's scoring for a single request (friendly presets `fast`/`balanced`/`quality`/`cheap`/`reliable`/`offline` **or** a raw mode-pack name; `balanced` forces the default weights), and `X-OmniRoute-Budget` sets a hard per-request USD cost ceiling. Both override the combo's stored config only for the request that carries them; unknown/garbage values are ignored so the saved config is preserved. The resolvers are pure (`open-sse/services/autoCombo/requestControls.ts`) and feed the engine's existing `config.modePack` / `config.budgetCap` inputs — no engine changes. Regression guard: `tests/unit/auto-combo-request-controls-6024.test.ts` (5). (thanks @chirag127) +- **feat(providers):** add the **Kenari** OpenAI-compatible gateway (BYOK). Regression guard: `tests/unit/kenari.test.ts`. (thanks @doedja) +- **feat(models):** add `claude-sonnet-5` to the Antigravity model catalog (alias mapping in `antigravityModelAliases.ts`). Regression guard: `tests/unit/antigravity-model-aliases.test.ts`. (thanks @anki1kr) - **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. (thanks @waguriagentic) - **Discovery tool (Phase 2):** add the `discoveryResults` DB module (CRUD over the `discovery_results` table, migration 074) and wire the opt-in provider-discovery service to persist and read findings through it (`persistDiscoveryResult`, `getDiscoveryResults`, `getDiscoveryResultById`, `markVerified`, `deleteDiscoveryResult`) with `(provider, method, endpoint)` upsert de-duplication. Adds the `/api/discovery/*` HTTP surface — `GET /results`, `GET|DELETE /results/:id`, `POST /scan`, `POST /verify/:id` — under **strict loopback-only** authorization (`/api/discovery/` is in `LOCAL_ONLY_API_PREFIXES` and is NOT manage-scope-bypassable, so the `scan` route's outbound probes can never be reached from a tunnel/remote origin). Adds a **dashboard UI tab** (Tools → Discovery, `/dashboard/discovery`) to run scans and review, verify, or delete findings. The service stays **opt-in / default-off**. - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. (thanks @KooshaPari) @@ -24,6 +28,20 @@ - **oauth (Zed "Unknown provider" crash):** adding **Zed** from the providers dashboard threw an unhandled `OAuth GET error: Unknown provider: zed` (500) ([#6041](https://github.com/diegosouzapw/OmniRoute/issues/6041)). Zed is a **keychain-import-only** provider — it's listed in the OAuth catalog so the UI shows it, but has no OAuth handler, so the generic `/api/oauth/[provider]/[action]` route hit `getProvider("zed")` and crashed. The route now recognizes keychain-import-only providers and returns a clear **400** pointing users at the **Import** button (for both GET and POST OAuth actions), instead of a 500. Regression guard: `tests/unit/oauth-keychain-import-only-6041.test.ts`. (thanks @imblowsnow) +- **fix(providers):** disable the unsupported `thinking` param for `minimax-m2.7` on NVIDIA NIM (the upstream rejects it). Regression guard: `tests/unit/nvidia-minimax-thinking-strip.test.ts`. (thanks @anki1kr) + +- **fix(mitm):** add an in-process guard so concurrent MITM server starts no longer race — a second start while one is already in flight is short-circuited instead of double-binding the listener. Regression guard: `tests/unit/mitm-start-guard.test.ts`. (thanks @anki1kr) + +- **translator (Responses → Chat Completions):** strip the Responses-API-only `truncation` field before forwarding a `/v1/responses` request to a non-OpenAI Chat Completions upstream. Strict upstreams (e.g. NVIDIA NIM) rejected it with HTTP 400 `Unsupported parameter(s): truncation`, breaking Codex-style clients routed to those providers. `client_metadata`, `background`, and `safety_identifier` were already stripped — `truncation` was the remaining gap. Regression guard: `tests/unit/responses-strip-truncation-2311.test.ts`. (thanks @TuanNguyen0708) + +- **combo (prefer known context capacity over unknown):** when a combo filters out at least one target for exceeding a *known* context limit, the router now prefers the remaining known-compatible targets over targets whose context metadata is simply unknown, instead of letting unknown-metadata targets be the only survivors. If no known-compatible context target remains, context-only candidates fall back to the normal strategy order. Regression guard: `tests/unit/combo-context-window-filter.test.ts`. ([#6088](https://github.com/diegosouzapw/OmniRoute/pull/6088) — thanks @Thinkscape) + +- **models (GLM-5.2 context normalization):** stop treating every hosted GLM-5.2 provider alias as the native 1M-context model. Native/bare GLM-5.2 and verified OpenCode / ZenMux routes keep their 1,000,000-token context, while hosted-provider aliases now respect the caps declared in their provider metadata instead of inheriting the native max. Regression guards: `tests/unit/model-capabilities-registry.test.ts`, `tests/unit/models-catalog-route.test.ts`. ([#6091](https://github.com/diegosouzapw/OmniRoute/pull/6091) — thanks @Thinkscape) + +- **providers (Gemini Web):** refresh the Gemini Web cookie handling and model catalog so live Gemini Web sessions keep authenticating and routing to current models. Regression guard: `tests/unit/gemini-web.test.ts`. ([#6095](https://github.com/diegosouzapw/OmniRoute/pull/6095) — thanks @backryun) + +- **providers (Perplexity Web):** refresh the Perplexity Web model catalog to the current set (GPT-5.4/5.5, Claude Sonnet 5.0 / Opus 4.8, GLM-5.2, Kimi K2.6, Nemotron 3 Ultra) and update the internal mode / `model_preference` mappings and thinking variants so requests resolve to live upstream models. Regression guard: `tests/unit/perplexity-web.test.ts`. ([#6106](https://github.com/diegosouzapw/OmniRoute/pull/6106) — thanks @backryun) + - **dashboard ("Update now" → Internal Server Error):** clicking **Update now** on the dashboard home could crash the page with a blank "Internal Server Error" screen (`Minified React error #31`). The handler POSTs the loopback-only `/api/system/version` auto-update endpoint and, on a non-OK JSON response (e.g. a `403` when the dashboard is reached through a reverse proxy / non-loopback origin), passed the raw error envelope object `{ error: { code, message, correlation_id } }` straight to `notify.error()`, which rendered the object as a React child and threw #31. The update-error path now funnels the body through `extractApiErrorMessage()` (the same safe extractor added in #5340), so a readable string always reaches the toast. Regression guard: `tests/unit/ui/home-update-error-render-5991.test.ts`. ([#5991](https://github.com/diegosouzapw/OmniRoute/issues/5991)) ### 📝 Maintenance @@ -104,6 +122,8 @@ - **providers (CLI profile auto-sync):** opt-in CLI profile auto-sync toggles, including Claude Code auto-sync, so generated CLI profiles can track provider changes automatically. ([#5755](https://github.com/diegosouzapw/OmniRoute/pull/5755) — thanks @diegosouzapw) +- **feat(minimax):** surface MiniMax M3 `` reasoning as `reasoning_content` on OpenAI-format provider tiers. (thanks @zmf963) + ### 🔧 Bug Fixes - **fix(opencode):** stop fabricating `User-Agent: opencode/local` and `x-opencode-client: cli` headers when the client sends none — the executor-dedup refactor ([#5720](https://github.com/diegosouzapw/OmniRoute/pull/5720)) accidentally re-introduced header fabrication, violating the forward-only contract (inventing opencode-internal values risks upstream rejection). Restored to forward-only: those headers are emitted only when a real client source is present. Regression guard: `tests/unit/opencode-executor.test.ts`. (thanks @diegosouzapw)