Browse Source
Implement ProviderInterface for fal.ai model marketplace: - Model discovery via REST API (listModels, searchModels, getModel) - Filter to relevant categories (text-to-image, image-to-image, text-to-video, image-to-video) - Optional API key auth (works without key but with rate limits) - Self-registration in provider registry on import - Stub generate() for Phase 3 implementation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
1 changed files with 262 additions and 0 deletions
@ -0,0 +1,262 @@ |
|||||
|
/** |
||||
|
* fal.ai Provider Implementation |
||||
|
* |
||||
|
* Implements ProviderInterface for fal.ai's AI model marketplace. |
||||
|
* Provides model discovery via fal.ai's REST API and self-registers |
||||
|
* in the provider registry when imported. |
||||
|
* |
||||
|
* Usage: |
||||
|
* import "@/lib/providers/fal"; // Just importing registers the provider
|
||||
|
* |
||||
|
* // Or get it from registry:
|
||||
|
* import { getProvider } from "@/lib/providers"; |
||||
|
* const fal = getProvider("fal"); |
||||
|
* |
||||
|
* Note: fal.ai works without an API key but with rate limits. |
||||
|
* Providing a key enables higher rate limits. |
||||
|
*/ |
||||
|
|
||||
|
import { |
||||
|
ProviderInterface, |
||||
|
ProviderModel, |
||||
|
ModelCapability, |
||||
|
GenerationInput, |
||||
|
GenerationOutput, |
||||
|
registerProvider, |
||||
|
} from "@/lib/providers"; |
||||
|
|
||||
|
const FAL_API_BASE = "https://api.fal.ai/v1"; |
||||
|
const PROVIDER_SETTINGS_KEY = "node-banana-provider-settings"; |
||||
|
|
||||
|
/** |
||||
|
* Categories we care about for image/video generation |
||||
|
*/ |
||||
|
const RELEVANT_CATEGORIES = [ |
||||
|
"text-to-image", |
||||
|
"image-to-image", |
||||
|
"text-to-video", |
||||
|
"image-to-video", |
||||
|
]; |
||||
|
|
||||
|
/** |
||||
|
* Response schema from fal.ai models endpoint |
||||
|
*/ |
||||
|
interface FalModelsResponse { |
||||
|
models: FalModel[]; |
||||
|
next_cursor: string | null; |
||||
|
has_more: boolean; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Model schema from fal.ai API |
||||
|
*/ |
||||
|
interface FalModel { |
||||
|
endpoint_id: string; |
||||
|
metadata: { |
||||
|
display_name: string; |
||||
|
category: string; |
||||
|
description: string; |
||||
|
status: "active" | "deprecated"; |
||||
|
tags: string[]; |
||||
|
updated_at: string; |
||||
|
is_favorited: boolean | null; |
||||
|
thumbnail_url: string; |
||||
|
model_url: string; |
||||
|
date: string; |
||||
|
highlighted: boolean; |
||||
|
pinned: boolean; |
||||
|
thumbnail_animated_url?: string; |
||||
|
github_url?: string; |
||||
|
license_type?: "commercial" | "research" | "private"; |
||||
|
}; |
||||
|
openapi?: Record<string, unknown>; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get API key from localStorage (client-side only) |
||||
|
* Returns null when running on server or if not configured |
||||
|
*/ |
||||
|
function getApiKeyFromStorage(): string | null { |
||||
|
if (typeof window === "undefined") { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const settingsJson = localStorage.getItem(PROVIDER_SETTINGS_KEY); |
||||
|
if (!settingsJson) return null; |
||||
|
|
||||
|
const settings = JSON.parse(settingsJson); |
||||
|
return settings?.providers?.fal?.apiKey ?? null; |
||||
|
} catch { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Map fal.ai category to ModelCapability |
||||
|
* Returns the capability if it's one we support, null otherwise |
||||
|
*/ |
||||
|
function mapCategoryToCapability(category: string): ModelCapability | null { |
||||
|
if (RELEVANT_CATEGORIES.includes(category)) { |
||||
|
return category as ModelCapability; |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Check if a model has a relevant category |
||||
|
*/ |
||||
|
function isRelevantModel(model: FalModel): boolean { |
||||
|
return RELEVANT_CATEGORIES.includes(model.metadata.category); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Map fal.ai model to our normalized ProviderModel format |
||||
|
*/ |
||||
|
function mapToProviderModel(model: FalModel): ProviderModel { |
||||
|
const capability = mapCategoryToCapability(model.metadata.category); |
||||
|
|
||||
|
return { |
||||
|
id: model.endpoint_id, |
||||
|
name: model.metadata.display_name, |
||||
|
description: model.metadata.description, |
||||
|
provider: "fal", |
||||
|
capabilities: capability ? [capability] : [], |
||||
|
coverImage: model.metadata.thumbnail_url, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Build authorization headers for fal.ai API |
||||
|
*/ |
||||
|
function buildHeaders(apiKey: string | null): HeadersInit { |
||||
|
const headers: HeadersInit = {}; |
||||
|
if (apiKey) { |
||||
|
headers["Authorization"] = `Key ${apiKey}`; |
||||
|
} |
||||
|
return headers; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Build category filter query string for relevant categories |
||||
|
*/ |
||||
|
function buildCategoryFilter(): string { |
||||
|
return RELEVANT_CATEGORIES.map((cat) => `category=${encodeURIComponent(cat)}`).join("&"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* fal.ai provider implementation |
||||
|
*/ |
||||
|
const falProvider: ProviderInterface = { |
||||
|
id: "fal", |
||||
|
name: "fal.ai", |
||||
|
|
||||
|
async listModels(): Promise<ProviderModel[]> { |
||||
|
const apiKey = getApiKeyFromStorage(); |
||||
|
|
||||
|
try { |
||||
|
// Fetch models with category filter for relevant types
|
||||
|
const url = `${FAL_API_BASE}/models?${buildCategoryFilter()}&status=active`; |
||||
|
const response = await fetch(url, { |
||||
|
headers: buildHeaders(apiKey), |
||||
|
}); |
||||
|
|
||||
|
if (!response.ok) { |
||||
|
throw new Error(`fal.ai API error: ${response.status}`); |
||||
|
} |
||||
|
|
||||
|
const data: FalModelsResponse = await response.json(); |
||||
|
|
||||
|
// Filter to relevant categories and map to ProviderModel
|
||||
|
return data.models.filter(isRelevantModel).map(mapToProviderModel); |
||||
|
} catch (error) { |
||||
|
console.error("[fal.ai] Failed to list models:", error); |
||||
|
return []; |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
async searchModels(query: string): Promise<ProviderModel[]> { |
||||
|
const apiKey = getApiKeyFromStorage(); |
||||
|
|
||||
|
try { |
||||
|
// Search with query and category filter
|
||||
|
const url = `${FAL_API_BASE}/models?q=${encodeURIComponent(query)}&${buildCategoryFilter()}&status=active`; |
||||
|
const response = await fetch(url, { |
||||
|
headers: buildHeaders(apiKey), |
||||
|
}); |
||||
|
|
||||
|
if (!response.ok) { |
||||
|
throw new Error(`fal.ai API error: ${response.status}`); |
||||
|
} |
||||
|
|
||||
|
const data: FalModelsResponse = await response.json(); |
||||
|
|
||||
|
// Filter to relevant categories and map to ProviderModel
|
||||
|
return data.models.filter(isRelevantModel).map(mapToProviderModel); |
||||
|
} catch (error) { |
||||
|
console.error("[fal.ai] Failed to search models:", error); |
||||
|
return []; |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
async getModel(modelId: string): Promise<ProviderModel | null> { |
||||
|
const apiKey = getApiKeyFromStorage(); |
||||
|
|
||||
|
try { |
||||
|
// Fetch specific model by endpoint_id
|
||||
|
const url = `${FAL_API_BASE}/models?endpoint_id=${encodeURIComponent(modelId)}`; |
||||
|
const response = await fetch(url, { |
||||
|
headers: buildHeaders(apiKey), |
||||
|
}); |
||||
|
|
||||
|
if (!response.ok) { |
||||
|
if (response.status === 404) { |
||||
|
return null; |
||||
|
} |
||||
|
throw new Error(`fal.ai API error: ${response.status}`); |
||||
|
} |
||||
|
|
||||
|
const data: FalModelsResponse = await response.json(); |
||||
|
|
||||
|
if (data.models.length === 0) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
const model = data.models[0]; |
||||
|
|
||||
|
// Check if it's a relevant model type
|
||||
|
if (!isRelevantModel(model)) { |
||||
|
console.warn(`[fal.ai] Model ${modelId} is not an image/video model`); |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
return mapToProviderModel(model); |
||||
|
} catch (error) { |
||||
|
console.error("[fal.ai] Failed to get model:", error); |
||||
|
return null; |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
async generate(_input: GenerationInput): Promise<GenerationOutput> { |
||||
|
// Generation will be implemented in Phase 3
|
||||
|
return { |
||||
|
success: false, |
||||
|
error: "Not implemented - generation support coming in Phase 3", |
||||
|
}; |
||||
|
}, |
||||
|
|
||||
|
isConfigured(): boolean { |
||||
|
// fal.ai works without API key (with rate limits)
|
||||
|
// Return true if key exists, but note that provider is usable without it
|
||||
|
return !!getApiKeyFromStorage(); |
||||
|
}, |
||||
|
|
||||
|
getApiKey(): string | null { |
||||
|
return getApiKeyFromStorage(); |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
// Self-register when module is imported
|
||||
|
registerProvider(falProvider); |
||||
|
|
||||
|
export default falProvider; |
||||
Loading…
Reference in new issue