diff --git a/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts b/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts index d3ec8e8e657..eac113e6550 100644 --- a/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts +++ b/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts @@ -51,7 +51,52 @@ export function useProviderModels(providerId: string): UseProviderModelsResult { } const data = (await res.json()) as { data?: ProviderModel[] }; if (cancelled) return; - setModels(data.data ?? []); + + let list = data.data ?? []; + + // Auto-sync from upstream if local catalog is empty + if (list.length === 0) { + setTimeout(async () => { + try { + if (cancelled) return; + const connRes = await fetch("/api/providers"); + if (!connRes.ok || cancelled) return; + const connData = (await connRes.json()) as { + connections?: Array<{ id: string; provider: string; isActive?: boolean }>; + }; + if (cancelled) return; + const providerConn = connData.connections?.find( + (c) => (c.provider === providerId || c.id === providerId) && c.isActive !== false + ); + + if (providerConn && !cancelled) { + const syncRes = await fetch( + `/api/providers/${encodeURIComponent(providerConn.id)}/sync-models?mode=sync`, + { method: "POST" } + ); + + if (syncRes.ok && !cancelled) { + const refetchRes = await fetch( + `/api/v1/providers/${encodeURIComponent(providerId)}/models` + ); + if (refetchRes.ok && !cancelled) { + const refetchData = (await refetchRes.json()) as { data?: ProviderModel[] }; + if (!cancelled) { + setModels(refetchData.data ?? []); + } + } + } + } + } catch (syncErr) { + if (!cancelled) { + console.log("Auto-fetch models failed:", syncErr); + } + } + }, 0); + } + + if (cancelled) return; + setModels(list); } catch (err) { if (!cancelled) { setError(err instanceof Error ? err.message : "Failed to load models"); diff --git a/src/app/api/v1/providers/[provider]/models/route.ts b/src/app/api/v1/providers/[provider]/models/route.ts index 525660e4a42..6dd209c7f95 100644 --- a/src/app/api/v1/providers/[provider]/models/route.ts +++ b/src/app/api/v1/providers/[provider]/models/route.ts @@ -20,18 +20,27 @@ export async function OPTIONS() { export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { const { provider: rawProvider } = await params; const providerEntry = getRegistryEntry(rawProvider); + let providerId = rawProvider; + let providerAlias = rawProvider; - if (!providerEntry) { - return Response.json( - { - error: { - message: `Unknown provider: ${rawProvider}`, - type: "invalid_request_error", - code: "invalid_provider", + if (providerEntry) { + providerId = providerEntry.id; + providerAlias = providerEntry.alias || providerId; + } else { + // Allow fetching models by connection ID for compatible providers + const isCompatibleConnectionId = /^(openai|anthropic)-compatible-chat-[a-f0-9-]+$/.test(rawProvider); + if (!isCompatibleConnectionId) { + return Response.json( + { + error: { + message: `Unknown provider: ${rawProvider}`, + type: "invalid_request_error", + code: "invalid_provider", + }, }, - }, - { status: 400 } - ); + { status: 400 } + ); + } } const response = await getUnifiedModelsResponse(request); @@ -44,9 +53,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov return response; } - const providerId = providerEntry.id; - const providerAlias = providerEntry.alias || providerId; - const toUnprefixedModelId = (model: Record) => { const root = typeof model.root === "string" && model.root.trim().length > 0 ? model.root : null; if (root) return root; diff --git a/tests/unit/provider-models-v1-route.test.ts b/tests/unit/provider-models-v1-route.test.ts new file mode 100644 index 00000000000..2acd4b721e5 --- /dev/null +++ b/tests/unit/provider-models-v1-route.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for GET /api/v1/providers/[provider]/models + * Covers the connection-ID fallback path added in PR #3402. + * + * Run: node --import tsx/esm --test tests/unit/provider-models-v1-route.test.ts + */ + +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-v1-provider-models-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const routeModule = await import( + "../../src/app/api/v1/providers/[provider]/models/route.ts" +); + +function makeRequest(provider: string) { + return new Request(`http://localhost/api/v1/providers/${encodeURIComponent(provider)}/models`); +} + +async function callGET(provider: string) { + return routeModule.GET(makeRequest(provider), { + params: Promise.resolve({ provider }), + }); +} + +test.beforeEach(() => { + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /v1/providers/:provider/models returns 400 for completely unknown provider", async () => { + const res = await callGET("utterly-unknown-provider-xyz"); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.error?.code, "invalid_provider"); + assert.ok(body.error?.message?.includes("utterly-unknown-provider-xyz")); +}); + +test("GET /v1/providers/:provider/models accepts openai-compatible connection ID format", async () => { + const connectionId = "openai-compatible-chat-a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + // A valid compatible connection ID should NOT return 400 (passes validation gate) + const res = await callGET(connectionId); + // The catalog may return 200 with empty data or non-200 for other reasons, + // but it must NOT return the "Unknown provider" 400 error. + if (res.status === 400) { + const body = await res.json(); + assert.notEqual( + body.error?.code, + "invalid_provider", + "openai-compatible-chat-* IDs must bypass the unknown-provider 400 gate" + ); + } +}); + +test("GET /v1/providers/:provider/models accepts anthropic-compatible connection ID format", async () => { + const connectionId = "anthropic-compatible-chat-deadbeef-0000-0000-0000-000000000000"; + const res = await callGET(connectionId); + if (res.status === 400) { + const body = await res.json(); + assert.notEqual(body.error?.code, "invalid_provider"); + } +}); + +test("GET /v1/providers/:provider/models rejects non-matching connection-like strings", async () => { + // Looks like a connection ID but with wrong prefix + const res = await callGET("custom-compatible-chat-a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.error?.code, "invalid_provider"); +});