import { isAllowedByLists, isModelKeyPattern } from "@server/lib/aiModelKeyMatch"; // Anthropic's Models API pagination: 20 per page by default, 1..1000. export const MODEL_PAGE_DEFAULT_LIMIT = 20; export const MODEL_PAGE_MAX_LIMIT = 1000; // Release dates aren't something we can know for a wildcard allow pattern or a // catalog entry. The Models API explicitly permits an epoch value when the // release date is unknown. const UNKNOWN_CREATED_AT = new Date(0).toISOString(); /** * One entry of Anthropic's `GET /v1/models` response. Only the identity fields * can be filled in from a provider's model lists - token limits and * per-model capability flags aren't derivable from an allow/block list, and the * API schema declares all three nullable. */ export type AnthropicModelInfo = { type: "model"; id: string; display_name: string; created_at: string; max_input_tokens: number | null; max_tokens: number | null; capabilities: null; }; /** A model row an administrator configured explicitly on a provider. */ export type ConfiguredModel = { name: string; createdAt: number }; /** * One attached provider's contribution to a resource's model listing, with the * allow/block lists already resolved for the attachment's access mode. */ export type ModelDiscoveryProvider = { providerId: number; allows: string[]; blocks: string[]; /** * Concrete model ids the provider's type is known to serve. This is what * lets a wildcard allow such as `claude-*` enumerate into real ids; * provider types with no catalog (aggregators, custom) pass an empty list * and surface only their exact allow entries. */ catalogModelIds: string[]; /** Keyed by model key, for display names and creation times. */ configured: Map; }; export type ModelPage = { data: AnthropicModelInfo[]; has_more: boolean; }; /** * Expands one provider's effective allow/block lists into concrete model ids. * Two sources feed the candidate set: exact (non-wildcard) allow entries, which * are already concrete ids, and the catalog for the provider's type, which is * what makes wildcard allows enumerable. Every candidate is then run back * through the same allow/block check the inference pipeline applies, so a block * pattern hides a model here exactly as it would reject it at request time. */ export function expandProviderModels( provider: ModelDiscoveryProvider ): AnthropicModelInfo[] { const candidates = new Set(); for (const allow of provider.allows) { if (!isModelKeyPattern(allow)) { candidates.add(allow); } } for (const modelId of provider.catalogModelIds) { candidates.add(modelId); } const models: AnthropicModelInfo[] = []; for (const modelKey of candidates) { if (!isAllowedByLists(modelKey, provider.allows, provider.blocks)) { continue; } const configured = provider.configured.get(modelKey); models.push({ type: "model", id: modelKey, display_name: configured?.name || modelKey, created_at: configured ? new Date(configured.createdAt).toISOString() : UNKNOWN_CREATED_AT, max_input_tokens: null, max_tokens: null, capabilities: null }); } return models; } /** * Aggregates the permitted models across every provider attached to a * resource. Unlike an inference request there is no requested model to * disambiguate on, so no provider selection happens - the listing is the union * of what each provider would accept, deduplicated by model id. */ export function listPermittedModels( providers: ModelDiscoveryProvider[] ): AnthropicModelInfo[] { const byModelId = new Map(); // Sorted so a model offered by two providers always resolves to the same // entry, which keeps the cursor ordering stable across requests. const ordered = [...providers].sort((a, b) => a.providerId - b.providerId); for (const provider of ordered) { for (const model of expandProviderModels(provider)) { if (!byModelId.has(model.id)) { byModelId.set(model.id, model); } } } // "More recently released models are listed first" per the Models API, // with the id as a tie-break so the ordering is total - cursor pagination // needs it to be stable between calls. return [...byModelId.values()].sort((a, b) => { const byCreated = b.created_at.localeCompare(a.created_at); return byCreated !== 0 ? byCreated : a.id.localeCompare(b.id); }); } /** * Applies Anthropic's cursor pagination to an ordered model list. `after_id` * returns the page immediately after that model, `before_id` the page * immediately before it. Returns an error message for a caller mistake * (both cursors, or a cursor naming a model that isn't in the list). */ export function paginateModels( models: AnthropicModelInfo[], limit: number, cursor: { afterId?: string; beforeId?: string } ): ModelPage | { error: string } { if (cursor.afterId && cursor.beforeId) { return { error: "Only one of after_id and before_id may be provided" }; } const cursorId = cursor.afterId ?? cursor.beforeId; if (!cursorId) { return { data: models.slice(0, limit), has_more: models.length > limit }; } const index = models.findIndex((model) => model.id === cursorId); if (index === -1) { return { error: `Unknown cursor id "${cursorId}"` }; } if (cursor.afterId) { const start = index + 1; return { data: models.slice(start, start + limit), has_more: models.length > start + limit }; } const start = Math.max(0, index - limit); return { data: models.slice(start, index), has_more: start > 0 }; }