/** * Provider model catalog: fetches the list of models available to an * Organization from its ACTIVE provider connection (OpenRouter), with an * in-memory TTL cache so the admin model picker stays responsive without * hammering the upstream API on every page load. * * The model list is **derived** from the provider connection — there is no * separate model table to maintain. When an org activates a provider, its * models become selectable; when the provider is disabled, the list empties. */ import type { PrismaClient } from "@prisma/client"; import type { LocalSecretEnvelope } from "../security/secretEnvelope.js"; import { decryptStoredProviderCredential } from "./providerConnections.js"; /** A model entry surfaced to the admin model picker. */ export interface ProviderModelEntry { readonly id: string; readonly label: string; readonly toolCapable: boolean; } /** A model entry as returned by OpenRouter's /v1/models endpoint. */ interface OpenRouterModel { readonly id: string; readonly name: string; readonly supported_parameters: readonly string[]; } interface OpenRouterModelsResponse { readonly data: readonly OpenRouterModel[]; } /** Cache entry: models + expiry timestamp. */ interface CacheEntry { readonly models: readonly ProviderModelEntry[]; readonly expiresAt: number; } const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes /** * Per-organization model catalog cache. Keyed by organizationId so that * multiple orgs in the same Silo don't cross-pollute. The cache is intentionally * process-local: it survives for the lifetime of the Hub process and is * invalidated by TTL, not by DB events. A cache miss re-fetches from the * provider API. */ export class ProviderModelCatalog { private readonly cache = new Map(); private readonly fetchImpl: typeof fetch; constructor( private readonly prisma: PrismaClient, private readonly secrets: LocalSecretEnvelope, private readonly ttlMs: number = DEFAULT_TTL_MS, fetchImpl: typeof fetch = fetch, ) { this.fetchImpl = fetchImpl; } /** * List tool-capable models available to the organization's ACTIVE provider * connection. Returns cached results when fresh; otherwise fetches from the * provider API. Falls back to an empty list (not an error) when the org has * no ACTIVE provider — the admin sees the env-default model from the * separate env fallback in the route. */ async listModels(organizationId: string): Promise { const cached = this.cache.get(organizationId); if (cached !== undefined && cached.expiresAt > Date.now()) { return cached.models; } const credential = await this.resolveActiveProviderCredential(organizationId); if (credential === null) return []; const models = await this.fetchModelsFromProvider(credential); this.cache.set(organizationId, { models, expiresAt: Date.now() + this.ttlMs, }); return models; } /** Force a cache invalidation (e.g. after provider connection changes). */ invalidate(organizationId: string): void { this.cache.delete(organizationId); } private async resolveActiveProviderCredential( organizationId: string, ): Promise<{ readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string } | null> { const connection = await this.prisma.organizationProviderConnection.findFirst({ where: { organizationId, status: "ACTIVE" }, include: { activeSecretVersion: true }, }); const version = connection?.activeSecretVersion; if (connection === null || version === null || version === undefined || version.retiredAt !== null) { return null; } const payload = decryptStoredProviderCredential(this.secrets, { organizationId, connectionId: connection.id, providerId: connection.providerId, secretVersionId: version.id, envelopeVersion: version.envelopeVersion, keyId: version.keyId, envelope: version.envelope, }); return { baseUrl: payload.baseUrl, authToken: payload.authToken, anthropicApiKey: payload.anthropicApiKey, }; } private async fetchModelsFromProvider( credential: { readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string }, ): Promise { const base = credential.baseUrl.endsWith("/") ? credential.baseUrl : `${credential.baseUrl}/`; // Filter to models that support tool calling — the agent runner requires it. const url = new URL("v1/models?supported_parameters=tools", base); const headers: Record = { authorization: `Bearer ${credential.authToken}`, accept: "application/json", }; if (credential.anthropicApiKey !== "") headers["x-api-key"] = credential.anthropicApiKey; const response = await this.fetchImpl(url, { method: "GET", headers, signal: AbortSignal.timeout(15_000), }); if (!response.ok) { throw new Error(`provider models request failed: status ${response.status}`); } const body = (await response.json()) as OpenRouterModelsResponse; return body.data.map((m) => ({ id: m.id, label: m.name, toolCapable: m.supported_parameters.includes("tools"), })); } }