Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
0xtbug marked this conversation as resolved.
}

if (cancelled) return;
setModels(list);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load models");
Expand Down
32 changes: 19 additions & 13 deletions src/app/api/v1/providers/[provider]/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<string, any>) => {
const root = typeof model.root === "string" && model.root.trim().length > 0 ? model.root : null;
if (root) return root;
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/provider-models-v1-route.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});