From 19b5356f3d7048123cca2f2d3ab1ef0e364d0664 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 25 Dec 2025 20:26:04 -0500 Subject: [PATCH] feat: add server-side AI service proxies for fal.ai and RunPod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/hooks/useLiveImage.tsx | 99 ++++----- src/lib/clientConfig.ts | 148 +++++++------ src/lib/runpodApi.ts | 97 ++++----- src/shapes/ImageGenShapeUtil.tsx | 20 +- src/shapes/VideoGenShapeUtil.tsx | 43 ++-- src/utils/llmUtils.ts | 104 ++++----- worker/automerge-sync-manager.ts | 27 ++- worker/types.ts | 8 + worker/worker.ts | 360 +++++++++++++++++++++++++++++++ wrangler.toml | 9 +- 10 files changed, 618 insertions(+), 297 deletions(-) diff --git a/src/hooks/useLiveImage.tsx b/src/hooks/useLiveImage.tsx index 535885d..ee5a738 100644 --- a/src/hooks/useLiveImage.tsx +++ b/src/hooks/useLiveImage.tsx @@ -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(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( - 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 ( - + {children} ) @@ -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) { diff --git a/src/lib/clientConfig.ts b/src/lib/clientConfig.ts index c1b6eb5..5a900e3 100644 --- a/src/lib/clientConfig.ts +++ b/src/lib/clientConfig.ts @@ -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 } /** diff --git a/src/lib/runpodApi.ts b/src/lib/runpodApi.ts index 5a6526b..e4cc9aa 100644 --- a/src/lib/runpodApi.ts +++ b/src/lib/runpodApi.ts @@ -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 { } /** - * 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 { - 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 { - 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)`) } - diff --git a/src/shapes/ImageGenShapeUtil.tsx b/src/shapes/ImageGenShapeUtil.tsx index a3bee09..5b378eb 100644 --- a/src/shapes/ImageGenShapeUtil.tsx +++ b/src/shapes/ImageGenShapeUtil.tsx @@ -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 { }) 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 { 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: { diff --git a/src/shapes/VideoGenShapeUtil.tsx b/src/shapes/VideoGenShapeUtil.tsx index 9d931f5..11cef0c 100644 --- a/src/shapes/VideoGenShapeUtil.tsx +++ b/src/shapes/VideoGenShapeUtil.tsx @@ -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 { } } - // 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 { } 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 = { prompt: prompt, @@ -226,19 +216,16 @@ export class VideoGenShape extends BaseBoxShapeUtil { } } - // 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 { 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 { 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 { 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}`) diff --git a/src/utils/llmUtils.ts b/src/utils/llmUtils.ts index 66719fa..fc432af 100644 --- a/src/utils/llmUtils.ts +++ b/src/utils/llmUtils.ts @@ -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, 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; // 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 { - 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; @@ -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) { diff --git a/worker/automerge-sync-manager.ts b/worker/automerge-sync-manager.ts index cfb3080..27314d2 100644 --- a/worker/automerge-sync-manager.ts +++ b/worker/automerge-sync-manager.ts @@ -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) { diff --git a/worker/types.ts b/worker/types.ts index 3920b5d..eadd745 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -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 diff --git a/worker/worker.ts b/worker/worker.ts index e93a692..0a73e18 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -1029,6 +1029,366 @@ const router = AutoRouter({ .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 = { + '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 = { + '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 = { + '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 */ diff --git a/wrangler.toml b/wrangler.toml index 928282a..b35ba01 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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. \ No newline at end of file +# - 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 \ No newline at end of file