From 03c7ecc905ff8455b0ce4d0e9de611b15fc402b2 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 16 Jan 2026 14:56:18 +1300 Subject: [PATCH] feat(24-01): Fetch fal.ai pricing and merge into models Add fetchFalPricing() to batch-fetch pricing from fal.ai API: - GET /v1/models/pricing?endpoint_id={ids} - Maps "image" unit -> per-run, others -> per-second - Best-effort: errors logged but don't fail model fetching Pricing is merged into ProviderModel after fetching fal.ai models. Co-Authored-By: Claude Opus 4.5 --- src/app/api/models/route.ts | 89 +++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 60661186..d3b74fe9 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -124,6 +124,19 @@ interface FalModel { openapi?: Record; } +interface FalPricingResponse { + prices: FalPrice[]; + has_more: boolean; + next_cursor: string | null; +} + +interface FalPrice { + endpoint_id: string; + unit_price: number; + unit: string; // "image", "video", "second", etc. + currency: string; +} + // ============ Response Types ============ interface ProviderResult { @@ -280,6 +293,68 @@ function mapFalModel(model: FalModel): ProviderModel { }; } +/** + * Fetch pricing for a list of fal.ai endpoint IDs + * Returns a Map of endpoint_id -> pricing info + * Best-effort: errors are logged but don't fail the request + */ +async function fetchFalPricing( + endpointIds: string[], + apiKey: string | null +): Promise> { + const pricingMap = new Map(); + + if (endpointIds.length === 0) { + return pricingMap; + } + + const headers: HeadersInit = {}; + if (apiKey) { + headers["Authorization"] = `Key ${apiKey}`; + } + + try { + // Batch endpoint IDs (API supports up to ~50 at once based on URL length limits) + const batchSize = 50; + for (let i = 0; i < endpointIds.length; i += batchSize) { + const batch = endpointIds.slice(i, i + batchSize); + const endpointIdsParam = batch.join(","); + const url = `${FAL_API_BASE}/models/pricing?endpoint_id=${encodeURIComponent(endpointIdsParam)}`; + + const response = await fetch(url, { headers }); + + if (!response.ok) { + console.warn(`[Models] fal.ai pricing API error: ${response.status}`); + continue; + } + + const data: FalPricingResponse = await response.json(); + + for (const price of data.prices) { + // Map fal.ai units to our pricing type + // "image" -> per-run (single generation) + // "video", "second" -> per-second (duration-based) + const pricingType: "per-run" | "per-second" = + price.unit === "image" ? "per-run" : "per-second"; + + pricingMap.set(price.endpoint_id, { + type: pricingType, + amount: price.unit_price, + currency: price.currency, + }); + } + } + } catch (error) { + // Best-effort: log warning but don't fail + console.warn( + `[Models] Failed to fetch fal.ai pricing:`, + error instanceof Error ? error.message : "Unknown error" + ); + } + + return pricingMap; +} + async function fetchFalModels( apiKey: string | null, searchQuery?: string @@ -320,6 +395,20 @@ async function fetchFalModels( pageCount++; } + // Fetch pricing for all models (best-effort) + if (allModels.length > 0) { + const endpointIds = allModels.map((m) => m.id); + const pricingMap = await fetchFalPricing(endpointIds, apiKey); + + // Merge pricing into models + for (const model of allModels) { + const pricing = pricingMap.get(model.id); + if (pricing) { + model.pricing = pricing; + } + } + } + return allModels; }