// spacecall — call any Gradio Space through the beacon // Handles the full Gradio pattern: upload → call → poll → result // Requires HF_TOKEN env var for authenticated access (ZeroGPU quota, private spaces) const HF_TOKEN = process.env.HF_TOKEN const authHeader = HF_TOKEN ? { Authorization: `Bearer ${HF_TOKEN}` } : {} function spaceUrl(owner, name) { const slug = s => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') return `https://${slug(owner)}-${slug(name)}.hf.space` } // Upload a file (URL or local path) to a Space's /gradio_api/upload // Returns the Gradio FileData object ready to use as input async function uploadToSpace(base, fileUrl, filename) { // Fetch the source file const fileRes = await fetch(fileUrl) if (!fileRes.ok) throw new Error(`Could not fetch file: ${fileUrl}`) const blob = await fileRes.blob() const form = new FormData() form.append('files', blob, filename || 'upload') const r = await fetch(`${base}/gradio_api/upload`, { method: 'POST', headers: { ...authHeader }, body: form, }) if (!r.ok) throw new Error(`Upload failed: ${r.status} ${await r.text()}`) const paths = await r.json() // [ "/tmp/gradio/.../filename" ] const path = paths[0] return { path, meta: { _type: 'gradio.FileData' }, orig_name: filename || path.split('/').pop(), } } // Resolve inputs — any string that looks like a URL gets uploaded first async function resolveInputs(base, inputs) { const resolved = {} for (const [key, val] of Object.entries(inputs)) { if (typeof val === 'string' && (val.startsWith('http://') || val.startsWith('https://'))) { const filename = val.split('/').pop().split('?')[0] || key resolved[key] = await uploadToSpace(base, val, filename) } else { resolved[key] = val } } return resolved } // Poll a Gradio SSE stream until complete, return parsed result async function pollResult(url, timeoutMs = 120000) { const start = Date.now() const r = await fetch(url, { headers: { ...authHeader } }) if (!r.ok) throw new Error(`Poll failed: ${r.status}`) const reader = r.body.getReader() const decoder = new TextDecoder() let buffer = '' while (Date.now() - start < timeoutMs) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() for (const line of lines) { if (line.startsWith('event: complete')) { // next line will be data continue } if (line.startsWith('data:') && buffer.includes('event: complete') || lines.some(l => l.includes('event: complete'))) { try { const data = JSON.parse(line.replace('data: ', '')) return data } catch {} } if (line.startsWith('data:')) { try { const data = JSON.parse(line.replace('data: ', '')) // If this is a final result (array, not a status object) if (Array.isArray(data)) return data } catch {} } if (line.includes('event: error')) { throw new Error(`Space returned error during generation`) } } } throw new Error('Timed out waiting for Space result') } // Fetch the Gradio schema for a Space async function fetchSpaceSchema(base) { const url = `${base}/gradio_api/info` let r try { r = await fetch(url, { signal: AbortSignal.timeout(10000) }) } catch (err) { throw new Error(`Schema fetch failed (${url}): ${err.message}`) } if (!r.ok) throw new Error(`Schema fetch ${r.status} (${url})`) return r.json() } // Use the LLM to map a natural language prompt → { endpoint, inputs } // given the Space's Gradio schema async function resolveWithLLM(schema, userPrompt, { chat }) { const endpoints = Object.entries(schema?.named_endpoints ?? {}) .map(([name, def]) => { const params = (def.parameters ?? []) .map(p => ` ${p.label} (${p.python_type?.type ?? p.type ?? 'any'}): ${p.component ?? ''}`) .join('\n') return `Endpoint "${name}":\n${params}` }) .join('\n\n') const messages = [ { role: 'system', content: `You are an API parameter resolver. Given a Gradio Space's available endpoints and a user's natural language request, return ONLY a JSON object with two fields: - "endpoint": the best matching endpoint name (string) - "inputs": an object mapping each required parameter name to its value Rules: - For image/file parameters: use the URL directly as the string value (the caller will handle upload) - For missing optional parameters: omit them - Return ONLY valid JSON, no explanation, no markdown` }, { role: 'user', content: `Available endpoints:\n${endpoints}\n\nUser request: ${userPrompt}\n\nReturn JSON only:` } ] const reply = await chat(messages) // Strip markdown code fences if LLM added them const clean = reply.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim() return JSON.parse(clean) } // Main entrypoint — call any Gradio Space endpoint // space: "owner/name" or { owner, name } // endpoint: named endpoint string e.g. "predict" or "/predict" // inputs: object of named parameters // returns: { outputs, output_urls, raw } export async function callSpace({ space, endpoint = 'predict', inputs = {}, timeout = 120000 }) { const [owner, name] = typeof space === 'string' ? space.split('/') : [space.owner, space.name] if (!owner || !name) throw new Error('space must be "owner/name"') const base = spaceUrl(owner, name) const ep = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint // Resolve file inputs const resolvedInputs = await resolveInputs(base, inputs) // Submit const callRes = await fetch(`${base}/gradio_api/call/v2/${ep}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeader }, body: JSON.stringify(resolvedInputs), }) if (!callRes.ok) { const err = await callRes.text() throw new Error(`Space call failed ${callRes.status}: ${err}`) } const { event_id } = await callRes.json() if (!event_id) throw new Error('No event_id returned from Space') // Poll const pollUrl = `${base}/gradio_api/call/${ep}/${event_id}` const raw = await pollResult(pollUrl, timeout) // Extract output URLs (Gradio returns FileData objects or plain values) const output_urls = [] const walk = (val) => { if (!val) return if (typeof val === 'string' && val.startsWith('/tmp/')) { output_urls.push(`${base}/gradio_api/file=${val}`) } else if (val?.path) { output_urls.push(`${base}/gradio_api/file=${val.path}`) } else if (Array.isArray(val)) { val.forEach(walk) } else if (typeof val === 'object') { Object.values(val).forEach(walk) } } walk(raw) return { outputs: raw, output_urls, space: `${owner}/${name}`, endpoint: ep, event_id } } // Build a Gradio-compatible schema object from a KNOWN_MANIFESTS entry // so we never need a live /gradio_api/info fetch for known spaces function manifestToSchema(manifest) { const params = (manifest.inputs ?? []).map(i => ({ label: i.name, python_type: { type: i.type ?? 'string' }, component: i.type ?? 'string', })) return { named_endpoints: { predict: { parameters: params } } } } async function getSchema(space) { const { getSpaceManifest } = await import('./curlycue.js') const manifest = getSpaceManifest(space) if (manifest) return manifestToSchema(manifest) const [owner, name] = space.split('/') return fetchSpaceSchema(spaceUrl(owner, name)) } // Preview only — LLM resolves parameters but does NOT execute the Space call // Used by the UI to show curl preview before committing export async function previewSpaceCall({ space, prompt }) { const { chat } = await import('./llm.js') const schema = await getSchema(space) const resolved = await resolveWithLLM(schema, prompt, { chat }) return { space, resolved, curl: `curl -X POST https://acecalisto3-beacon.hf.space/space/ask \\\n -H "Content-Type: application/json" \\\n -d '${JSON.stringify({ space, prompt }, null, 2)}'`, structured: { space, endpoint: resolved.endpoint, inputs: resolved.inputs }, } } // Natural language entrypoint — user just describes what they want // The LLM reads the Space schema and fills in the parameters export async function callSpaceFromPrompt({ space, prompt, timeout = 120000 }) { const { chat } = await import('./llm.js') const schema = await getSchema(space) const resolved = await resolveWithLLM(schema, prompt, { chat }) if (!resolved?.endpoint || !resolved?.inputs) { throw new Error('LLM could not resolve Space parameters from prompt') } const result = await callSpace({ space, endpoint: resolved.endpoint, inputs: resolved.inputs, timeout }) return { ...result, resolved_from_prompt: prompt, llm_resolved: resolved } }