| import os |
| import uuid |
| from enum import Enum |
| from html import escape |
| from typing import Optional |
|
|
| from fastapi import FastAPI, Form, Header, HTTPException |
| from fastapi.responses import HTMLResponse, RedirectResponse |
| from pydantic import BaseModel |
|
|
|
|
| |
| |
| |
|
|
| BROKER_TOKEN = os.environ.get("BROKER_TOKEN") |
| UI_TOKEN = os.environ.get("UI_TOKEN") |
|
|
| if not BROKER_TOKEN: |
| raise RuntimeError( |
| "Missing BROKER_TOKEN. Add it in Hugging Face Space " |
| "Settings → Variables and secrets → New secret." |
| ) |
|
|
| if not UI_TOKEN: |
| raise RuntimeError( |
| "Missing UI_TOKEN. Add it in Hugging Face Space " |
| "Settings → Variables and secrets → New secret." |
| ) |
|
|
|
|
| |
| |
| |
|
|
| MODEL_OPTIONS = { |
| "granite-4.0-micro": "ibm-granite/granite-4.0-micro", |
| "granite-4.1-8b": "ibm-granite/granite-4.1-8b", |
| "granite-4.1-30b": "ibm-granite/granite-4.1-30b", |
| "qwen2.5-coder-32b": "Qwen/Qwen2.5-Coder-32B-Instruct", |
| } |
|
|
| BASIC_COMMANDS = { |
| "hostname": "Run hostname", |
| "whoami": "Run whoami", |
| "pwd": "Show current directory", |
| "disk": "Show disk usage", |
| "date": "Show current date", |
| "list_home": "List home directory", |
| } |
|
|
| PARAMETERIZED_COMMANDS = { |
| "run_paper_reader": "Run paper_reader with model, GPU count, and prompt", |
| } |
|
|
|
|
| |
| |
| |
|
|
| class JobStatus(str, Enum): |
| queued = "queued" |
| running = "running" |
| done = "done" |
| failed = "failed" |
|
|
|
|
| class Job(BaseModel): |
| id: str |
| command: str |
| status: JobStatus = JobStatus.queued |
| result: Optional[str] = None |
|
|
| |
| model: Optional[str] = None |
| gpus: Optional[int] = None |
| user_text: Optional[str] = None |
|
|
|
|
| app = FastAPI(title="Fury Broker") |
|
|
| |
| jobs: dict[str, Job] = {} |
|
|
|
|
| |
| |
| |
|
|
| def verify_broker_token(x_broker_token: Optional[str]) -> None: |
| """ |
| Used by the Fury worker and command-line API calls. |
| |
| Header: |
| X-Broker-Token: BROKER_TOKEN |
| """ |
| if x_broker_token != BROKER_TOKEN: |
| raise HTTPException(status_code=401, detail="Unauthorized") |
|
|
|
|
| def verify_ui_token(ui_token: Optional[str]) -> None: |
| """ |
| Used by browser form submissions. |
| |
| Form field: |
| ui_token |
| """ |
| if ui_token != UI_TOKEN: |
| raise HTTPException(status_code=401, detail="Invalid UI token") |
|
|
|
|
| def validate_basic_command(command: str) -> None: |
| if command not in BASIC_COMMANDS: |
| raise HTTPException(status_code=400, detail=f"Command is not allowed: {command}") |
|
|
|
|
| def validate_paper_reader_args( |
| model: str, |
| gpus: int, |
| user_text: str, |
| ) -> None: |
| if model not in MODEL_OPTIONS: |
| raise HTTPException(status_code=400, detail=f"Model is not allowed: {model}") |
|
|
| if gpus < 1 or gpus > 16: |
| raise HTTPException(status_code=400, detail="GPUs must be between 1 and 16") |
|
|
| if user_text is None: |
| raise HTTPException(status_code=400, detail="Prompt is required") |
|
|
| if len(user_text.strip()) == 0: |
| raise HTTPException(status_code=400, detail="Prompt cannot be empty") |
|
|
| if len(user_text) > 10_000: |
| raise HTTPException( |
| status_code=400, |
| detail="Prompt is too long; max 10,000 characters", |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @app.get("/health") |
| def health(): |
| return { |
| "status": "ok", |
| "service": "fury-broker", |
| "jobs_count": len(jobs), |
| } |
|
|
|
|
| @app.get("/api/commands") |
| def list_commands(x_broker_token: Optional[str] = Header(default=None)): |
| verify_broker_token(x_broker_token) |
|
|
| return { |
| "basic_commands": BASIC_COMMANDS, |
| "parameterized_commands": PARAMETERIZED_COMMANDS, |
| "paper_reader": { |
| "command": "run_paper_reader", |
| "model_options": MODEL_OPTIONS, |
| "gpu_range": [1, 16], |
| "default_gpus": 1, |
| "default_prompt": "hello", |
| }, |
| } |
|
|
|
|
| |
| |
| |
|
|
| @app.get("/", response_class=HTMLResponse) |
| def home(): |
| basic_command_options_html = "\n".join( |
| f'<option value="{escape(name)}">{escape(name)} — {escape(label)}</option>' |
| for name, label in BASIC_COMMANDS.items() |
| ) |
|
|
| model_options_html = "\n".join( |
| f'<option value="{escape(key)}">{escape(value)}</option>' |
| for key, value in MODEL_OPTIONS.items() |
| ) |
|
|
| gpu_options_html = "\n".join( |
| f'<option value="{i}" {"selected" if i == 1 else ""}>{i}</option>' |
| for i in range(1, 17) |
| ) |
|
|
| rows = "" |
|
|
| for job in reversed(list(jobs.values())): |
| details = [] |
|
|
| if job.model: |
| details.append(f"model={job.model}") |
|
|
| if job.gpus: |
| details.append(f"gpus={job.gpus}") |
|
|
| if job.user_text: |
| preview = job.user_text[:500] |
| if len(job.user_text) > 500: |
| preview += "..." |
| details.append(f"prompt={preview}") |
|
|
| safe_details = escape("\n".join(details)) |
| safe_result = escape(job.result or "") |
|
|
| rows += f""" |
| <tr> |
| <td><code>{escape(job.id)}</code></td> |
| <td>{escape(job.command)}</td> |
| <td>{escape(job.status.value)}</td> |
| <td><pre>{safe_details}</pre></td> |
| <td><pre>{safe_result}</pre></td> |
| </tr> |
| """ |
|
|
| return f""" |
| <!doctype html> |
| <html> |
| <head> |
| <title>Fury Broker</title> |
| <style> |
| body {{ |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| margin: 40px; |
| max-width: 1300px; |
| line-height: 1.45; |
| }} |
| h1 {{ |
| margin-bottom: 0.2rem; |
| }} |
| .subtitle {{ |
| color: #555; |
| margin-bottom: 2rem; |
| }} |
| form {{ |
| margin: 1.5rem 0; |
| padding: 1rem; |
| border: 1px solid #ddd; |
| border-radius: 8px; |
| background: #fafafa; |
| }} |
| input, select, textarea, button {{ |
| padding: 6px; |
| margin: 4px; |
| }} |
| textarea {{ |
| width: 95%; |
| font-family: monospace; |
| }} |
| table {{ |
| border-collapse: collapse; |
| width: 100%; |
| margin-top: 20px; |
| }} |
| th, td {{ |
| border: 1px solid #ccc; |
| padding: 8px; |
| vertical-align: top; |
| }} |
| th {{ |
| background: #f5f5f5; |
| text-align: left; |
| }} |
| pre {{ |
| white-space: pre-wrap; |
| max-width: 600px; |
| max-height: 500px; |
| overflow: auto; |
| margin: 0; |
| }} |
| .warning {{ |
| color: #8a4b00; |
| background: #fff4dd; |
| border: 1px solid #f0c36d; |
| padding: 0.8rem; |
| border-radius: 6px; |
| }} |
| </style> |
| </head> |
| <body> |
| <h1>Fury Broker</h1> |
| <div class="subtitle"> |
| Submit approved jobs from Hugging Face Spaces to the worker running on Fury. |
| </div> |
| |
| <div class="warning"> |
| This UI does not allow arbitrary shell commands. |
| It submits only predefined command names and validated parameters. |
| Fury validates the job again locally before execution. |
| </div> |
| |
| <form method="post" action="/submit-basic"> |
| <h2>Basic Fury Command</h2> |
| |
| <div> |
| <label><strong>UI token:</strong></label> |
| <input type="password" name="ui_token" placeholder="Enter UI_TOKEN" required> |
| </div> |
| |
| <div> |
| <label><strong>Command:</strong></label> |
| <select name="command"> |
| {basic_command_options_html} |
| </select> |
| </div> |
| |
| <button type="submit">Submit basic job</button> |
| </form> |
| |
| <form method="post" action="/submit-paper-reader"> |
| <h2>run_paper_reader</h2> |
| |
| <div> |
| <label><strong>UI token:</strong></label> |
| <input type="password" name="ui_token" placeholder="Enter UI_TOKEN" required> |
| </div> |
| |
| <div> |
| <label><strong>Model:</strong></label> |
| <select name="model"> |
| {model_options_html} |
| </select> |
| </div> |
| |
| <div> |
| <label><strong>Number of GPUs:</strong></label> |
| <select name="gpus"> |
| {gpu_options_html} |
| </select> |
| </div> |
| |
| <div> |
| <label><strong>Prompt to send to the LLM:</strong></label><br> |
| <textarea |
| name="user_text" |
| rows="8" |
| required |
| >hello</textarea> |
| </div> |
| |
| <button type="submit">Submit run_paper_reader job</button> |
| </form> |
| |
| <p> |
| Refresh the page after a few seconds to see updated results. |
| </p> |
| |
| <h2>Jobs</h2> |
| |
| <table> |
| <tr> |
| <th>ID</th> |
| <th>Command</th> |
| <th>Status</th> |
| <th>Parameters</th> |
| <th>Result</th> |
| </tr> |
| {rows} |
| </table> |
| </body> |
| </html> |
| """ |
|
|
|
|
| |
| |
| |
|
|
| @app.post("/submit-basic") |
| def submit_basic_from_ui( |
| command: str = Form(...), |
| ui_token: str = Form(...), |
| ): |
| verify_ui_token(ui_token) |
| validate_basic_command(command) |
|
|
| job_id = str(uuid.uuid4()) |
|
|
| jobs[job_id] = Job( |
| id=job_id, |
| command=command, |
| status=JobStatus.queued, |
| ) |
|
|
| return RedirectResponse("/", status_code=303) |
|
|
|
|
| @app.post("/submit-paper-reader") |
| def submit_paper_reader_from_ui( |
| model: str = Form(...), |
| gpus: int = Form(1), |
| user_text: str = Form("hello"), |
| ui_token: str = Form(...), |
| ): |
| verify_ui_token(ui_token) |
|
|
| validate_paper_reader_args( |
| model=model, |
| gpus=gpus, |
| user_text=user_text, |
| ) |
|
|
| job_id = str(uuid.uuid4()) |
|
|
| jobs[job_id] = Job( |
| id=job_id, |
| command="run_paper_reader", |
| model=model, |
| gpus=gpus, |
| user_text=user_text, |
| status=JobStatus.queued, |
| ) |
|
|
| return RedirectResponse("/", status_code=303) |
|
|
|
|
| |
| |
| |
|
|
| @app.post("/api/jobs/basic") |
| def submit_basic_job_api( |
| command: str = Form(...), |
| x_broker_token: Optional[str] = Header(default=None), |
| ): |
| verify_broker_token(x_broker_token) |
| validate_basic_command(command) |
|
|
| job_id = str(uuid.uuid4()) |
|
|
| job = Job( |
| id=job_id, |
| command=command, |
| status=JobStatus.queued, |
| ) |
|
|
| jobs[job_id] = job |
| return job |
|
|
|
|
| @app.post("/api/jobs/paper-reader") |
| def submit_paper_reader_job_api( |
| model: str = Form(...), |
| gpus: int = Form(1), |
| user_text: str = Form("hello"), |
| x_broker_token: Optional[str] = Header(default=None), |
| ): |
| verify_broker_token(x_broker_token) |
|
|
| validate_paper_reader_args( |
| model=model, |
| gpus=gpus, |
| user_text=user_text, |
| ) |
|
|
| job_id = str(uuid.uuid4()) |
|
|
| job = Job( |
| id=job_id, |
| command="run_paper_reader", |
| model=model, |
| gpus=gpus, |
| user_text=user_text, |
| status=JobStatus.queued, |
| ) |
|
|
| jobs[job_id] = job |
| return job |
|
|
|
|
| |
| @app.post("/api/jobs") |
| def submit_job_legacy_api( |
| command: str = Form(...), |
| x_broker_token: Optional[str] = Header(default=None), |
| ): |
| verify_broker_token(x_broker_token) |
| validate_basic_command(command) |
|
|
| job_id = str(uuid.uuid4()) |
|
|
| job = Job( |
| id=job_id, |
| command=command, |
| status=JobStatus.queued, |
| ) |
|
|
| jobs[job_id] = job |
| return job |
|
|
|
|
| |
| |
| |
|
|
| @app.get("/api/next-job") |
| def get_next_job(x_broker_token: Optional[str] = Header(default=None)): |
| verify_broker_token(x_broker_token) |
|
|
| for job in jobs.values(): |
| if job.status == JobStatus.queued: |
| job.status = JobStatus.running |
|
|
| return { |
| "id": job.id, |
| "command": job.command, |
| "model": job.model, |
| "gpus": job.gpus, |
| "user_text": job.user_text, |
| } |
|
|
| return {"id": None} |
|
|
|
|
| @app.post("/api/jobs/{job_id}/result") |
| def post_result( |
| job_id: str, |
| result: str = Form(...), |
| success: bool = Form(...), |
| x_broker_token: Optional[str] = Header(default=None), |
| ): |
| verify_broker_token(x_broker_token) |
|
|
| if job_id not in jobs: |
| raise HTTPException(status_code=404, detail="Job not found") |
|
|
| job = jobs[job_id] |
| job.result = result |
| job.status = JobStatus.done if success else JobStatus.failed |
|
|
| return job |
|
|
|
|
| @app.get("/api/jobs") |
| def list_jobs(x_broker_token: Optional[str] = Header(default=None)): |
| verify_broker_token(x_broker_token) |
| return {"jobs": list(jobs.values())} |
|
|
|
|
| @app.get("/api/jobs/{job_id}") |
| def get_job( |
| job_id: str, |
| x_broker_token: Optional[str] = Header(default=None), |
| ): |
| verify_broker_token(x_broker_token) |
|
|
| if job_id not in jobs: |
| raise HTTPException(status_code=404, detail="Job not found") |
|
|
| return jobs[job_id] |
|
|
|
|
| @app.post("/api/clear") |
| def clear_jobs(x_broker_token: Optional[str] = Header(default=None)): |
| verify_broker_token(x_broker_token) |
|
|
| jobs.clear() |
|
|
| return { |
| "status": "cleared", |
| "jobs_count": len(jobs), |
| } |
|
|