diff --git a/changelog.d/fixes/10615-api-models-v1-models-id-mismatch.md b/changelog.d/fixes/10615-api-models-v1-models-id-mismatch.md new file mode 100644 index 0000000000..8cee943efa --- /dev/null +++ b/changelog.d/fixes/10615-api-models-v1-models-id-mismatch.md @@ -0,0 +1 @@ +- fix(dashboard): make /api/models agree with /v1/models on synced-catalog coverage instead of reporting stale models as available (#10615) diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 55282ecf2e..293d34a607 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -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; @@ -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>(); + 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, diff --git a/tests/unit/api-models-v1-models-mismatch-10615.test.ts b/tests/unit/api-models-v1-models-mismatch-10615.test.ts new file mode 100644 index 0000000000..b6a241844a --- /dev/null +++ b/tests/unit/api-models-v1-models-mismatch-10615.test.ts @@ -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" + ); +});