Beacon / src /spacecall.js
Ig0tU
fix: use local manifest schema for known spaces, skip live /gradio_api/info fetch
e2c3839
Raw
History Blame Contribute Delete
8.95 kB
// 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 }
}