diff --git a/CHANGELOG.md b/CHANGELOG.md index f056feb6be..520d290bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) - **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) - **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. (#4389, thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) --- diff --git a/open-sse/config/providers/registry/minimax/cn/index.ts b/open-sse/config/providers/registry/minimax/cn/index.ts index 675d9e61a6..2274b01c39 100644 --- a/open-sse/config/providers/registry/minimax/cn/index.ts +++ b/open-sse/config/providers/registry/minimax/cn/index.ts @@ -7,6 +7,7 @@ export const minimax_cnProvider: RegistryEntry = { format: "claude", executor: "default", baseUrl: "https://api.minimaxi.com/anthropic/v1/messages", + modelsUrl: "https://api.minimaxi.com/v1/models", urlSuffix: "?beta=true", authType: "apikey", authHeader: "bearer", diff --git a/open-sse/config/providers/registry/minimax/index.ts b/open-sse/config/providers/registry/minimax/index.ts index 136c2d8e41..3033fccb46 100644 --- a/open-sse/config/providers/registry/minimax/index.ts +++ b/open-sse/config/providers/registry/minimax/index.ts @@ -7,6 +7,7 @@ export const minimaxProvider: RegistryEntry = { format: "claude", executor: "default", baseUrl: "https://api.minimax.io/anthropic/v1/messages", + modelsUrl: "https://api.minimax.io/v1/models", urlSuffix: "?beta=true", authType: "apikey", authHeader: "bearer", diff --git a/src/app/api/providers/[id]/models/discoveryConfig.ts b/src/app/api/providers/[id]/models/discoveryConfig.ts new file mode 100644 index 0000000000..5432cc8c32 --- /dev/null +++ b/src/app/api/providers/[id]/models/discoveryConfig.ts @@ -0,0 +1,35 @@ +import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; + +/** + * Derive a models-discovery config from the provider's registry `modelsUrl` + * when the provider is absent from the hardcoded PROVIDER_MODELS_CONFIG. + * + * Returns a config object with Bearer auth suitable for fetching an + * OpenAI-compatible `/v1/models` endpoint, or `undefined` when the + * registry entry has no `modelsUrl`. + */ +export function deriveConfigFromRegistryModelsUrl(provider: string): + | { + url: string; + method: "GET"; + headers: Record; + authHeader?: string; + authPrefix?: string; + authQuery?: string; + body?: unknown; + parseResponse: (data: any) => any; + } + | undefined { + const entry = getRegistryEntry(provider); + if (typeof entry?.modelsUrl === "string" && entry.modelsUrl.length > 0) { + return { + url: entry.modelsUrl, + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + headers: { "Content-Type": "application/json" }, + parseResponse: (data) => data.data || data.models || [], + }; + } + return undefined; +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 21b3894eea..5e680024f7 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -25,6 +25,7 @@ import { import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts"; +import { deriveConfigFromRegistryModelsUrl } from "./discoveryConfig"; import { fetchGitHubCopilotModels } from "@omniroute/open-sse/services/githubCopilotModels.ts"; import { fetchKiroAvailableModels } from "@omniroute/open-sse/services/kiroModels.ts"; import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; @@ -2390,8 +2391,7 @@ export async function GET( const config = provider in PROVIDER_MODELS_CONFIG ? PROVIDER_MODELS_CONFIG[provider as keyof typeof PROVIDER_MODELS_CONFIG] - : undefined; - + : deriveConfigFromRegistryModelsUrl(provider); // Static model providers (no remote /models API) // Qwen OAuth Fallback: The Dashscope /models API rejects OAuth tokens with 401 if (provider === "qwen" && connection.authType === "oauth") { diff --git a/tests/unit/derive-discovery-config-5087.test.ts b/tests/unit/derive-discovery-config-5087.test.ts new file mode 100644 index 0000000000..160598abdd --- /dev/null +++ b/tests/unit/derive-discovery-config-5087.test.ts @@ -0,0 +1,34 @@ +/** + * #5087 — derive model-discovery config from a provider's registry `modelsUrl`. + * + * When a provider is absent from the hardcoded PROVIDER_MODELS_CONFIG but its + * registry entry carries a `modelsUrl`, `deriveConfigFromRegistryModelsUrl` + * builds a Bearer `/v1/models` discovery config so the dashboard's "discover + * models" path works (e.g. MiniMax). Providers without `modelsUrl` return + * `undefined` (caller falls back to its existing undefined handling). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { deriveConfigFromRegistryModelsUrl } from "../../src/app/api/providers/[id]/models/discoveryConfig.ts"; + +test("minimax (registry modelsUrl present) yields a Bearer /v1/models config", () => { + const config = deriveConfigFromRegistryModelsUrl("minimax"); + assert.ok(config, "minimax must yield a config"); + assert.equal(config.url, "https://api.minimax.io/v1/models"); + assert.equal(config.method, "GET"); + assert.equal(config.authHeader, "Authorization"); + assert.equal(config.authPrefix, "Bearer "); + // parseResponse tolerates both {data:[...]} and {models:[...]} shapes. + assert.deepEqual(config.parseResponse({ data: [{ id: "abab6.5" }] }), [{ id: "abab6.5" }]); + assert.deepEqual(config.parseResponse({ models: [{ id: "m" }] }), [{ id: "m" }]); +}); + +test("a provider without a registry modelsUrl returns undefined", () => { + // `baseten` is a registry provider with no `modelsUrl`. + assert.equal(deriveConfigFromRegistryModelsUrl("baseten"), undefined); +}); + +test("an unknown provider returns undefined", () => { + assert.equal(deriveConfigFromRegistryModelsUrl("not-a-real-provider-xyz"), undefined); +});