From 454eb75b31b861982e72e17c825a1eb2a4dbad26 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:12:17 -0300 Subject: [PATCH] fix(api): resolve /v1/models/{id} case-insensitively (#5082) (#5135) --- CHANGELOG.md | 1 + src/app/api/v1/models/modelById.ts | 14 ++++++++++++-- tests/unit/v1-models-by-id-4674.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb76f03ac..99849f89d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) - **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) - **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) - **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) diff --git a/src/app/api/v1/models/modelById.ts b/src/app/api/v1/models/modelById.ts index 5a8b2eadb8..a59479cf69 100644 --- a/src/app/api/v1/models/modelById.ts +++ b/src/app/api/v1/models/modelById.ts @@ -12,13 +12,23 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; type CatalogModel = { id?: unknown } & Record; -/** Find a model entry in the unified catalog `data` array by its exact id. */ +/** + * Find a model entry in the unified catalog `data` array by id. + * + * Exact-case matches win; failing that we fall back to a case-insensitive match + * so clients that normalise the model id (#5082 — OpenCode requesting + * `minimax/minimax-m3` for the canonical `minimax/MiniMax-M3`) still resolve the + * real entry — and its `context_length` — instead of falling back to 0. + */ export function findModelById( data: CatalogModel[] | null | undefined, requestedId: string ): CatalogModel | null { if (!Array.isArray(data)) return null; - return data.find((m) => typeof m?.id === "string" && m.id === requestedId) ?? null; + const exact = data.find((m) => typeof m?.id === "string" && m.id === requestedId); + if (exact) return exact; + const lower = requestedId.toLowerCase(); + return data.find((m) => typeof m?.id === "string" && m.id.toLowerCase() === lower) ?? null; } /** diff --git a/tests/unit/v1-models-by-id-4674.test.ts b/tests/unit/v1-models-by-id-4674.test.ts index 4af0bb7193..71ab24e533 100644 --- a/tests/unit/v1-models-by-id-4674.test.ts +++ b/tests/unit/v1-models-by-id-4674.test.ts @@ -38,6 +38,28 @@ test("findModelById returns null for an unknown model", () => { assert.equal(findModelById(CATALOG, "does-not-exist"), null); }); +// #5082 — OpenCode (and other @ai-sdk/openai-compatible clients) may request a +// model id with different casing than the canonical catalog entry +// (e.g. `minimax/minimax-m3` vs the registered `minimax/MiniMax-M3`). A +// case-sensitive lookup misses, the client falls back to `context_length: 0`. +// The single-model lookup must resolve case-insensitively so the real entry +// (with its context window) is returned. +test("findModelById resolves a differently-cased id (case-insensitive)", () => { + const found = findModelById(CATALOG, "CLAUDE/Claude-Sonnet-4-6"); + assert.ok(found, "lowercase/uppercase variants must resolve to the canonical entry"); + assert.equal(found.id, "claude/claude-sonnet-4-6"); +}); + +test("findModelById prefers an exact-case match over a case-insensitive one", () => { + const data = [ + { id: "Model-X", object: "model", context_length: 111 }, + { id: "model-x", object: "model", context_length: 222 }, + ]; + // Exact case must win when present, not the first case-insensitive hit. + assert.equal(findModelById(data, "model-x")?.context_length, 222); + assert.equal(findModelById(data, "Model-X")?.context_length, 111); +}); + test("findModelById tolerates a non-array catalog", () => { assert.equal(findModelById(undefined, "gpt-5"), null); assert.equal(findModelById(null, "gpt-5"), null);