semif-api-rocm

SemIf HTTP API and rocm flake
Log | Files | Refs | README | LICENSE

app.py (11220B)


      1 """HTTP API around TheoLeeCJ/SemIf.
      2 
      3 SemIf is used purely as a library (semif_phase1); this package contains no
      4 copied scorer logic, so upstream updates apply without a rebase.
      5 """
      6 
      7 from __future__ import annotations
      8 
      9 import asyncio
     10 import os
     11 import time
     12 from contextlib import asynccontextmanager
     13 from pathlib import Path
     14 
     15 import uvicorn
     16 from fastapi import FastAPI, HTTPException
     17 from fastapi.staticfiles import StaticFiles
     18 from pydantic import BaseModel
     19 from starlette.concurrency import run_in_threadpool
     20 
     21 from semif_phase1.core import load_causal_model, validate_row
     22 from .llama import BackendError, DEFAULT_MODEL, LlamaBackend
     23 
     24 MODEL = os.environ.get("SEMIF_MODEL", "Qwen/Qwen3.5-4B")
     25 REVISION = os.environ.get("SEMIF_REVISION", "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a")
     26 MAX_TOKENS = int(os.environ.get("SEMIF_MAX_TOKENS", "4096"))
     27 # Note: no planning sampling knobs — plan() pins the model card's
     28 # thinking-mode settings (PLAN_SAMPLING), and no max_tokens is sent since it
     29 # would cap the reasoning trace too.
     30 HOST = os.environ.get("SEMIF_HOST", "127.0.0.1")
     31 PORT = int(os.environ.get("SEMIF_PORT", "8321"))
     32 
     33 # The browser UI ships as package data (see pyproject package-data) and is served
     34 # by the API itself, so the page is same-origin: no CORS, and it works on a GPU
     35 # host with no network access.
     36 WEB_DIR = Path(__file__).parent / "web"
     37 
     38 
     39 class OptionIn(BaseModel):
     40     id: str
     41     description: str
     42 
     43 
     44 class DecisionIn(BaseModel):
     45     """One decision: evidence state + criterion + declared options."""
     46 
     47     id: str
     48     state: str | dict | list
     49     question: str
     50     options: list[OptionIn]
     51 
     52 
     53 class BatchDecisionIn(BaseModel):
     54     id: str
     55     question: str
     56     options: list[OptionIn]
     57 
     58 
     59 class BatchIn(BaseModel):
     60     """Many decisions against one state; execution depends on the backend."""
     61 
     62     state: str | dict | list
     63     decisions: list[BatchDecisionIn]
     64 
     65 
     66 class ModelIn(BaseModel):
     67     model: str
     68 
     69 
     70 class PlanTurn(BaseModel):
     71     """One transcript entry: a completed action and its observed outcome."""
     72 
     73     role: str
     74     content: str
     75 
     76 
     77 class PlanIn(BaseModel):
     78     """A planning request: context note + action history for a reasoning chat.
     79 
     80     Game-agnostic by construction: the caller composes the prompt from facts it
     81     owns — the simulation's one-line goal, what triggered planning, and the
     82     rules currently in effect — and the transcript of completed actions; the
     83     backend runs one chat completion under a universal system prompt and
     84     returns the generated rules plus the thinking trace.
     85     """
     86 
     87     id: str
     88     prompt: str
     89     transcript: list[PlanTurn] = []
     90 
     91 
     92 async def _score(function, *args, **kwargs):
     93     """Run one scorer call, mapping upstream input failures to 422.
     94 
     95     Upstream raises ValueError for every input problem it detects -- token
     96     budget in encode_prompt, answer-slot tokenisation, the shared-state and
     97     batch-id rules in score_shared -- and those are contract violations, not
     98     server faults. Without this they escape as a bare 500 whose real reason is
     99     only in the log, contradicting docs/usage.md. Remote backend failures
    100     become 502; unexpected local faults still propagate as 500.
    101     """
    102     try:
    103         return await run_in_threadpool(function, *args, **kwargs)
    104     except ValueError as error:
    105         raise HTTPException(status_code=422, detail=str(error)) from error
    106     except BackendError as error:
    107         raise HTTPException(status_code=502, detail=str(error)) from error
    108 
    109 
    110 def _row(decision: DecisionIn) -> dict:
    111     row = decision.model_dump()
    112     try:
    113         validate_row(row)
    114     except ValueError as error:
    115         raise HTTPException(status_code=422, detail=str(error)) from error
    116     return row
    117 
    118 
    119 def create_app() -> FastAPI:
    120     backend_name = os.environ.get("SEMIF_BACKEND", "torch")
    121     if backend_name not in {"torch", "llama"}:
    122         raise ValueError("SEMIF_BACKEND must be torch or llama")
    123 
    124     @asynccontextmanager
    125     async def lifespan(app):
    126         app.state.lock = asyncio.Lock()
    127         app.state.llama = None
    128         if backend_name == "llama":
    129             app.state.llama = LlamaBackend(
    130                 os.environ.get("SEMIF_LLAMA_URL", "http://127.0.0.1:8080"),
    131                 os.environ.get("SEMIF_LLAMA_MODEL", DEFAULT_MODEL),
    132                 timeout=float(os.environ.get("SEMIF_LLAMA_TIMEOUT", "600")),
    133                 n_probs=int(os.environ.get("SEMIF_LLAMA_N_PROBS", "1024")),
    134                 max_n_probs=int(os.environ.get("SEMIF_LLAMA_MAX_N_PROBS", "16384")),
    135                 max_tokens=MAX_TOKENS,
    136                 cache_prompt=os.environ.get("SEMIF_LLAMA_CACHE_PROMPT", "true").lower() == "true",
    137             )
    138             app.state.metadata = app.state.llama.metadata
    139             # The server loads the selected GGUF lazily; the first score confirms it.
    140             app.state.model_state = "pending"
    141         else:
    142             # No torch model or scorer initialization on the llama path.
    143             from semif_phase1.direct import score as direct_score
    144             from semif_phase1.shared import score_shared
    145             app.state.direct_score = direct_score
    146             app.state.shared_score = score_shared
    147             model, tokenizer, metadata = await run_in_threadpool(load_causal_model, MODEL, REVISION)
    148             app.state.model, app.state.tokenizer, app.state.metadata = model, tokenizer, metadata
    149             app.state.model_state = "loaded"
    150         yield
    151 
    152     app = FastAPI(title="semif-api", version="0.1.0", lifespan=lifespan)
    153     app.mount("/ui", StaticFiles(directory=WEB_DIR, html=True), name="ui")
    154 
    155     @app.get("/healthz")
    156     def healthz() -> dict:
    157         # Liveness, not a remote readiness check: never load a GGUF on polling.
    158         return {"status": "ok", "backend": backend_name, "model": app.state.metadata,
    159                 "max_tokens": MAX_TOKENS,
    160                 "backend_status": "not_checked" if backend_name == "llama" else "loaded",
    161                 "model_state": app.state.model_state}
    162 
    163     @app.get("/models")
    164     async def models() -> dict:
    165         """Served models + the runtime selection. `switching` is false on torch."""
    166         if app.state.llama is None:
    167             return {"switching": False, "current": app.state.metadata["source"],
    168                     "model_state": app.state.model_state, "models": []}
    169         try:
    170             available = await run_in_threadpool(app.state.llama.list_models)
    171         except BackendError as error:
    172             raise HTTPException(status_code=502, detail=str(error)) from error
    173         return {"switching": True, "current": app.state.llama.model,
    174                 "model_state": app.state.model_state, "models": available}
    175 
    176     @app.post("/models")
    177     async def select_model(selection: ModelIn) -> dict:
    178         """Switch the llama backend's model: unload current, lazy-load on first request.
    179 
    180         Serialized on the same lock as scoring, so a switch never interleaves
    181         with a score that still needs the old weights. Runtime state only:
    182         nothing here changes the process environment or the llama-server config.
    183         """
    184         if app.state.llama is None:
    185             raise HTTPException(status_code=422,
    186                                 detail="model selection is only available on the llama backend")
    187         async with app.state.lock:
    188             try:
    189                 result = await run_in_threadpool(app.state.llama.switch, selection.model)
    190             except ValueError as error:
    191                 raise HTTPException(status_code=422, detail=str(error)) from error
    192             except BackendError as error:
    193                 raise HTTPException(status_code=502, detail=str(error)) from error
    194             if not result["unchanged"]:
    195                 app.state.model_state = "pending"
    196                 app.state.metadata = app.state.llama.metadata
    197         return {"current": app.state.llama.model, "model_state": app.state.model_state,
    198                 "warning": result["warning"],
    199                 "status": "selected " + result["selected"] + (
    200                     " (no change)" if result["unchanged"]
    201                     else "; loads on first request")}
    202 
    203     @app.post("/decide")
    204     async def decide(decision: DecisionIn) -> dict:
    205         row = _row(decision)
    206         async with app.state.lock:
    207             if app.state.llama is not None:
    208                 result = await _score(app.state.llama.score, row)
    209                 app.state.model_state = "loaded"
    210                 return result
    211             return await _score(
    212                 app.state.direct_score, app.state.model, app.state.tokenizer, row, app.state.metadata, MAX_TOKENS
    213             )
    214 
    215     @app.post("/plan")
    216     async def plan(request: PlanIn) -> dict:
    217         """Generate environment rules from an action transcript; llama backend only.
    218 
    219         Torch has no text-generation path (it exists purely for logit readout),
    220         so planning is refused there rather than silently degraded.
    221         """
    222         if app.state.llama is None:
    223             raise HTTPException(
    224                 status_code=422,
    225                 detail="/plan requires the llama backend (SEMIF_BACKEND=llama); "
    226                        "the torch backend only scores declared options")
    227         async with app.state.lock:
    228             result = await _score(
    229                 app.state.llama.plan, request.id, request.prompt,
    230                 [turn.model_dump() for turn in request.transcript],
    231             )
    232             app.state.model_state = "loaded"
    233             return result
    234 
    235     @app.post("/decide-batch")
    236     async def decide_batch(batch: BatchIn) -> dict:
    237         rows = [
    238             {
    239                 "id": decision.id,
    240                 "state": batch.state,
    241                 "question": decision.question,
    242                 "options": [option.model_dump() for option in decision.options],
    243             }
    244             for decision in batch.decisions
    245         ]
    246         if not rows:
    247             raise HTTPException(status_code=422, detail="Shared scoring requires one nonempty exact state")
    248         if len({row["id"] for row in rows}) != len(rows):
    249             raise HTTPException(status_code=422, detail="Decision IDs must be unique")
    250         for row in rows:
    251             try:
    252                 validate_row(row)
    253             except ValueError as error:
    254                 raise HTTPException(status_code=422, detail=str(error)) from error
    255         async with app.state.lock:
    256             if app.state.llama is not None:
    257                 started = time.perf_counter()
    258                 results = []
    259                 for row in rows:
    260                     results.append(await _score(app.state.llama.score, row))
    261                 app.state.model_state = "loaded"
    262                 timing = {"total_seconds": time.perf_counter() - started,
    263                           "batch_size": len(rows), "mode": "llama-sequential"}
    264             else:
    265                 results, timing = await _score(
    266                     app.state.shared_score, app.state.model, app.state.tokenizer,
    267                     rows, app.state.metadata, MAX_TOKENS
    268                 )
    269         return {
    270             "results": [{**result, "shared_timing": timing} for result in results],
    271             "timing": timing,
    272         }
    273 
    274     return app
    275 
    276 
    277 app = create_app()
    278 
    279 
    280 def run() -> None:
    281     uvicorn.run(app, host=HOST, port=PORT)