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:
parent
fc117299ab
commit
5bad65eed6
|
|
@ -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,10 +201,19 @@ 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) {
|
||||
|
|
@ -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]
|
||||
const firstImage = data.images[0]
|
||||
imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null
|
||||
} else if (data.image) {
|
||||
imageUrl = data.image.url || 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
|
||||
}
|
||||
imageUrl = typeof data.output === 'string' ? data.output : data.output?.url || null
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
|
|
|
|||
|
|
@ -99,118 +99,114 @@ export function getClientConfig(): ClientConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// Default fal.ai API key - shared for all users
|
||||
const DEFAULT_FAL_API_KEY = '(REDACTED-FAL-KEY)'
|
||||
// ============================================================================
|
||||
// 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 = '(REDACTED-RUNPOD-KEY)'
|
||||
/**
|
||||
* 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()
|
||||
|
||||
const apiKey = config.runpodApiKey || DEFAULT_RUNPOD_API_KEY
|
||||
const endpointId = config.runpodWhisperEndpointId || DEFAULT_RUNPOD_WHISPER_ENDPOINT_ID
|
||||
|
||||
return {
|
||||
apiKey: apiKey,
|
||||
endpointId: endpointId
|
||||
export function getRunPodWhisperConfig(): { proxyUrl: string } {
|
||||
return getRunPodProxyConfig('whisper')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -65,7 +64,8 @@ export async function transcribeWithRunPod(
|
|||
// 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
|
||||
|
|
@ -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,13 +99,13 @@ 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()
|
||||
|
|
@ -113,7 +113,7 @@ export async function transcribeWithRunPod(
|
|||
|
||||
// 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
|
||||
|
|
@ -134,8 +134,8 @@ export async function transcribeWithRunPod(
|
|||
// 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,16 +144,16 @@ 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++) {
|
||||
|
|
@ -164,20 +164,18 @@ 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
|
||||
|
|
@ -186,7 +184,7 @@ async function pollRunPodJob(
|
|||
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()
|
||||
|
|
@ -216,8 +214,8 @@ async function pollRunPodJob(
|
|||
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))
|
||||
|
|
@ -236,4 +234,3 @@ async function pollRunPodJob(
|
|||
|
||||
throw new Error(`Job polling timeout after ${maxAttempts} attempts (${(maxAttempts * pollInterval / 1000).toFixed(0)} seconds)`)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
const runpodProxyConfig = getRunPodProxyConfig('text');
|
||||
// Always add RunPod as a provider - the proxy handles auth server-side
|
||||
providers.push({
|
||||
provider: 'runpod',
|
||||
apiKey: runpodTextConfig.apiKey,
|
||||
endpointId: runpodTextConfig.endpointId,
|
||||
proxyUrl: runpodProxyConfig.proxyUrl,
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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,29 +558,18 @@ 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 });
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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()
|
||||
if (legacyDoc) {
|
||||
console.log(`🔄 Found legacy JSON document, migrating to Automerge format`)
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
360
worker/worker.ts
360
worker/worker.ts
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue