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
49 changes: 48 additions & 1 deletion packages/websocket/src/sdk/sdk-model-catalog-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type SdkV1ModelCatalogQuery
} from "@nodetool-ai/protocol/api-schemas/sdk-models-v1.js";
import {
collectProviderCatalogModels,
getAllModels,
getAvailableProviderIds
} from "../trpc/routers/models.js";
Expand Down Expand Up @@ -161,6 +162,43 @@ export function projectSdkModelCatalog(
});
}

// The per-provider model enumeration fans out to remote provider APIs and is
// the slow part of a catalog request. Its result is pure remote data (no
// local download state), so a short TTL cache is safe and keeps repeated SDK
// catalog polls fast.
const PROVIDER_CATALOG_TTL_MS = 60_000;
const providerCatalogCache = new Map<
string,
{ at: number; models: readonly UnifiedModel[] }
>();

function pruneProviderCatalogCache(now: number): void {
for (const [key, value] of providerCatalogCache) {
if (now - value.at >= PROVIDER_CATALOG_TTL_MS) {
providerCatalogCache.delete(key);
}
}
}

async function getCachedProviderCatalogModels(
userId: string
): Promise<readonly UnifiedModel[]> {
const now = Date.now();
pruneProviderCatalogCache(now);

const cached = providerCatalogCache.get(userId);
if (cached && now - cached.at < PROVIDER_CATALOG_TTL_MS) {
return cached.models;
}
const models = await collectProviderCatalogModels(userId);
providerCatalogCache.set(userId, { at: now, models });
return models;
}
Comment thread
heavy-d marked this conversation as resolved.

export function clearProviderCatalogCache(): void {
providerCatalogCache.clear();
}

function dedupeCatalogModels(models: readonly UnifiedModel[]): UnifiedModel[] {
const byKey = new Map<string, UnifiedModel>();
for (const model of models) {
Expand All @@ -176,8 +214,12 @@ export async function getSdkV1ModelCatalog(args: {
query: SdkV1ModelCatalogQuery;
recommendedModels?: readonly UnifiedModel[];
getWorkerModels?: () => Promise<readonly UnifiedModel[]>;
getProviderCatalogModels?: (
userId: string
) => Promise<readonly UnifiedModel[]>;
}): Promise<SdkV1ModelCatalog> {
let availableModels: readonly UnifiedModel[];
let providerCatalogModels: readonly UnifiedModel[];
let providerIds: readonly string[];
if (args.query.scope === "worker") {
if (!args.getWorkerModels) {
Expand All @@ -186,10 +228,14 @@ export async function getSdkV1ModelCatalog(args: {
);
}
availableModels = await args.getWorkerModels();
providerCatalogModels = [];
providerIds = [];
} else {
[availableModels, providerIds] = await Promise.all([
[availableModels, providerCatalogModels, providerIds] = await Promise.all([
getAllModels(args.userId),
(args.getProviderCatalogModels ?? getCachedProviderCatalogModels)(
args.userId
),
Comment on lines +234 to +238
getAvailableProviderIds(args.userId)
]);
}
Expand All @@ -199,6 +245,7 @@ export async function getSdkV1ModelCatalog(args: {
];
const models = dedupeCatalogModels([
...availableModels,
...providerCatalogModels,
...recommendedModels
]);
const manager =
Expand Down
47 changes: 47 additions & 0 deletions packages/websocket/src/trpc/routers/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,53 @@ async function collectProviderModelsForKind(
return out;
}

/**
* Every remote model the user's configured providers can enumerate, across all
* non-language-model types (embeddings, images, TTS, music, ASR, video), for the
* SDK model catalog. Unlike `collectProviderModelsForKind` this makes one pass
* per provider (each `getAvailable*Models` called once, no task filtering) so
* text_to_image and image_to_image capable models are both included. Language
* models are not collected here — `getAllModels` already enumerates them.
*/
Comment thread
Copilot marked this conversation as resolved.
export async function collectProviderCatalogModels(
userId: string
): Promise<UnifiedModel[]> {
const providerIds = await getAvailableProviderIds(userId);
const perProvider = await Promise.all(
providerIds.map((providerId) =>
safeProviderCall(
"catalogModels",
{ provider: providerId, userId },
async () => {
const instance = await instantiateProvider(providerId, userId);
if (!instance) return [];
const collect = (
fetchModels: () => Promise<Parameters<typeof toUnifiedModel>[0][]>,
type: string
) =>
safeProviderCall(
`catalogModels:${type}`,
{ provider: providerId, userId },
async () => (await fetchModels()).map((m) => toUnifiedModel(m, type)),
[] as UnifiedModel[]
);
const lists = await Promise.all([
collect(() => instance.getAvailableImageModels(), "image_model"),
collect(() => instance.getAvailableEmbeddingModels(), "embedding_model"),
collect(() => instance.getAvailableTTSModels(), "tts_model"),
collect(() => instance.getAvailableMusicModels(), "music_model"),
collect(() => instance.getAvailableASRModels(), "asr_model"),
collect(() => instance.getAvailableVideoModels(), "video_model")
]);
return lists.flat();
},
[] as UnifiedModel[]
)
)
);
return perProvider.flat();
}

function curatedForKind(kind: ModelSearchKind): UnifiedModel[] {
const modality = KIND_TO_MODALITY[kind];
// For text_generation/embedding/image/video, RECOMMENDED_MODELS entries are
Expand Down
115 changes: 115 additions & 0 deletions packages/websocket/tests/sdk-model-catalog-provider-models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UnifiedModel } from "@nodetool-ai/protocol";

const getAllModels = vi.fn<(userId: string) => Promise<UnifiedModel[]>>();
const getAvailableProviderIds = vi.fn<(userId: string) => Promise<string[]>>();
const collectProviderCatalogModels =
vi.fn<(userId: string) => Promise<UnifiedModel[]>>();

vi.mock("../src/trpc/routers/models.js", () => ({
getAllModels: (userId: string) => getAllModels(userId),
getAvailableProviderIds: (userId: string) => getAvailableProviderIds(userId),
collectProviderCatalogModels: (userId: string) =>
collectProviderCatalogModels(userId)
}));

vi.mock("@nodetool-ai/huggingface", () => ({
getExistingDownloadManager: () => null
}));

import {
clearProviderCatalogCache,
getSdkV1ModelCatalog
} from "../src/sdk/sdk-model-catalog-service.js";

const query = { scope: "local" as const, limit: 200 };

const languageModel: UnifiedModel = {
id: "gpt-test",
name: "GPT Test",
type: "language_model",
provider: "openai"
};

const falImageModel: UnifiedModel = {
id: "fal-ai/flux/schnell",
name: "FLUX.1 Schnell",
type: "image_model",
provider: "fal_ai"
};

const openaiImageModel: UnifiedModel = {
id: "gpt-image-2",
name: "GPT Image 2",
type: "image_model",
provider: "openai"
};

beforeEach(() => {
clearProviderCatalogCache();
getAllModels.mockReset().mockResolvedValue([languageModel]);
getAvailableProviderIds
.mockReset()
.mockResolvedValue(["openai", "fal_ai"]);
collectProviderCatalogModels
.mockReset()
.mockResolvedValue([falImageModel, openaiImageModel]);
});

describe("SDK model catalog provider models", () => {
it("includes provider-enumerated non-language models as ready_remote", async () => {
const catalog = await getSdkV1ModelCatalog({ userId: "alice", query });

const flux = catalog.entries.find(
(entry) => entry.id === "fal-ai/flux/schnell"
);
expect(flux).toMatchObject({
compatibility: "image_model",
availability: "ready_remote",
provider: "fal_ai"
});
});

it("dedupes provider-enumerated models against the recommended list", async () => {
const catalog = await getSdkV1ModelCatalog({ userId: "alice", query });

// gpt-image-2 exists both in RECOMMENDED_MODELS and in the provider
// enumeration; the catalog must carry it once per (type, provider, id).
const gptImage = catalog.entries.filter(
(entry) => entry.id === "gpt-image-2" && entry.provider === "openai"
);
expect(gptImage).toHaveLength(1);
});

it("caches the provider enumeration per user", async () => {
await getSdkV1ModelCatalog({ userId: "alice", query });
await getSdkV1ModelCatalog({ userId: "alice", query });
expect(collectProviderCatalogModels).toHaveBeenCalledTimes(1);

await getSdkV1ModelCatalog({ userId: "bob", query });
expect(collectProviderCatalogModels).toHaveBeenCalledTimes(2);
expect(collectProviderCatalogModels).toHaveBeenLastCalledWith("bob");
});

it("prefers an injected provider-catalog fetcher over the cache", async () => {
const injected = vi.fn().mockResolvedValue([falImageModel]);
await getSdkV1ModelCatalog({
userId: "alice",
query,
getProviderCatalogModels: injected
});
expect(injected).toHaveBeenCalledWith("alice");
expect(collectProviderCatalogModels).not.toHaveBeenCalled();
});

it("never enumerates providers for worker-scoped catalogs", async () => {
const catalog = await getSdkV1ModelCatalog({
userId: "alice",
query: { scope: "worker" as const, limit: 200 },
getWorkerModels: async () => [languageModel]
});
expect(collectProviderCatalogModels).not.toHaveBeenCalled();
expect(getAllModels).not.toHaveBeenCalled();
expect(catalog.scope).toBe("worker");
});
});
Loading