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
@@ -0,0 +1 @@
- fix(dashboard): make /api/models agree with /v1/models on synced-catalog coverage instead of reporting stale models as available (#10615)
43 changes: 42 additions & 1 deletion src/app/api/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import {
getResolvedModelCapabilities,
} from "@/lib/modelCapabilities";
import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels";
import { getAllActiveSyncedModels } from "@/lib/db/models/activeSyncedCatalog";
import { providerUsesExclusiveSyncedListing } from "@/lib/providers/modelListingCapability";
import {
buildSyncedModelIdsByCanonicalProvider,
shouldSuppressStaticModelForExclusiveListing,
} from "@/app/api/v1/models/catalogSyncedCoverage";
import { buildAliasMaps } from "@/app/api/v1/models/catalogProviderMaps";
import { resolveCanonicalProviderId as resolveCanonicalProviderIdFromMaps } from "@/app/api/v1/models/catalogProviderMaps";

interface GetModelsDependencies {
createCapabilitySnapshot?: typeof createModelCapabilityResolutionSnapshot;
Expand Down Expand Up @@ -107,9 +115,42 @@ export async function handleGetModels(request: Request, dependencies: GetModelsD
const capabilitySnapshot = (
dependencies.createCapabilitySnapshot ?? createModelCapabilityResolutionSnapshot
)();

// #10615: a static row can be `available: true` here while /v1/models has already
// dropped it because a provider's live-synced catalog covers it (or, for
// exclusive-listing providers like Cursor, replaces the static list entirely).
// Recompute the same suppression /v1/models applies so the two endpoints agree.
let syncedModelIdsByCanonicalProvider = new Map<string, Set<string>>();
let resolveCanonicalProviderIdForStatic: (alias: string) => string = (alias) => alias;
try {
const syncedModelsByProvider = await getAllActiveSyncedModels();
const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();
const resolve = (aliasOrId: string, fallbackProviderId?: string) =>
resolveCanonicalProviderIdFromMaps(aliasToProviderId, aliasOrId, fallbackProviderId);
resolveCanonicalProviderIdForStatic = (alias: string) => resolve(alias);
syncedModelIdsByCanonicalProvider = buildSyncedModelIdsByCanonicalProvider(
syncedModelsByProvider,
resolve,
{},
providerIdToAlias
);
} catch {
// Synced catalog unavailable — fall through with static-only availability.
}

const models = candidates.map((m: any) => {
const fullModel = `${m.provider}/${m.model}`;
const available = !activeProviders || activeProviders.has(m.provider);
const canonicalProviderId = resolveCanonicalProviderIdForStatic(m.provider);
const syncedForProvider = syncedModelIdsByCanonicalProvider.get(canonicalProviderId);
const providerHasSynced = syncedForProvider !== undefined && syncedForProvider.size > 0;
const suppressedBySync = shouldSuppressStaticModelForExclusiveListing({
exclusiveListing: providerUsesExclusiveSyncedListing(canonicalProviderId),
providerHasSynced,
staticModelId: m.model,
syncedModelIds: syncedForProvider ? [...syncedForProvider] : [],
});
const available =
(!activeProviders || activeProviders.has(m.provider)) && !suppressedBySync;
return {
...m,
fullModel,
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/api-models-v1-models-mismatch-10615.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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-10615-"));
process.env.DATA_DIR = TEST_DATA_DIR;

const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const localDb = await import("../../src/lib/localDb.ts");
const modelsRoute = await import("../../src/app/api/models/route.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");

test.after(() => {
core.resetDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {}
});

test("#10615: /api/models must agree with /v1/models on exclusive synced-listing coverage", async () => {
const connection = await providersDb.createProviderConnection({
provider: "cursor",
authType: "oauth",
name: "cursor-main",
accessToken: "cursor-access-token",
isActive: true,
});

const apiModelsRes = await modelsRoute.GET(new Request("http://localhost/api/models"));
const apiModelsBody = (await apiModelsRes.json()) as {
models: Array<{ provider: string; model: string; fullModel: string; available: boolean }>;
};
const staticRow = apiModelsBody.models.find((m) => m.fullModel === "cu/composer-2.5");
assert.ok(staticRow, "/api/models must list the static cursor model");
assert.equal(staticRow!.available, true);

await localDb.replaceSyncedAvailableModelsForConnection("cursor", connection.id, [
{ id: "composer-3", name: "Composer 3" },
]);
v1ModelsCatalog.__resetCatalogBuilderRunsForTest();

const v1Res = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models")
);
const v1Body = (await v1Res.json()) as { data: Array<{ id: string }> };
const v1Ids = v1Body.data.map((m) => m.id);

assert.equal(v1Res.status, 200);
assert.ok(v1Ids.includes("cu/composer-3"));
assert.equal(v1Ids.includes("cu/composer-2.5"), false);

const apiModelsAfterRes = await modelsRoute.GET(new Request("http://localhost/api/models"));
const apiModelsAfterBody = (await apiModelsAfterRes.json()) as {
models: Array<{ fullModel: string; available: boolean }>;
};
const staticRowAfter = apiModelsAfterBody.models.find((m) => m.fullModel === "cu/composer-2.5");
assert.ok(staticRowAfter);
assert.equal(
staticRowAfter!.available,
false,
"/api/models must mark a synced-superseded static model as unavailable"
);
});
Loading