Spaces:
Paused
Paused
| import { | |
| BedrockClient, | |
| ListFoundationModelsCommand, | |
| type ListFoundationModelsCommandOutput, | |
| } from "@aws-sdk/client-bedrock"; | |
| import type { BedrockDiscoveryConfig, ModelDefinitionConfig } from "../config/types.js"; | |
| const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600; | |
| const DEFAULT_CONTEXT_WINDOW = 32000; | |
| const DEFAULT_MAX_TOKENS = 4096; | |
| const DEFAULT_COST = { | |
| input: 0, | |
| output: 0, | |
| cacheRead: 0, | |
| cacheWrite: 0, | |
| }; | |
| type BedrockModelSummary = NonNullable<ListFoundationModelsCommandOutput["modelSummaries"]>[number]; | |
| type BedrockDiscoveryCacheEntry = { | |
| expiresAt: number; | |
| value?: ModelDefinitionConfig[]; | |
| inFlight?: Promise<ModelDefinitionConfig[]>; | |
| }; | |
| const discoveryCache = new Map<string, BedrockDiscoveryCacheEntry>(); | |
| let hasLoggedBedrockError = false; | |
| function normalizeProviderFilter(filter?: string[]): string[] { | |
| if (!filter || filter.length === 0) { | |
| return []; | |
| } | |
| const normalized = new Set( | |
| filter.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0), | |
| ); | |
| return Array.from(normalized).toSorted(); | |
| } | |
| function buildCacheKey(params: { | |
| region: string; | |
| providerFilter: string[]; | |
| refreshIntervalSeconds: number; | |
| defaultContextWindow: number; | |
| defaultMaxTokens: number; | |
| }): string { | |
| return JSON.stringify(params); | |
| } | |
| function includesTextModalities(modalities?: Array<string>): boolean { | |
| return (modalities ?? []).some((entry) => entry.toLowerCase() === "text"); | |
| } | |
| function isActive(summary: BedrockModelSummary): boolean { | |
| const status = summary.modelLifecycle?.status; | |
| return typeof status === "string" ? status.toUpperCase() === "ACTIVE" : false; | |
| } | |
| function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image"> { | |
| const inputs = summary.inputModalities ?? []; | |
| const mapped = new Set<"text" | "image">(); | |
| for (const modality of inputs) { | |
| const lower = modality.toLowerCase(); | |
| if (lower === "text") { | |
| mapped.add("text"); | |
| } | |
| if (lower === "image") { | |
| mapped.add("image"); | |
| } | |
| } | |
| if (mapped.size === 0) { | |
| mapped.add("text"); | |
| } | |
| return Array.from(mapped); | |
| } | |
| function inferReasoningSupport(summary: BedrockModelSummary): boolean { | |
| const haystack = `${summary.modelId ?? ""} ${summary.modelName ?? ""}`.toLowerCase(); | |
| return haystack.includes("reasoning") || haystack.includes("thinking"); | |
| } | |
| function resolveDefaultContextWindow(config?: BedrockDiscoveryConfig): number { | |
| const value = Math.floor(config?.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW); | |
| return value > 0 ? value : DEFAULT_CONTEXT_WINDOW; | |
| } | |
| function resolveDefaultMaxTokens(config?: BedrockDiscoveryConfig): number { | |
| const value = Math.floor(config?.defaultMaxTokens ?? DEFAULT_MAX_TOKENS); | |
| return value > 0 ? value : DEFAULT_MAX_TOKENS; | |
| } | |
| function matchesProviderFilter(summary: BedrockModelSummary, filter: string[]): boolean { | |
| if (filter.length === 0) { | |
| return true; | |
| } | |
| const providerName = | |
| summary.providerName ?? | |
| (typeof summary.modelId === "string" ? summary.modelId.split(".")[0] : undefined); | |
| const normalized = providerName?.trim().toLowerCase(); | |
| if (!normalized) { | |
| return false; | |
| } | |
| return filter.includes(normalized); | |
| } | |
| function shouldIncludeSummary(summary: BedrockModelSummary, filter: string[]): boolean { | |
| if (!summary.modelId?.trim()) { | |
| return false; | |
| } | |
| if (!matchesProviderFilter(summary, filter)) { | |
| return false; | |
| } | |
| if (summary.responseStreamingSupported !== true) { | |
| return false; | |
| } | |
| if (!includesTextModalities(summary.outputModalities)) { | |
| return false; | |
| } | |
| if (!isActive(summary)) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| function toModelDefinition( | |
| summary: BedrockModelSummary, | |
| defaults: { contextWindow: number; maxTokens: number }, | |
| ): ModelDefinitionConfig { | |
| const id = summary.modelId?.trim() ?? ""; | |
| return { | |
| id, | |
| name: summary.modelName?.trim() || id, | |
| reasoning: inferReasoningSupport(summary), | |
| input: mapInputModalities(summary), | |
| cost: DEFAULT_COST, | |
| contextWindow: defaults.contextWindow, | |
| maxTokens: defaults.maxTokens, | |
| }; | |
| } | |
| export function resetBedrockDiscoveryCacheForTest(): void { | |
| discoveryCache.clear(); | |
| hasLoggedBedrockError = false; | |
| } | |
| export async function discoverBedrockModels(params: { | |
| region: string; | |
| config?: BedrockDiscoveryConfig; | |
| now?: () => number; | |
| clientFactory?: (region: string) => BedrockClient; | |
| }): Promise<ModelDefinitionConfig[]> { | |
| const refreshIntervalSeconds = Math.max( | |
| 0, | |
| Math.floor(params.config?.refreshInterval ?? DEFAULT_REFRESH_INTERVAL_SECONDS), | |
| ); | |
| const providerFilter = normalizeProviderFilter(params.config?.providerFilter); | |
| const defaultContextWindow = resolveDefaultContextWindow(params.config); | |
| const defaultMaxTokens = resolveDefaultMaxTokens(params.config); | |
| const cacheKey = buildCacheKey({ | |
| region: params.region, | |
| providerFilter, | |
| refreshIntervalSeconds, | |
| defaultContextWindow, | |
| defaultMaxTokens, | |
| }); | |
| const now = params.now?.() ?? Date.now(); | |
| if (refreshIntervalSeconds > 0) { | |
| const cached = discoveryCache.get(cacheKey); | |
| if (cached?.value && cached.expiresAt > now) { | |
| return cached.value; | |
| } | |
| if (cached?.inFlight) { | |
| return cached.inFlight; | |
| } | |
| } | |
| const clientFactory = params.clientFactory ?? ((region: string) => new BedrockClient({ region })); | |
| const client = clientFactory(params.region); | |
| const discoveryPromise = (async () => { | |
| const response = await client.send(new ListFoundationModelsCommand({})); | |
| const discovered: ModelDefinitionConfig[] = []; | |
| for (const summary of response.modelSummaries ?? []) { | |
| if (!shouldIncludeSummary(summary, providerFilter)) { | |
| continue; | |
| } | |
| discovered.push( | |
| toModelDefinition(summary, { | |
| contextWindow: defaultContextWindow, | |
| maxTokens: defaultMaxTokens, | |
| }), | |
| ); | |
| } | |
| return discovered.toSorted((a, b) => a.name.localeCompare(b.name)); | |
| })(); | |
| if (refreshIntervalSeconds > 0) { | |
| discoveryCache.set(cacheKey, { | |
| expiresAt: now + refreshIntervalSeconds * 1000, | |
| inFlight: discoveryPromise, | |
| }); | |
| } | |
| try { | |
| const value = await discoveryPromise; | |
| if (refreshIntervalSeconds > 0) { | |
| discoveryCache.set(cacheKey, { | |
| expiresAt: now + refreshIntervalSeconds * 1000, | |
| value, | |
| }); | |
| } | |
| return value; | |
| } catch (error) { | |
| if (refreshIntervalSeconds > 0) { | |
| discoveryCache.delete(cacheKey); | |
| } | |
| if (!hasLoggedBedrockError) { | |
| hasLoggedBedrockError = true; | |
| console.warn(`[bedrock-discovery] Failed to list models: ${String(error)}`); | |
| } | |
| return []; | |
| } | |
| } | |