Compare commits

...

2 Commits

Author SHA1 Message Date
Jeff Emmett 19b5356f3d feat: add server-side AI service proxies for fal.ai and RunPod
Add proxy endpoints to Cloudflare Worker for AI services, keeping
API credentials server-side for better security architecture.

Changes:
- Add fal.ai proxy endpoints (/api/fal/*) for image generation
- Add RunPod proxy endpoints (/api/runpod/*) for image, video, text, whisper
- Update client code to use proxy pattern:
  - useLiveImage.tsx (fal.ai live image generation)
  - VideoGenShapeUtil.tsx (video generation)
  - ImageGenShapeUtil.tsx (image generation)
  - runpodApi.ts (whisper transcription)
  - llmUtils.ts (LLM text generation)
- Add Environment types for AI service configuration
- Improve Automerge migration: compare shape counts between formats
  to prevent data loss during format conversion

To deploy, set secrets:
  wrangler secret put FAL_API_KEY
  wrangler secret put RUNPOD_API_KEY

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:26:04 -05:00
Jeff Emmett 1df612660d Update task task-027 2025-12-25 18:59:45 -05:00
11 changed files with 625 additions and 298 deletions

View File

@ -4,7 +4,7 @@ title: Implement proper Automerge CRDT sync for offline-first support
status: In Progress
assignee: []
created_date: '2025-12-04 21:06'
updated_date: '2025-12-25 23:38'
updated_date: '2025-12-25 23:59'
labels:
- offline-sync
- crdt
@ -110,4 +110,10 @@ The Automerge Repo requires proper peer discovery. The adapter emits `peer-candi
1. Add debug logging to adapter.send() to verify Repo calls
2. Check sync states between local peer and server
3. May need to manually trigger sync or fix Repo configuration
Dec 25: Added debug logging and peer-candidate re-emission fix to CloudflareAdapter.ts
Key fix: Re-emit peer-candidate after documentId is set to trigger Repo sync (timing issue)
Committed and pushed to dev branch - needs testing to verify binary sync is now working
<!-- SECTION:NOTES:END -->

View File

@ -2,12 +2,14 @@
* useLiveImage Hook
* Captures drawings within a frame shape and sends them to Fal.ai for AI enhancement
* Based on draw-fast implementation, adapted for canvas-website with Automerge sync
*
* SECURITY: All fal.ai API calls go through the Cloudflare Worker proxy
* API keys are stored server-side, never exposed to the browser
*/
import React, { createContext, useContext, useEffect, useRef, useCallback, useState } from 'react'
import { Editor, TLShapeId, Box, exportToBlob } from 'tldraw'
import { fal } from '@fal-ai/client'
import { getFalConfig } from '@/lib/clientConfig'
import { getFalProxyConfig } from '@/lib/clientConfig'
// Fal.ai model endpoints
const FAL_MODEL_LCM = 'fal-ai/lcm-sd15-i2i' // Fast, real-time (~150ms)
@ -15,7 +17,7 @@ const FAL_MODEL_FLUX_CANNY = 'fal-ai/flux-control-lora-canny/image-to-image' //
interface LiveImageContextValue {
isConnected: boolean
apiKey: string | null
// Note: apiKey is no longer exposed to the browser
setApiKey: (key: string) => void
}
@ -23,53 +25,31 @@ const LiveImageContext = createContext<LiveImageContextValue | null>(null)
interface LiveImageProviderProps {
children: React.ReactNode
apiKey?: string
apiKey?: string // Deprecated - API keys are now server-side
}
/**
* Provider component that manages Fal.ai connection
* API keys are now stored server-side and proxied through Cloudflare Worker
*/
export function LiveImageProvider({ children, apiKey: initialApiKey }: LiveImageProviderProps) {
// Get default FAL key from clientConfig (includes the hardcoded default)
const falConfig = getFalConfig()
const defaultApiKey = falConfig?.apiKey || null
export function LiveImageProvider({ children }: LiveImageProviderProps) {
// Fal.ai is always "connected" via the proxy - actual auth happens server-side
const [isConnected, setIsConnected] = useState(true)
const [apiKey, setApiKeyState] = useState<string | null>(
initialApiKey || import.meta.env.VITE_FAL_API_KEY || defaultApiKey
)
const [isConnected, setIsConnected] = useState(false)
// Configure Fal.ai client when API key is available
// Log that we're using the proxy
useEffect(() => {
if (apiKey) {
fal.config({ credentials: apiKey })
setIsConnected(true)
} else {
setIsConnected(false)
}
}, [apiKey])
const setApiKey = useCallback((key: string) => {
setApiKeyState(key)
// Also save to localStorage for persistence
localStorage.setItem('fal_api_key', key)
const { proxyUrl } = getFalProxyConfig()
console.log('LiveImage: Using fal.ai proxy at', proxyUrl || '(same origin)')
}, [])
// Try to load API key from localStorage on mount (but only if no default key)
useEffect(() => {
if (!apiKey) {
const storedKey = localStorage.getItem('fal_api_key')
if (storedKey) {
setApiKeyState(storedKey)
} else if (defaultApiKey) {
// Use default key from config
setApiKeyState(defaultApiKey)
}
}
}, [defaultApiKey])
// setApiKey is now a no-op since keys are server-side
// Kept for backward compatibility with any code that tries to set a key
const setApiKey = useCallback((_key: string) => {
console.warn('LiveImage: setApiKey is deprecated. API keys are now stored server-side.')
}, [])
return (
<LiveImageContext.Provider value={{ isConnected, apiKey, setApiKey }}>
<LiveImageContext.Provider value={{ isConnected, setApiKey }}>
{children}
</LiveImageContext.Provider>
)
@ -177,7 +157,7 @@ export function useLiveImage({
}
}, [editor, getChildShapes])
// Generate AI image from the sketch
// Generate AI image from the sketch via proxy
const generateImage = useCallback(async () => {
if (!context?.isConnected || !enabled) {
return
@ -206,9 +186,13 @@ export function useLiveImage({
? `${prompt}, hd, award-winning, impressive, detailed`
: 'hd, award-winning, impressive, detailed illustration'
// Use the proxy endpoint instead of calling fal.ai directly
const { proxyUrl } = getFalProxyConfig()
const result = await fal.subscribe(modelEndpoint, {
input: {
const response = await fetch(`${proxyUrl}/subscribe/${modelEndpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: fullPrompt,
image_url: imageDataUrl,
strength: strength,
@ -217,11 +201,20 @@ export function useLiveImage({
num_inference_steps: model === 'lcm' ? 4 : 20,
guidance_scale: model === 'lcm' ? 1 : 7.5,
enable_safety_checks: false,
},
pollInterval: 1000,
logs: true,
})
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText })) as { error?: string }
throw new Error(errorData.error || `Proxy error: ${response.status}`)
}
const data = await response.json() as {
images?: Array<{ url?: string } | string>
image?: { url?: string } | string
output?: { url?: string } | string
}
// Check if this result is still relevant
if (currentVersion !== requestVersionRef.current) {
return
@ -230,15 +223,13 @@ export function useLiveImage({
// Extract image URL from result
let imageUrl: string | null = null
if (result.data) {
const data = result.data as any
if (data.images && Array.isArray(data.images) && data.images.length > 0) {
imageUrl = data.images[0].url || data.images[0]
} else if (data.image) {
imageUrl = data.image.url || data.image
} else if (data.output) {
imageUrl = typeof data.output === 'string' ? data.output : data.output.url
}
if (data.images && Array.isArray(data.images) && data.images.length > 0) {
const firstImage = data.images[0]
imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null
} else if (data.image) {
imageUrl = typeof data.image === 'string' ? data.image : data.image?.url || null
} else if (data.output) {
imageUrl = typeof data.output === 'string' ? data.output : data.output?.url || null
}
if (imageUrl) {

View File

@ -99,118 +99,114 @@ export function getClientConfig(): ClientConfig {
}
}
// Default fal.ai API key - shared for all users
const DEFAULT_FAL_API_KEY = 'a4125de3-283b-4a2b-a2ef-eeac8eb25d92:45f0c80070ff0fe3ed1d43a82a332442'
// ============================================================================
// IMPORTANT: API keys are now stored server-side only!
// All AI service calls go through the Cloudflare Worker proxy at /api/fal/* and /api/runpod/*
// This prevents exposing API keys in the browser
// ============================================================================
// Default RunPod API key - shared across all endpoints
// This allows all users to access AI features without their own API keys
const DEFAULT_RUNPOD_API_KEY = 'rpa_YYOARL5MEBTTKKWGABRKTW2CVHQYRBTOBZNSGIL3lwwfdz'
/**
* Get the worker API URL for proxied requests
* In production, this will be the same origin as the app
* In development, we need to use the worker's dev port
*/
export function getWorkerApiUrl(): string {
// Check for explicit worker URL override (useful for development)
const workerUrl = import.meta.env.VITE_WORKER_URL
if (workerUrl) {
return workerUrl
}
// Default RunPod endpoint IDs (from CLAUDE.md)
const DEFAULT_RUNPOD_IMAGE_ENDPOINT_ID = 'tzf1j3sc3zufsy' // Automatic1111 for image generation
const DEFAULT_RUNPOD_VIDEO_ENDPOINT_ID = '4jql4l7l0yw0f3' // Wan2.2 for video generation
const DEFAULT_RUNPOD_TEXT_ENDPOINT_ID = '03g5hz3hlo8gr2' // vLLM for text generation
const DEFAULT_RUNPOD_WHISPER_ENDPOINT_ID = 'lrtisuv8ixbtub' // Whisper for transcription
// In production, use same origin (worker is served from same domain)
if (typeof window !== 'undefined' && window.location.hostname !== 'localhost') {
return '' // Empty string = same origin
}
// In development, use the worker dev server
// Default to port 5172 as configured in wrangler.toml
return 'http://localhost:5172'
}
/**
* Get RunPod proxy configuration
* All RunPod calls now go through the Cloudflare Worker proxy
* API keys are stored server-side, never exposed to the browser
*/
export function getRunPodProxyConfig(type: 'image' | 'video' | 'text' | 'whisper' = 'image'): {
proxyUrl: string
endpointType: string
} {
const workerUrl = getWorkerApiUrl()
return {
proxyUrl: `${workerUrl}/api/runpod/${type}`,
endpointType: type
}
}
/**
* Get RunPod configuration for API calls (defaults to image endpoint)
* Falls back to pre-configured endpoints if not set via environment
* @deprecated Use getRunPodProxyConfig() instead - API keys are now server-side
*/
export function getRunPodConfig(): { apiKey: string; endpointId: string } | null {
const config = getClientConfig()
const apiKey = config.runpodApiKey || DEFAULT_RUNPOD_API_KEY
const endpointId = config.runpodEndpointId || config.runpodImageEndpointId || DEFAULT_RUNPOD_IMAGE_ENDPOINT_ID
return {
apiKey: apiKey,
endpointId: endpointId
}
export function getRunPodConfig(): { proxyUrl: string } {
return { proxyUrl: `${getWorkerApiUrl()}/api/runpod/image` }
}
/**
* Get RunPod configuration for image generation
* Falls back to pre-configured Automatic1111 endpoint
* @deprecated Use getRunPodProxyConfig('image') instead
*/
export function getRunPodImageConfig(): { apiKey: string; endpointId: string } | null {
const config = getClientConfig()
const apiKey = config.runpodApiKey || DEFAULT_RUNPOD_API_KEY
const endpointId = config.runpodImageEndpointId || config.runpodEndpointId || DEFAULT_RUNPOD_IMAGE_ENDPOINT_ID
return {
apiKey: apiKey,
endpointId: endpointId
}
export function getRunPodImageConfig(): { proxyUrl: string } {
return getRunPodProxyConfig('image')
}
/**
* Get RunPod configuration for video generation
* Falls back to pre-configured Wan2.2 endpoint
* @deprecated Use getRunPodProxyConfig('video') instead
*/
export function getRunPodVideoConfig(): { apiKey: string; endpointId: string } | null {
const config = getClientConfig()
const apiKey = config.runpodApiKey || DEFAULT_RUNPOD_API_KEY
const endpointId = config.runpodVideoEndpointId || DEFAULT_RUNPOD_VIDEO_ENDPOINT_ID
return {
apiKey: apiKey,
endpointId: endpointId
}
export function getRunPodVideoConfig(): { proxyUrl: string } {
return getRunPodProxyConfig('video')
}
/**
* Get RunPod configuration for text generation (vLLM)
* Falls back to pre-configured vLLM endpoint
* @deprecated Use getRunPodProxyConfig('text') instead
*/
export function getRunPodTextConfig(): { apiKey: string; endpointId: string } | null {
const config = getClientConfig()
const apiKey = config.runpodApiKey || DEFAULT_RUNPOD_API_KEY
const endpointId = config.runpodTextEndpointId || DEFAULT_RUNPOD_TEXT_ENDPOINT_ID
return {
apiKey: apiKey,
endpointId: endpointId
}
export function getRunPodTextConfig(): { proxyUrl: string } {
return getRunPodProxyConfig('text')
}
/**
* Get RunPod configuration for Whisper transcription
* Falls back to pre-configured Whisper endpoint
* @deprecated Use getRunPodProxyConfig('whisper') instead
*/
export function getRunPodWhisperConfig(): { apiKey: string; endpointId: string } | null {
const config = getClientConfig()
export function getRunPodWhisperConfig(): { proxyUrl: string } {
return getRunPodProxyConfig('whisper')
}
const apiKey = config.runpodApiKey || DEFAULT_RUNPOD_API_KEY
const endpointId = config.runpodWhisperEndpointId || DEFAULT_RUNPOD_WHISPER_ENDPOINT_ID
return {
apiKey: apiKey,
endpointId: endpointId
}
/**
* Get fal.ai proxy configuration
* All fal.ai calls now go through the Cloudflare Worker proxy
* API keys are stored server-side, never exposed to the browser
*/
export function getFalProxyConfig(): { proxyUrl: string } {
const workerUrl = getWorkerApiUrl()
return { proxyUrl: `${workerUrl}/api/fal` }
}
/**
* Get fal.ai configuration for image and video generation
* Falls back to pre-configured API key if not set
* @deprecated API keys are now server-side. Use getFalProxyConfig() for proxy URL.
*/
export function getFalConfig(): { apiKey: string } | null {
const config = getClientConfig()
const apiKey = config.falApiKey || DEFAULT_FAL_API_KEY
return {
apiKey: apiKey
}
export function getFalConfig(): { proxyUrl: string } {
return getFalProxyConfig()
}
/**
* Check if fal.ai integration is configured
* Now always returns true since the proxy handles configuration
*/
export function isFalConfigured(): boolean {
const config = getClientConfig()
return !!(config.falApiKey || DEFAULT_FAL_API_KEY)
return true // Proxy is always available, server-side config determines availability
}
/**
@ -231,10 +227,10 @@ export function getOllamaConfig(): { url: string } | null {
/**
* Check if RunPod integration is configured
* Now always returns true since the proxy handles configuration
*/
export function isRunPodConfigured(): boolean {
const config = getClientConfig()
return !!(config.runpodApiKey && config.runpodEndpointId)
return true // Proxy is always available, server-side config determines availability
}
/**

View File

@ -1,9 +1,12 @@
/**
* RunPod API utility functions
* Handles communication with RunPod WhisperX endpoints
*
* SECURITY: All RunPod calls go through the Cloudflare Worker proxy
* API keys are stored server-side, never exposed to the browser
*/
import { getRunPodConfig } from './clientConfig'
import { getRunPodProxyConfig } from './clientConfig'
export interface RunPodTranscriptionResponse {
id?: string
@ -40,18 +43,14 @@ export async function blobToBase64(blob: Blob): Promise<string> {
}
/**
* Send transcription request to RunPod endpoint
* Send transcription request to RunPod endpoint via proxy
* Handles both synchronous and asynchronous job patterns
*/
export async function transcribeWithRunPod(
audioBlob: Blob,
language?: string
): Promise<string> {
const config = getRunPodConfig()
if (!config) {
throw new Error('RunPod API key or endpoint ID not configured. Please set VITE_RUNPOD_API_KEY and VITE_RUNPOD_ENDPOINT_ID environment variables.')
}
const { proxyUrl } = getRunPodProxyConfig('whisper')
// Check audio blob size (limit to ~10MB to prevent issues)
const maxSize = 10 * 1024 * 1024 // 10MB
@ -61,12 +60,13 @@ export async function transcribeWithRunPod(
// Convert audio blob to base64
const audioBase64 = await blobToBase64(audioBlob)
// Detect audio format from blob type
const audioFormat = audioBlob.type || 'audio/wav'
const url = `https://api.runpod.ai/v2/${config.endpointId}/run`
// Use proxy endpoint - API key and endpoint ID are handled server-side
const url = `${proxyUrl}/run`
// Prepare the request payload
// WhisperX typically expects audio as base64 or file URL
// The exact format may vary based on your WhisperX endpoint implementation
@ -89,8 +89,8 @@ export async function transcribeWithRunPod(
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`
'Content-Type': 'application/json'
// Authorization is handled by the proxy server-side
},
body: JSON.stringify(requestBody),
signal: controller.signal
@ -99,43 +99,43 @@ export async function transcribeWithRunPod(
clearTimeout(timeoutId)
if (!response.ok) {
const errorText = await response.text()
const errorData = await response.json().catch(() => ({ error: response.statusText })) as { error?: string; details?: string }
console.error('RunPod API error response:', {
status: response.status,
statusText: response.statusText,
body: errorText
error: errorData
})
throw new Error(`RunPod API error: ${response.status} - ${errorText}`)
throw new Error(`RunPod API error: ${response.status} - ${errorData.error || errorData.details || 'Unknown error'}`)
}
const data: RunPodTranscriptionResponse = await response.json()
// Handle async job pattern (RunPod often returns job IDs)
if (data.id && (data.status === 'IN_QUEUE' || data.status === 'IN_PROGRESS')) {
return await pollRunPodJob(data.id, config.apiKey, config.endpointId)
return await pollRunPodJob(data.id, proxyUrl)
}
// Handle direct response
if (data.output?.text) {
return data.output.text.trim()
}
// Handle error response
if (data.error) {
throw new Error(`RunPod transcription error: ${data.error}`)
}
// Fallback: try to extract text from segments
if (data.output?.segments && data.output.segments.length > 0) {
return data.output.segments.map(seg => seg.text).join(' ').trim()
}
// Check if response has unexpected structure
console.warn('Unexpected RunPod response structure:', data)
throw new Error('No transcription text found in RunPod response. Check endpoint response format.')
} catch (error: any) {
if (error.name === 'AbortError') {
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('RunPod request timed out after 30 seconds')
}
console.error('RunPod transcription error:', error)
@ -144,18 +144,18 @@ export async function transcribeWithRunPod(
}
/**
* Poll RunPod job status until completion
* Poll RunPod job status until completion via proxy
*/
async function pollRunPodJob(
jobId: string,
apiKey: string,
endpointId: string,
proxyUrl: string,
maxAttempts: number = 120, // Increased to 120 attempts (2 minutes at 1s intervals)
pollInterval: number = 1000
): Promise<string> {
const statusUrl = `https://api.runpod.ai/v2/${endpointId}/status/${jobId}`
// Use proxy endpoint for status checks
const statusUrl = `${proxyUrl}/status/${jobId}`
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
// Add timeout for each status check (5 seconds)
@ -164,60 +164,58 @@ async function pollRunPodJob(
const response = await fetch(statusUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`
},
// Authorization is handled by the proxy server-side
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
const errorText = await response.text()
const errorData = await response.json().catch(() => ({ error: response.statusText })) as { error?: string; details?: string }
console.error(`Job status check failed (attempt ${attempt + 1}/${maxAttempts}):`, {
status: response.status,
statusText: response.statusText,
body: errorText
error: errorData
})
// Don't fail immediately on 404 - job might still be processing
if (response.status === 404 && attempt < maxAttempts - 1) {
await new Promise(resolve => setTimeout(resolve, pollInterval))
continue
}
throw new Error(`Failed to check job status: ${response.status} - ${errorText}`)
throw new Error(`Failed to check job status: ${response.status} - ${errorData.error || errorData.details || 'Unknown error'}`)
}
const data: RunPodTranscriptionResponse = await response.json()
if (data.status === 'COMPLETED') {
if (data.output?.text) {
return data.output.text.trim()
}
if (data.output?.segments && data.output.segments.length > 0) {
return data.output.segments.map(seg => seg.text).join(' ').trim()
}
// Log the full response for debugging
console.error('Job completed but no transcription found. Full response:', JSON.stringify(data, null, 2))
throw new Error('Job completed but no transcription text found in response')
}
if (data.status === 'FAILED') {
const errorMsg = data.error || 'Unknown error'
console.error('Job failed:', errorMsg)
throw new Error(`Job failed: ${errorMsg}`)
}
// Job still in progress, wait and retry
if (attempt % 10 === 0) {
}
await new Promise(resolve => setTimeout(resolve, pollInterval))
} catch (error: any) {
if (error.name === 'AbortError') {
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
console.warn(`Status check timed out (attempt ${attempt + 1}/${maxAttempts})`)
if (attempt < maxAttempts - 1) {
await new Promise(resolve => setTimeout(resolve, pollInterval))
@ -225,7 +223,7 @@ async function pollRunPodJob(
}
throw new Error('Status check timed out multiple times')
}
if (attempt === maxAttempts - 1) {
throw error
}
@ -233,7 +231,6 @@ async function pollRunPodJob(
await new Promise(resolve => setTimeout(resolve, pollInterval))
}
}
throw new Error(`Job polling timeout after ${maxAttempts} attempts (${(maxAttempts * pollInterval / 1000).toFixed(0)} seconds)`)
}

View File

@ -6,7 +6,7 @@ import {
TLBaseShape,
} from "tldraw"
import React, { useState } from "react"
import { getRunPodConfig } from "@/lib/clientConfig"
import { getRunPodProxyConfig } from "@/lib/clientConfig"
import { aiOrchestrator, isAIOrchestratorAvailable } from "@/lib/aiOrchestrator"
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
import { usePinnedToView } from "@/hooks/usePinnedToView"
@ -341,10 +341,8 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
})
try {
// Get RunPod configuration
const runpodConfig = getRunPodConfig()
const endpointId = shape.props.endpointId || runpodConfig?.endpointId || "tzf1j3sc3zufsy"
const apiKey = runpodConfig?.apiKey
// Get RunPod proxy configuration - API keys are now server-side
const { proxyUrl } = getRunPodProxyConfig('image')
// Mock API mode: Return placeholder image without calling RunPod
if (USE_MOCK_API) {
@ -382,20 +380,18 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
return
}
// Real API mode: Use RunPod
if (!apiKey) {
throw new Error("RunPod API key not configured. Please set VITE_RUNPOD_API_KEY environment variable.")
}
// Real API mode: Use RunPod via proxy
// API key and endpoint ID are handled server-side
// Use runsync for synchronous execution - returns output directly without polling
const url = `https://api.runpod.ai/v2/${endpointId}/runsync`
const url = `${proxyUrl}/runsync`
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`
"Content-Type": "application/json"
// Authorization is handled by the proxy server-side
},
body: JSON.stringify({
input: {

View File

@ -6,7 +6,7 @@ import {
TLBaseShape,
} from "tldraw"
import React, { useState, useRef, useEffect } from "react"
import { getFalConfig } from "@/lib/clientConfig"
import { getFalProxyConfig } from "@/lib/clientConfig"
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
import { usePinnedToView } from "@/hooks/usePinnedToView"
import { useMaximize } from "@/hooks/useMaximize"
@ -166,16 +166,10 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
}
}
// Check fal.ai config
const falConfig = getFalConfig()
if (!falConfig) {
setError("fal.ai not configured. Please set VITE_FAL_API_KEY in your .env file.")
return
}
// Get fal.ai proxy config
const { proxyUrl } = getFalProxyConfig()
const currentMode = (imageUrl.trim() || imageBase64) ? 'i2v' : 't2v'
if (currentMode === 'i2v') {
}
// Clear any existing video and set loading state
setIsGenerating(true)
@ -198,14 +192,10 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
}
try {
const { apiKey } = falConfig
// Choose fal.ai endpoint based on mode
// WAN 2.1 models: fast startup, good quality
const endpoint = currentMode === 'i2v' ? 'fal-ai/wan-i2v' : 'fal-ai/wan-t2v'
const submitUrl = `https://queue.fal.run/${endpoint}`
// Build input payload for fal.ai
const inputPayload: Record<string, any> = {
prompt: prompt,
@ -226,19 +216,16 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
}
}
// Submit to fal.ai queue
const response = await fetch(submitUrl, {
// Submit to fal.ai queue via proxy
const response = await fetch(`${proxyUrl}/queue/${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Key ${apiKey}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(inputPayload)
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`fal.ai API error: ${response.status} - ${errorText}`)
const errorData = await response.json().catch(() => ({ error: response.statusText })) as { error?: string; details?: string }
throw new Error(`fal.ai API error: ${response.status} - ${errorData.error || errorData.details || 'Unknown error'}`)
}
const jobData = await response.json() as FalQueueResponse
@ -247,10 +234,9 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
throw new Error('No request_id returned from fal.ai')
}
// Poll for completion
// Poll for completion via proxy
// fal.ai is generally faster than RunPod due to warm instances
// Typical times: 30-90 seconds for video generation
const statusUrl = `https://queue.fal.run/${endpoint}/requests/${jobData.request_id}/status`
let attempts = 0
const maxAttempts = 120 // 4 minutes with 2s intervals
@ -258,9 +244,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
await new Promise(resolve => setTimeout(resolve, 2000))
attempts++
const statusResponse = await fetch(statusUrl, {
headers: { 'Authorization': `Key ${apiKey}` }
})
const statusResponse = await fetch(`${proxyUrl}/queue/${endpoint}/status/${jobData.request_id}`)
if (!statusResponse.ok) {
console.warn(`🎬 VideoGen: Poll error (attempt ${attempts}):`, statusResponse.status)
@ -270,11 +254,8 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
const statusData = await statusResponse.json() as FalQueueResponse
if (statusData.status === 'COMPLETED') {
// Fetch the result
const resultUrl = `https://queue.fal.run/${endpoint}/requests/${jobData.request_id}`
const resultResponse = await fetch(resultUrl, {
headers: { 'Authorization': `Key ${apiKey}` }
})
// Fetch the result via proxy
const resultResponse = await fetch(`${proxyUrl}/queue/${endpoint}/result/${jobData.request_id}`)
if (!resultResponse.ok) {
throw new Error(`Failed to fetch result: ${resultResponse.status}`)

View File

@ -1,7 +1,7 @@
import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk";
import { makeRealSettings, AI_PERSONALITIES } from "@/lib/settings";
import { getRunPodConfig, getRunPodTextConfig, getOllamaConfig } from "@/lib/clientConfig";
import { getRunPodProxyConfig, getOllamaConfig } from "@/lib/clientConfig";
export async function llm(
userPrompt: string,
@ -170,28 +170,15 @@ function getAvailableProviders(availableKeys: Record<string, string>, settings:
});
}
// PRIORITY 1: Check for RunPod TEXT configuration from environment variables
// PRIORITY 1: Add RunPod via proxy - API keys are stored server-side
// RunPod vLLM text endpoint is used as fallback when Ollama is not available
const runpodTextConfig = getRunPodTextConfig();
if (runpodTextConfig && runpodTextConfig.apiKey && runpodTextConfig.endpointId) {
providers.push({
provider: 'runpod',
apiKey: runpodTextConfig.apiKey,
endpointId: runpodTextConfig.endpointId,
model: 'default' // RunPod vLLM endpoint
});
} else {
// Fallback to generic RunPod config if text endpoint not configured
const runpodConfig = getRunPodConfig();
if (runpodConfig && runpodConfig.apiKey && runpodConfig.endpointId) {
providers.push({
provider: 'runpod',
apiKey: runpodConfig.apiKey,
endpointId: runpodConfig.endpointId,
model: 'default'
});
}
}
const runpodProxyConfig = getRunPodProxyConfig('text');
// Always add RunPod as a provider - the proxy handles auth server-side
providers.push({
provider: 'runpod',
proxyUrl: runpodProxyConfig.proxyUrl,
model: 'default' // RunPod vLLM endpoint
});
// PRIORITY 2: Then add user-configured keys (they will be tried after RunPod)
// First, try the preferred provider - support multiple keys if stored as comma-separated
@ -503,7 +490,7 @@ async function callProviderAPI(
userPrompt: string,
onToken: (partialResponse: string, done?: boolean) => void,
settings?: any,
endpointId?: string,
_endpointId?: string, // Deprecated - RunPod now uses proxy with server-side endpoint config
customSystemPrompt?: string | null
) {
let partial = "";
@ -571,37 +558,26 @@ async function callProviderAPI(
throw error;
}
} else if (provider === 'runpod') {
// RunPod API integration - uses environment variables for automatic setup
// Get endpointId from parameter or from config
let runpodEndpointId = endpointId;
if (!runpodEndpointId) {
const runpodConfig = getRunPodConfig();
if (runpodConfig) {
runpodEndpointId = runpodConfig.endpointId;
}
}
if (!runpodEndpointId) {
throw new Error('RunPod endpoint ID not configured');
}
// RunPod API integration via proxy - API keys are stored server-side
const { proxyUrl } = getRunPodProxyConfig('text');
// Try /runsync first for synchronous execution (returns output immediately)
// Fall back to /run + polling if /runsync is not available
const syncUrl = `https://api.runpod.ai/v2/${runpodEndpointId}/runsync`;
const asyncUrl = `https://api.runpod.ai/v2/${runpodEndpointId}/run`;
const syncUrl = `${proxyUrl}/runsync`;
const asyncUrl = `${proxyUrl}/run`;
// vLLM endpoints typically expect OpenAI-compatible format with messages array
// But some endpoints might accept simple prompt format
// Try OpenAI-compatible format first, as it's more standard for vLLM
const messages = [];
const messages: Array<{ role: string; content: string }> = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: userPrompt });
// Combine system prompt and user prompt for simple prompt format (fallback)
const fullPrompt = systemPrompt ? `${systemPrompt}\n\nUser: ${userPrompt}` : userPrompt;
const requestBody = {
input: {
messages: messages,
@ -615,8 +591,8 @@ async function callProviderAPI(
const syncResponse = await fetch(syncUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
'Content-Type': 'application/json'
// Authorization is handled by the proxy server-side
},
body: JSON.stringify(requestBody)
});
@ -654,7 +630,7 @@ async function callProviderAPI(
// If sync endpoint returned a job ID, fall through to async polling
if (syncData.id && (syncData.status === 'IN_QUEUE' || syncData.status === 'IN_PROGRESS')) {
const result = await pollRunPodJob(syncData.id, apiKey, runpodEndpointId);
const result = await pollRunPodJob(syncData.id, proxyUrl);
partial = result;
onToken(partial, true);
return;
@ -668,22 +644,22 @@ async function callProviderAPI(
const response = await fetch(asyncUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
'Content-Type': 'application/json'
// Authorization is handled by the proxy server-side
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`RunPod API error: ${response.status} - ${errorText}`);
const errorData = await response.json().catch(() => ({ error: response.statusText })) as { error?: string; details?: string };
throw new Error(`RunPod API error: ${response.status} - ${errorData.error || errorData.details || 'Unknown error'}`);
}
const data = await response.json() as Record<string, any>;
// Handle async job pattern (RunPod often returns job IDs)
if (data.id && (data.status === 'IN_QUEUE' || data.status === 'IN_PROGRESS')) {
const result = await pollRunPodJob(data.id, apiKey, runpodEndpointId);
const result = await pollRunPodJob(data.id, proxyUrl);
partial = result;
onToken(partial, true);
return;
@ -835,28 +811,26 @@ async function callProviderAPI(
onToken(partial, true);
}
// Helper function to poll RunPod job status until completion
// Helper function to poll RunPod job status until completion via proxy
async function pollRunPodJob(
jobId: string,
apiKey: string,
endpointId: string,
proxyUrl: string,
maxAttempts: number = 60,
pollInterval: number = 1000
): Promise<string> {
const statusUrl = `https://api.runpod.ai/v2/${endpointId}/status/${jobId}`;
// Use proxy endpoint for status checks
const statusUrl = `${proxyUrl}/status/${jobId}`;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await fetch(statusUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`
}
method: 'GET'
// Authorization is handled by the proxy server-side
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to check job status: ${response.status} - ${errorText}`);
const errorData = await response.json().catch(() => ({ error: response.statusText })) as { error?: string; details?: string };
throw new Error(`Failed to check job status: ${response.status} - ${errorData.error || errorData.details || 'Unknown error'}`);
}
const data = await response.json() as Record<string, any>;
@ -872,12 +846,10 @@ async function pollRunPodJob(
// After a few retries, try the stream endpoint as fallback
try {
const streamUrl = `https://api.runpod.ai/v2/${endpointId}/stream/${jobId}`;
const streamUrl = `${proxyUrl}/stream/${jobId}`;
const streamResponse = await fetch(streamUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`
}
method: 'GET'
// Authorization is handled by the proxy server-side
});
if (streamResponse.ok) {

View File

@ -64,14 +64,27 @@ export class AutomergeSyncManager {
// Try to load existing document from R2
let doc = await this.storage.loadDocument(this.roomId)
const automergeShapeCount = doc?.store
? Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
: 0
if (!doc) {
// Check if there's a legacy JSON document to migrate
const legacyDoc = await this.loadLegacyJsonDocument()
if (legacyDoc) {
console.log(`🔄 Found legacy JSON document, migrating to Automerge format`)
doc = await this.storage.migrateFromJson(this.roomId, legacyDoc)
}
// Always check legacy JSON and compare - this prevents data loss if automerge.bin
// was created with fewer shapes than the legacy JSON
const legacyDoc = await this.loadLegacyJsonDocument()
const legacyShapeCount = legacyDoc?.store
? Object.values(legacyDoc.store).filter((r: any) => r?.typeName === 'shape').length
: 0
console.log(`📊 Document comparison: automerge.bin has ${automergeShapeCount} shapes, legacy JSON has ${legacyShapeCount} shapes`)
// Use legacy JSON if it has more shapes than the automerge binary
// This handles the case where an empty automerge.bin was created before migration
if (legacyDoc && legacyShapeCount > automergeShapeCount) {
console.log(`🔄 Legacy JSON has more shapes (${legacyShapeCount} vs ${automergeShapeCount}), migrating to Automerge format`)
doc = await this.storage.migrateFromJson(this.roomId, legacyDoc)
} else if (!doc && legacyDoc) {
console.log(`🔄 No automerge.bin found, migrating legacy JSON document`)
doc = await this.storage.migrateFromJson(this.roomId, legacyDoc)
}
if (!doc) {

View File

@ -15,6 +15,14 @@ export interface Environment {
APP_URL?: string;
// Admin secret for protected endpoints
ADMIN_SECRET?: string;
// AI Service API keys (stored as secrets, never exposed to client)
FAL_API_KEY?: string;
RUNPOD_API_KEY?: string;
// RunPod endpoint IDs (not secrets, but kept server-side for flexibility)
RUNPOD_IMAGE_ENDPOINT_ID?: string;
RUNPOD_VIDEO_ENDPOINT_ID?: string;
RUNPOD_TEXT_ENDPOINT_ID?: string;
RUNPOD_WHISPER_ENDPOINT_ID?: string;
}
// CryptID types for auth

View File

@ -1029,6 +1029,366 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
.get("/boards/:boardId/editors", (req, env) =>
handleListEditors(req.params.boardId, req, env))
// =============================================================================
// AI Service Proxies (fal.ai, RunPod)
// These keep API keys server-side instead of exposing them to the browser
// =============================================================================
// Fal.ai proxy - submit job to queue
.post("/api/fal/queue/:endpoint(*)", async (req, env) => {
if (!env.FAL_API_KEY) {
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const endpoint = req.params.endpoint
const body = await req.json()
const response = await fetch(`https://queue.fal.run/${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Key ${env.FAL_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
if (!response.ok) {
const errorText = await response.text()
return new Response(JSON.stringify({
error: `fal.ai API error: ${response.status}`,
details: errorText
}), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('Fal.ai proxy error:', error)
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
// Fal.ai proxy - check job status
.get("/api/fal/queue/:endpoint(*)/status/:requestId", async (req, env) => {
if (!env.FAL_API_KEY) {
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const { endpoint, requestId } = req.params
const response = await fetch(`https://queue.fal.run/${endpoint}/requests/${requestId}/status`, {
headers: { 'Authorization': `Key ${env.FAL_API_KEY}` }
})
if (!response.ok) {
const errorText = await response.text()
return new Response(JSON.stringify({
error: `fal.ai status error: ${response.status}`,
details: errorText
}), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('Fal.ai status proxy error:', error)
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
// Fal.ai proxy - get job result
.get("/api/fal/queue/:endpoint(*)/result/:requestId", async (req, env) => {
if (!env.FAL_API_KEY) {
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const { endpoint, requestId } = req.params
const response = await fetch(`https://queue.fal.run/${endpoint}/requests/${requestId}`, {
headers: { 'Authorization': `Key ${env.FAL_API_KEY}` }
})
if (!response.ok) {
const errorText = await response.text()
return new Response(JSON.stringify({
error: `fal.ai result error: ${response.status}`,
details: errorText
}), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('Fal.ai result proxy error:', error)
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
// Fal.ai subscribe (synchronous generation) - used by LiveImage
.post("/api/fal/subscribe/:endpoint(*)", async (req, env) => {
if (!env.FAL_API_KEY) {
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const endpoint = req.params.endpoint
const body = await req.json()
// Use the direct endpoint for synchronous generation
const response = await fetch(`https://fal.run/${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Key ${env.FAL_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
if (!response.ok) {
const errorText = await response.text()
return new Response(JSON.stringify({
error: `fal.ai API error: ${response.status}`,
details: errorText
}), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('Fal.ai subscribe proxy error:', error)
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
// RunPod proxy - run sync (blocking)
.post("/api/runpod/:endpointType/runsync", async (req, env) => {
const endpointType = req.params.endpointType as 'image' | 'video' | 'text' | 'whisper'
// Get the appropriate endpoint ID
const endpointIds: Record<string, string | undefined> = {
'image': env.RUNPOD_IMAGE_ENDPOINT_ID || 'tzf1j3sc3zufsy',
'video': env.RUNPOD_VIDEO_ENDPOINT_ID || '4jql4l7l0yw0f3',
'text': env.RUNPOD_TEXT_ENDPOINT_ID || '03g5hz3hlo8gr2',
'whisper': env.RUNPOD_WHISPER_ENDPOINT_ID || 'lrtisuv8ixbtub'
}
const endpointId = endpointIds[endpointType]
if (!endpointId) {
return new Response(JSON.stringify({ error: `Unknown endpoint type: ${endpointType}` }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
if (!env.RUNPOD_API_KEY) {
return new Response(JSON.stringify({ error: 'RUNPOD_API_KEY not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const body = await req.json()
const response = await fetch(`https://api.runpod.ai/v2/${endpointId}/runsync`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.RUNPOD_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
if (!response.ok) {
const errorText = await response.text()
return new Response(JSON.stringify({
error: `RunPod API error: ${response.status}`,
details: errorText
}), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('RunPod runsync proxy error:', error)
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
// RunPod proxy - run async (non-blocking)
.post("/api/runpod/:endpointType/run", async (req, env) => {
const endpointType = req.params.endpointType as 'image' | 'video' | 'text' | 'whisper'
const endpointIds: Record<string, string | undefined> = {
'image': env.RUNPOD_IMAGE_ENDPOINT_ID || 'tzf1j3sc3zufsy',
'video': env.RUNPOD_VIDEO_ENDPOINT_ID || '4jql4l7l0yw0f3',
'text': env.RUNPOD_TEXT_ENDPOINT_ID || '03g5hz3hlo8gr2',
'whisper': env.RUNPOD_WHISPER_ENDPOINT_ID || 'lrtisuv8ixbtub'
}
const endpointId = endpointIds[endpointType]
if (!endpointId) {
return new Response(JSON.stringify({ error: `Unknown endpoint type: ${endpointType}` }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
if (!env.RUNPOD_API_KEY) {
return new Response(JSON.stringify({ error: 'RUNPOD_API_KEY not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const body = await req.json()
const response = await fetch(`https://api.runpod.ai/v2/${endpointId}/run`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.RUNPOD_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
if (!response.ok) {
const errorText = await response.text()
return new Response(JSON.stringify({
error: `RunPod API error: ${response.status}`,
details: errorText
}), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('RunPod run proxy error:', error)
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
// RunPod proxy - check job status
.get("/api/runpod/:endpointType/status/:jobId", async (req, env) => {
const endpointType = req.params.endpointType as 'image' | 'video' | 'text' | 'whisper'
const endpointIds: Record<string, string | undefined> = {
'image': env.RUNPOD_IMAGE_ENDPOINT_ID || 'tzf1j3sc3zufsy',
'video': env.RUNPOD_VIDEO_ENDPOINT_ID || '4jql4l7l0yw0f3',
'text': env.RUNPOD_TEXT_ENDPOINT_ID || '03g5hz3hlo8gr2',
'whisper': env.RUNPOD_WHISPER_ENDPOINT_ID || 'lrtisuv8ixbtub'
}
const endpointId = endpointIds[endpointType]
if (!endpointId) {
return new Response(JSON.stringify({ error: `Unknown endpoint type: ${endpointType}` }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
if (!env.RUNPOD_API_KEY) {
return new Response(JSON.stringify({ error: 'RUNPOD_API_KEY not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const { jobId } = req.params
const response = await fetch(`https://api.runpod.ai/v2/${endpointId}/status/${jobId}`, {
headers: { 'Authorization': `Bearer ${env.RUNPOD_API_KEY}` }
})
if (!response.ok) {
const errorText = await response.text()
return new Response(JSON.stringify({
error: `RunPod status error: ${response.status}`,
details: errorText
}), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('RunPod status proxy error:', error)
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
/**
* Compute SHA-256 hash of content for change detection
*/

View File

@ -108,4 +108,11 @@ crons = ["0 0 * * *"] # Run at midnight UTC every day
# DO NOT put these directly in wrangler.toml:
# - DAILY_API_KEY
# - CLOUDFLARE_API_TOKEN
# etc.
# - FAL_API_KEY # For fal.ai image/video generation proxy
# - RUNPOD_API_KEY # For RunPod AI endpoints proxy
# - RESEND_API_KEY # For email sending
# - ADMIN_SECRET # For admin-only endpoints
#
# To set secrets:
# wrangler secret put FAL_API_KEY
# wrangler secret put RUNPOD_API_KEY