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>
This commit is contained in:
Jeff Emmett 2025-12-25 20:26:04 -05:00
parent 1df612660d
commit 19b5356f3d
10 changed files with 618 additions and 297 deletions

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