Add Gradio-compatible /gradio_api routes on Spaces#515
Conversation
Serve GET /gradio_api/info (and trailing slash), POST /gradio_api/call/{name},
GET /gradio_api/call/{name}/{event_id}, and POST /gradio_api/upload so HF Spaces
Agents tab and Gradio-style clients can discover and call the Trackio API.
Closes #514
Made-with: Cursor
🪼 branch checks and previews
|
🦄 change detectedThis Pull Request includes changes to the following packages.
|
🪼 branch checks and previews
Install Trackio from this PR (includes built frontend) pip install "https://huggingface.co/buckets/trackio/trackio-wheels/resolve/e85ebf7b939378ecedd4681957d78c9728af3b4f/trackio-0.23.0-py3-none-any.whl" |
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
There was a problem hiding this comment.
Pull request overview
Adds Gradio-compatible /gradio_api/* HTTP routes when Trackio is running on Hugging Face Spaces (SYSTEM=spaces), so the Spaces “Agents” tab can discover and invoke Trackio’s existing HTTP APIs via a Gradio-shaped protocol.
Changes:
- Implement
/gradio_api/info,/gradio_api/call/{api_name}(POST),/gradio_api/call/{api_name}/{event_id}(GET SSE), and/gradio_api/uploadhandlers on Spaces. - Generate a Gradio-style
named_endpointsschema from the existingapi_registry. - Add a changeset marking this as a minor feature release.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
trackio/asgi_app.py |
Adds Gradio-compat handlers/routes (conditional on Spaces) and a small in-memory event store for POST→poll SSE flow. |
.changeset/silly-coats-go.md |
Declares a minor-version changeset for the new feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async def run_api_request(request: Request, api_name: str) -> Response: | ||
| api_registry = request.app.state.api_registry | ||
| api_name = request.path_params["api_name"] | ||
| fn = api_registry.get(api_name) | ||
| if fn is None: | ||
| return JSONResponse({"error": f"Unknown API: {api_name}"}, status_code=404) |
There was a problem hiding this comment.
On Spaces, the PR description (and HF Agents tab) indicates calls should authenticate via Authorization: Bearer $HF_TOKEN, but this request handler path never reads the Authorization header. Endpoints that require an hf_token argument (e.g., write/mutation APIs) will fail unless callers explicitly pass hf_token in the JSON body. Consider extracting the bearer token from request.headers['authorization'] on Spaces and injecting it into the computed kwargs when the target function accepts an hf_token parameter and it wasn't provided in the request body.
| _store_gradio_call_result(request, event_id, body["data"]) | ||
| return JSONResponse({"event_id": event_id}) | ||
|
|
||
|
|
||
| async def gradio_call_poll_handler(request: Request) -> Response: | ||
| event_id = request.path_params["event_id"] | ||
| with request.app.state.gradio_call_events_lock: | ||
| data = request.app.state.gradio_call_events.pop(event_id, None) | ||
| if data is None: | ||
| return JSONResponse({"error": "Unknown or expired event_id"}, status_code=404) | ||
|
|
||
| payload = json.dumps(_json_safe([data])) |
There was a problem hiding this comment.
/gradio_api/call/{api_name}/{event_id} includes api_name in the route, but the poll handler ignores it and looks up results solely by event_id. This makes api_name effectively decorative and can return a result even if the caller polls with the wrong endpoint name. Consider storing (api_name, result) (or nesting the event map by api_name) and validating that the polled api_name matches the one used in the POST.
| _store_gradio_call_result(request, event_id, body["data"]) | |
| return JSONResponse({"event_id": event_id}) | |
| async def gradio_call_poll_handler(request: Request) -> Response: | |
| event_id = request.path_params["event_id"] | |
| with request.app.state.gradio_call_events_lock: | |
| data = request.app.state.gradio_call_events.pop(event_id, None) | |
| if data is None: | |
| return JSONResponse({"error": "Unknown or expired event_id"}, status_code=404) | |
| payload = json.dumps(_json_safe([data])) | |
| with request.app.state.gradio_call_events_lock: | |
| request.app.state.gradio_call_events[event_id] = { | |
| "api_name": api_name, | |
| "data": body["data"], | |
| } | |
| return JSONResponse({"event_id": event_id}) | |
| async def gradio_call_poll_handler(request: Request) -> Response: | |
| api_name = request.path_params["api_name"] | |
| event_id = request.path_params["event_id"] | |
| with request.app.state.gradio_call_events_lock: | |
| event = request.app.state.gradio_call_events.pop(event_id, None) | |
| if event is None: | |
| return JSONResponse({"error": "Unknown or expired event_id"}, status_code=404) | |
| if event.get("api_name") != api_name: | |
| return JSONResponse({"error": "Unknown or expired event_id"}, status_code=404) | |
| payload = json.dumps(_json_safe([event["data"]])) |
| async def sse() -> Any: | ||
| yield f"event: complete\ndata: {payload}\n\n" | ||
|
|
||
| return StreamingResponse(sse(), media_type="text/event-stream") |
There was a problem hiding this comment.
The SSE poll response doesn’t set any anti-caching headers. Since event_id results are one-time and user-specific, it should likely mirror the info endpoint’s Cache-Control: no-store (and optionally X-Accel-Buffering: no) to avoid intermediary/proxy caching or buffering of text/event-stream responses.
| return StreamingResponse(sse(), media_type="text/event-stream") | |
| return StreamingResponse( | |
| sse(), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-store", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| ) |
| if on_spaces(): | ||
| routes.extend( | ||
| [ | ||
| Route( | ||
| "/gradio_api/info", |
There was a problem hiding this comment.
New /gradio_api/* routes are introduced here, but there don’t appear to be any tests covering them (there are existing HTTP API tests for /api/*). Consider adding pytest coverage that sets SYSTEM=spaces and verifies: GET /gradio_api/info shape, POST /gradio_api/call/{api} returns an event_id, GET .../{event_id} returns a complete SSE event, and /gradio_api/upload aliases /api/upload.
… SSE no-store - Apply Authorization Bearer to hf_token when missing (Spaces only) - Store api_name with gradio call events; validate on poll; restore event on mismatch - Add Cache-Control and X-Accel-Buffering on SSE poll responses - Add unit tests for gradio_api routes under SYSTEM=spaces Made-with: Cursor
…log poll outcomes - Treat None and blank hf_token (incl. whitespace-only) as unset for Bearer injection - On gradio poll api_name mismatch, evict oldest if needed then restore event - Log distinct messages for unknown event_id vs api_name mismatch (same 404 body) Made-with: Cursor
Short description
This pull request registers Gradio-style HTTP routes when Trackio runs on Hugging Face Spaces (
SYSTEM=spaces):GET /gradio_api/info,POST /gradio_api/call/{api_name},GET /gradio_api/call/{api_name}/{event_id}, andPOST /gradio_api/upload(mirroring/api/upload). The info endpoint returns a schema shaped like Gradiosnamed_endpoints/unnamed_endpointspayload. Call endpoints reuse the existing Trackio API implementation: POST returns anevent_id, and GET streams a single SSEcomplete` event with the result, matching the pattern shown in the Spaces Agents tab.AI Disclosure
Type of Change
Related Issues
Closes: #514
Testing and linting
ruff check --fix --select I && ruff format(passed)pytest tests/ --ignore=tests/ui/— 127 passed (UI Playwright tests were not run; they failed locally due to missing frontend build / timeouts unrelated to this change)Made with Cursor