forked from bai/curriculum-project-hub
35251986af
The admin role model picker was hardcoded to the env-default model registry (createDefaultModelRegistry), which only ever returned a single Sonnet model. Roles could not select any other model regardless of what the org's provider connection supported. Replace the env-only model list with a ProviderModelCatalog that: - Resolves the org's ACTIVE provider connection credential (BYOK or platform-managed, encrypted via ADR-0024 envelope) - Calls OpenRouter GET /v1/models?supported_parameters=tools to list tool-capable models available to that org - Caches results in-memory with a 5-minute TTL per organization - Falls back to the env-default registry when no ACTIVE provider exists The runtime modelRegistry no longer validates role.defaultModel against the env model list — the admin already validated by selection from the provider catalog. The env list remains as the fallback for roles with null defaultModel. The admin roles page loads models independently (non-blocking) so roles remain editable even if the provider API is slow or unreachable.
145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
/**
|
|
* 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<string, CacheEntry>();
|
|
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<readonly ProviderModelEntry[]> {
|
|
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<readonly ProviderModelEntry[]> {
|
|
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<string, string> = {
|
|
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"),
|
|
}));
|
|
}
|
|
}
|