File size: 28,480 Bytes
1a2024f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f70bb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a2024f
 
 
 
1f70bb6
 
 
1a2024f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f70bb6
 
 
1a2024f
 
 
 
 
 
1f70bb6
 
1a2024f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f70bb6
 
1a2024f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f70bb6
1a2024f
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
"""THOX ZeroGPU model Space — one model behind an OpenAI-compatible /v1 endpoint.

This single file is deployed verbatim to every THOX ZeroGPU model Space. The only
per-Space difference is `thox_space_config.py`, which names the model to serve.

Why FastAPI *and* Gradio: Open WebUI consumes this Space as an ordinary "OpenAI
API" connection, which means it needs `/v1/models` and `/v1/chat/completions`.
Gradio alone does not speak that protocol, so the FastAPI app owns the `/v1`
routes and the Gradio Blocks UI is mounted at `/` for humans. ZeroGPU only
schedules Gradio SDK Spaces, so the Gradio app must genuinely be present -- it is
not decoration.
"""

from __future__ import annotations

import json
import re
import os
import time
import uuid
from threading import Thread
from typing import Any, Iterator

# Disable Gradio 5 server-side rendering BEFORE gradio is imported.
# On a Spaces Gradio SDK Space, SSR spawns a Node.js front-end server that claims the
# port this app needs, producing "[Errno 98] address already in use" followed by
# "Stopping Node.js server...". We serve the Blocks app from our own uvicorn process, so
# the SSR layer has nothing to add here and everything to collide with.
os.environ.setdefault("GRADIO_SSR_MODE", "false")

import gradio as gr  # noqa: E402
import spaces
import torch
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer

import thox_auth  # noqa: E402
from thox_space_config import (
    ADAPTER_ID,
    CAVEAT,
    CHAT_TEMPLATE_KWARGS,
    DESCRIPTION,
    MAX_CONTEXT_TOKENS,
    MODEL_ID,
    SERVED_MODEL_NAME,
    SYSTEM_PROMPT,
)

# --------------------------------------------------------------------------------------
# Model loading (module scope, eager -- see ZeroGPU rules)
# --------------------------------------------------------------------------------------
# ZeroGPU monkey-patches torch so `cuda` resolves at module scope and the weights are
# registered with the scheduler, then streamed into VRAM when a @spaces.GPU call lands.
# Loading lazily on first request would just move this cost onto the first user.
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
DTYPE = torch.bfloat16 if DEVICE.type == "cuda" else torch.float32

print(f"[thox] loading base model: {MODEL_ID}", flush=True)
TOKENIZER = AutoTokenizer.from_pretrained(MODEL_ID)
if TOKENIZER.pad_token_id is None:
    TOKENIZER.pad_token = TOKENIZER.eos_token

# ASSEMBLE ON CPU, MOVE ONCE AT THE END.
#
# ZeroGPU intercepts .to("cuda") at module scope and *registers* tensors with its
# scheduler rather than allocating them -- there is no GPU attached outside a
# @spaces.GPU call. PEFT does not go through that path: load_peft_weights calls
# safe_load_file(..., device=<model.device>), which is a REAL CUDA allocation, so
# attaching an adapter to an already-"cuda" model dies at import with
# "RuntimeError: No CUDA GPUs are available".
#
# Building base + adapter + merge entirely on CPU and moving the finished module once
# keeps every CUDA touch inside the single call ZeroGPU is prepared for.
MODEL = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=DTYPE)

if ADAPTER_ID:
    # THOX ships several of its models as LoRA adapters rather than merged weights.
    from peft import PeftModel

    print(f"[thox] applying LoRA adapter on CPU: {ADAPTER_ID}", flush=True)

    # PEFT picks its own load device: load_peft_weights() -> infer_device(), which
    # branches on torch.cuda.is_available(). `import spaces` monkey-patches that to
    # return True unconditionally, so PEFT selects "cuda" and safe_load_file() attempts
    # a real allocation in a process with no GPU attached -- "RuntimeError: No CUDA GPUs
    # are available", at import, before anything can be served. Putting the model on CPU
    # does not help, because PEFT never consults the model.
    #
    # So the flag PEFT actually reads is told the truth for the duration of the load.
    # Restored immediately after: the rest of the app (and ZeroGPU itself) depends on the
    # patched value being True.
    _patched_is_available = torch.cuda.is_available
    torch.cuda.is_available = lambda: False
    try:
        MODEL = PeftModel.from_pretrained(MODEL, ADAPTER_ID)
        MODEL = MODEL.merge_and_unload()  # merge so generation runs at full speed
    finally:
        torch.cuda.is_available = _patched_is_available

MODEL = MODEL.to(DEVICE)
MODEL.eval()
print(f"[thox] ready: serving '{SERVED_MODEL_NAME}' on {DEVICE}", flush=True)


# --------------------------------------------------------------------------------------
# Generation
# --------------------------------------------------------------------------------------
def _encode(messages: list[dict], tools: list | None = None):
    """Apply the chat template, returning the dict form.

    return_dict=True is passed explicitly: transformers v5 flipped the default, so the
    older `...(return_tensors="pt").to(device)` idiom yields a BatchEncoding whose
    `.shape` lookup falls through __getattr__ into KeyError('shape') -- which crosses the
    ZeroGPU process boundary as a bare AttributeError with the cause stripped off. Asking
    for the dict form is correct on both 4.x and 5.x.

    CHAT_TEMPLATE_KWARGS carries per-model template switches (e.g. Qwen3's
    enable_thinking). Unknown keys are simply template variables, so a model whose
    template ignores them is unaffected.
    """
    kwargs = dict(CHAT_TEMPLATE_KWARGS)
    # Passing tools= lets the model's OWN chat template render the tool schema in the
    # exact form it was trained on. Qwen2.5-Coder and Hermes-3 both support this; a
    # template that does not simply ignores the argument. Hand-writing tool descriptions
    # into the prompt instead would fight whatever format the model actually learned.
    if tools:
        kwargs["tools"] = tools
    return TOKENIZER.apply_chat_template(
        messages, add_generation_prompt=True, return_tensors="pt", return_dict=True,
        **kwargs,
    )


def _estimate_duration(messages: list[dict], max_new_tokens: int, temperature: float,
                       top_p: float, tools: list | None = None) -> int:
    """Request only the GPU seconds this call plausibly needs.

    NOTE: @spaces.GPU(duration=...) invokes this with the SAME arguments as the wrapped
    function, so this signature MUST track _stream_tokens exactly. Adding a parameter to
    one without the other fails at request time inside the ZeroGPU wrapper
    ("takes 4 positional arguments but 5 were given"), not at import.

    ZeroGPU pre-checks the *requested* duration against the caller's remaining quota,
    not the actual runtime -- so leaving everything at the 60s default makes short
    requests fail with "quota exceeded" once a user drops below 60s. Shorter requests
    also rank higher in the node-level queue.
    """
    return int(min(120, max(15, (max_new_tokens / 12) + 10)))


@spaces.GPU(duration=_estimate_duration)
def _stream_tokens(messages: list[dict], max_new_tokens: int, temperature: float,
                   top_p: float, tools: list | None = None) -> Iterator[tuple[str, str]]:
    """Yield ("delta", text) pairs, then exactly one ("finish", reason) pair.

    Runs in a separate ZeroGPU worker process. Everything crossing this boundary is
    pickled, so only plain strings are yielded -- never CUDA tensors, which would trigger
    a blocked torch.cuda._lazy_init() in the parent process.

    The trailing ("finish", reason) pair exists because the stop reason is only knowable
    inside the worker: the caller sees text, not token counts, and cannot tell a model
    that chose to stop from one that ran into the max_new_tokens ceiling. Clients rely on
    finish_reason to detect truncation, so reporting "stop" for a truncated response
    makes a cut-off answer look complete.
    """
    # return_dict=True explicitly, and index the result.
    # transformers v5 flipped apply_chat_template's default to return_dict=True, so the
    # older `...(return_tensors="pt").to(device)` idiom yields a BatchEncoding whose
    # `.shape` lookup falls through __getattr__ to KeyError('shape') -- which crosses the
    # ZeroGPU process boundary as a bare AttributeError with the cause stripped off.
    # Asking for the dict form is correct on both 4.x and 5.x.
    enc = _encode(messages, tools)
    input_ids = enc["input_ids"]
    attention_mask = enc.get("attention_mask")

    # Guard the context window so an over-long conversation degrades predictably rather
    # than producing silent garbage.
    if input_ids.shape[-1] > MAX_CONTEXT_TOKENS:
        input_ids = input_ids[:, -MAX_CONTEXT_TOKENS:]
        if attention_mask is not None:
            attention_mask = attention_mask[:, -MAX_CONTEXT_TOKENS:]

    input_ids = input_ids.to(MODEL.device)
    if attention_mask is not None:
        attention_mask = attention_mask.to(MODEL.device)

    streamer = TextIteratorStreamer(TOKENIZER, skip_prompt=True, skip_special_tokens=True)
    kwargs: dict[str, Any] = dict(
        input_ids=input_ids,
        attention_mask=attention_mask,
        streamer=streamer,
        max_new_tokens=max_new_tokens,
        do_sample=temperature > 0,
        pad_token_id=TOKENIZER.pad_token_id,
    )
    if temperature > 0:
        kwargs["temperature"] = temperature
        kwargs["top_p"] = top_p

    # Capture generate()'s output so the true stop reason is available after the stream
    # drains. Comparing generated length against the ceiling is exact, where inferring it
    # from the decoded text would not be.
    result: dict[str, Any] = {}

    def _run() -> None:
        try:
            result["ids"] = MODEL.generate(**kwargs)
        except Exception as exc:  # noqa: BLE001
            result["error"] = exc

    thread = Thread(target=_run)
    thread.start()
    for delta in streamer:
        if delta:
            yield "delta", delta
    thread.join()

    if "error" in result:
        raise result["error"]

    generated = int(result["ids"].shape[-1] - input_ids.shape[-1]) if "ids" in result else 0
    yield "finish", ("length" if generated >= max_new_tokens else "stop")


def _normalise_messages(raw: list[dict]) -> list[dict]:
    """Coerce OpenAI message content into the plain strings the chat template expects."""
    out: list[dict] = []
    if SYSTEM_PROMPT and not (raw and raw[0].get("role") == "system"):
        out.append({"role": "system", "content": SYSTEM_PROMPT})
    for m in raw:
        content = m.get("content", "")
        if isinstance(content, list):
            # OpenAI multimodal array form -- keep only the text parts.
            content = "".join(
                p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"
            )
        msg: dict = {"role": m.get("role", "user"), "content": content or ""}
        # Tool linkage must survive normalisation. An assistant turn that made a call
        # carries tool_calls; the following tool turn carries tool_call_id pointing back
        # at it. Rebuilding messages as {role, content} alone silently breaks that chain,
        # so the model can make a call but can never be told what the tool returned --
        # the round trip dies on the second turn, which looks like the tool "not working".
        if m.get("tool_calls"):
            msg["tool_calls"] = m["tool_calls"]
        if m.get("tool_call_id"):
            msg["tool_call_id"] = m["tool_call_id"]
        if m.get("name"):
            msg["name"] = m["name"]
        out.append(msg)
    return out


# --------------------------------------------------------------------------------------
# Tool-call parsing
# --------------------------------------------------------------------------------------
_TOOL_CALL_RX = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.S)
# Qwen2.5-Coder ignores its own template's <tool_call> instruction and emits an
# XML form instead (observed verbatim, inside a ```xml fence):
#     <response><function_call><name>x</name><arguments>{}</arguments></function_call></response>
# The template DOES render the Hermes instruction correctly - the model simply
# deviates - so this is a model-behaviour gap, not a prompting bug, and the parser
# has to absorb it or the coder model can never call a tool.
_FUNCTION_CALL_XML_RX = re.compile(
    r"<function_call>\s*<name>\s*([^<]+?)\s*</name>\s*"
    r"<arguments>\s*(.*?)\s*</arguments>\s*</function_call>", re.S)


# A FOURTH form, observed on thox-coder-7b in production: format 3 wrapped in a markdown
# code fence.
#     ```json
#     {"name": "generate_video", "arguments": {...}}
#     ```
# Qwen2.5-Coder is a CODER model and fencing JSON is precisely what it was trained to do,
# so this is not an edge case for it -- it is the default. Without unwrapping, the
# bare-JSON branch never fires, because the text starts with a backtick rather than a
# brace, and the model reads as refusing tool use rather than as using another wrapper.
_CODE_FENCE_RX = re.compile(r"^\s*```(?:[A-Za-z0-9_+-]*)\s*\n(.*?)\n?\s*```\s*$", re.S)


def _strip_code_fence(text: str) -> str:
    """Unwrap a fence that surrounds the ENTIRE response.

    Deliberately anchored at both ends: a fence embedded in prose is a model TALKING
    about JSON, not calling something, and unwrapping that would turn an explanation into
    a spurious invocation.
    """
    m = _CODE_FENCE_RX.match(text)
    return m.group(1).strip() if m else text


def _extract_tool_calls(text: str) -> tuple[list[dict], str]:
    """Pull tool calls out of generated text -> (openai_tool_calls, remaining_content).

    Parsed in the PARENT process, deliberately. The @spaces.GPU worker keeps yielding
    plain ("delta"|"finish", str) pairs; putting structured objects through that boundary
    would mean pickling them across process lines for no benefit.

    THREE FORMATS, because the fleet is not one model family:
      * <tool_call>{"name":..., "arguments":{...}}</tool_call>
        Qwen2.5-Coder and Hermes-3 (Qwen adopted the Hermes convention).
      * <function_call><name>x</name><arguments>{...}</arguments></function_call>
        Qwen2.5-Coder's actual observed output, despite its template instructing the
        tagged JSON form above.
      * a bare JSON object with name/arguments and no tags
        Llama-3.2 derivatives (thox-mini-3b) do not emit the Hermes tags.
    Supporting only the tagged form would leave one model in the fleet silently unable
    to call anything, which is worse than not shipping the feature - it would look like
    a model quality problem rather than a protocol gap.
    """
    calls: list[dict] = []
    remaining = text

    for m in _TOOL_CALL_RX.finditer(text):
        try:
            obj = json.loads(m.group(1))
        except json.JSONDecodeError:
            continue
        calls.append(obj)
        remaining = remaining.replace(m.group(0), "")

    if not calls:
        for m in _FUNCTION_CALL_XML_RX.finditer(text):
            raw_args = (m.group(2) or "").strip()
            try:
                args = json.loads(raw_args) if raw_args else {}
            except json.JSONDecodeError:
                args = {}
            calls.append({"name": m.group(1).strip(), "arguments": args})
            remaining = remaining.replace(m.group(0), "")

    if not calls:
        # Unwrap a whole-response code fence first -- see _strip_code_fence.
        stripped = _strip_code_fence(text).strip()
        # Only treat a bare object as a call when it actually looks like one; a model
        # answering a question ABOUT JSON must not be mistaken for invoking something.
        # Widening the bare-JSON path to fenced text widened the false-positive surface
        # too, so the shape test is tightened in the same change: a real call is
        # name+arguments (or name+parameters), or a bare name for a zero-argument tool.
        # {"name": "Alice", "age": 30} satisfies the old test and is not a call.
        if stripped.startswith("{") and '"name"' in stripped:
            try:
                obj = json.loads(stripped)
                looks_like_call = (
                    isinstance(obj, dict)
                    and "name" in obj
                    and ("arguments" in obj or "parameters" in obj or set(obj) == {"name"})
                )
                if looks_like_call:
                    calls.append(obj)
                    remaining = ""
            except json.JSONDecodeError:
                pass

    out: list[dict] = []
    for i, c in enumerate(calls):
        args = c.get("arguments", c.get("parameters", {}))
        if not isinstance(args, str):
            args = json.dumps(args or {})
        out.append({
            "id": f"call_{uuid.uuid4().hex[:20]}",
            "type": "function",
            "index": i,
            "function": {"name": c.get("name", ""), "arguments": args},
        })
    return out, remaining.strip()


# --------------------------------------------------------------------------------------
# OpenAI-compatible API
# --------------------------------------------------------------------------------------
class ChatMessage(BaseModel):
    role: str
    content: Any = ""
    # Carried so a tool round trip can complete: the assistant turn that made the call,
    # and the tool turn that answers it, must keep their linkage.
    tool_calls: Any = None
    tool_call_id: str | None = None
    name: str | None = None


# Ceilings are enforced by CLAMPING, not by Pydantic bounds.
#
# `Field(le=4096)` looks tidier but is an OpenAI incompatibility: FastAPI turns a
# violation into `422 {"detail": [...]}`, while the OpenAI API returns
# `400 {"error": {...}}`. Measured with the real `openai` SDK, `max_tokens=100000` raised
# `UnprocessableEntityError` on all three template Spaces -- a client that reads
# `error.message` sees nothing at all. Out-of-range sampling values are the caller being
# sloppy, not the caller being wrong, so clamp them and answer.
MAX_OUTPUT_TOKENS = 4096


def _clamp(value, low, high, fallback):
    try:
        return min(max(type(low)(value), low), high)
    except (TypeError, ValueError):
        return fallback


class ChatRequest(BaseModel):
    model: str | None = None
    messages: list[ChatMessage]
    stream: bool = False
    max_tokens: int | None = None
    temperature: float = 0.7
    top_p: float = 0.95
    # Without these two fields Pydantic silently DROPS what the client sent, so the
    # model is never told a tool exists and every call looks like "I have no tools".
    tools: Any = None
    tool_choice: Any = None


api = FastAPI(title=f"THOX ZeroGPU — {SERVED_MODEL_NAME}")


@api.get("/health")
def health() -> dict:
    # Open on purpose, and must stay free of anything secret.
    return {
        "status": "ok",
        "auth": "required" if thox_auth.is_configured() else "UNCONFIGURED",
        "model": SERVED_MODEL_NAME,
        "base": MODEL_ID,
        "adapter": ADAPTER_ID,
    }


@api.get("/v1/models")
def list_models(request: Request):
    # Gated too, deliberately. An unauthenticated /v1/models is how a bogus key gets
    # mistaken for a working one -- it answers 200 and nothing ever proves the key.
    denied = thox_auth.check(request)
    if denied:
        return denied
    return {
        "object": "list",
        "data": [{
            "id": SERVED_MODEL_NAME,
            "object": "model",
            "created": 0,
            "owned_by": "thox",
        }],
    }


@api.post("/v1/chat/completions")
def chat_completions(req: ChatRequest, request: Request):
    denied = thox_auth.check(request)
    if denied:
        return denied
    messages = _normalise_messages([m.model_dump() for m in req.messages])
    # tool_choice "none" means the caller explicitly wants no tool use this turn.
    tools = None if (req.tool_choice == "none") else (req.tools or None)
    max_new = _clamp(req.max_tokens or 512, 1, MAX_OUTPUT_TOKENS, 512)
    temperature = _clamp(req.temperature, 0.0, 2.0, 0.7)
    top_p = _clamp(req.top_p, 0.001, 1.0, 0.95)
    completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
    created = int(time.time())

    if not req.stream:
        parts: list[str] = []
        finish = "stop"
        for kind, payload in _stream_tokens(messages, max_new, temperature,
                                            top_p, tools):
            if kind == "delta":
                parts.append(payload)
            else:
                finish = payload
        text = "".join(parts)
        prompt_tokens = int(_encode(messages, tools)["input_ids"].shape[-1])
        completion_tokens = len(TOKENIZER.encode(text))

        message: dict = {"role": "assistant", "content": text}
        if tools:
            tool_calls, remaining = _extract_tool_calls(text)
            if tool_calls:
                # OpenAI contract: content is null when the turn is a tool call, and
                # finish_reason becomes "tool_calls". Clients branch on finish_reason to
                # decide whether to execute a tool, so reporting "stop" here means the
                # call is emitted and then never run.
                message = {"role": "assistant",
                           "content": remaining or None,
                           "tool_calls": tool_calls}
                finish = "tool_calls"

        return JSONResponse({
            "id": completion_id,
            "object": "chat.completion",
            "created": created,
            "model": SERVED_MODEL_NAME,
            "choices": [{
                "index": 0,
                "message": message,
                "finish_reason": finish,
            }],
            "usage": {
                "prompt_tokens": prompt_tokens,
                "completion_tokens": completion_tokens,
                "total_tokens": prompt_tokens + completion_tokens,
            },
        })

    def sse() -> Iterator[str]:
        base = {
            "id": completion_id,
            "object": "chat.completion.chunk",
            "created": created,
            "model": SERVED_MODEL_NAME,
        }
        # Open WebUI expects the role to arrive before any content delta.
        yield f"data: {json.dumps({**base, 'choices': [{'index': 0, 'delta': {'role': 'assistant'}, 'finish_reason': None}]})}\n\n"
        finish = "stop"
        # With tools in play the text must be BUFFERED rather than streamed through.
        # A tool call is only recognisable once its closing tag has arrived, so emitting
        # deltas as they appear would stream the raw <tool_call>{...}</tool_call> markup
        # into the user's chat window before we could tell it was a call. This costs the
        # typing effect only on tool-enabled turns; ordinary chat still streams.
        buffered: list[str] = []
        try:
            for kind, payload in _stream_tokens(messages, max_new, temperature,
                                                top_p, tools):
                if kind == "finish":
                    finish = payload
                    continue
                if tools:
                    buffered.append(payload)
                    continue
                chunk = {**base, "choices": [{"index": 0, "delta": {"content": payload},
                                              "finish_reason": None}]}
                yield f"data: {json.dumps(chunk)}\n\n"

            if tools:
                text = "".join(buffered)
                tool_calls, remaining = _extract_tool_calls(text)
                if tool_calls:
                    delta: dict = {"tool_calls": tool_calls}
                    if remaining:
                        delta["content"] = remaining
                    finish = "tool_calls"
                    yield f"data: {json.dumps({**base, 'choices': [{'index': 0, 'delta': delta, 'finish_reason': None}]})}\n\n"
                elif text:
                    yield f"data: {json.dumps({**base, 'choices': [{'index': 0, 'delta': {'content': text}, 'finish_reason': None}]})}\n\n"
        except Exception as exc:  # noqa: BLE001
            # Headers are already sent, so a failure cannot become an HTTP 500. Emit a
            # sanitised terminal event instead of tearing the connection down mid-frame,
            # which Open WebUI surfaces as a TransferEncodingError.
            print(f"[thox] generation failed: {type(exc).__name__}", flush=True)
            err = {**base, "choices": [{"index": 0, "delta": {}, "finish_reason": "error"}]}
            yield f"data: {json.dumps(err)}\n\n"
            yield "data: [DONE]\n\n"
            return
        done = {**base, "choices": [{"index": 0, "delta": {}, "finish_reason": finish}]}
        yield f"data: {json.dumps(done)}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(sse(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})


# --------------------------------------------------------------------------------------
# Gradio UI (mounted at /)
# --------------------------------------------------------------------------------------
# Brand v2 theme + shared chrome. Canonical source: thox_theme.py at the repo
# root, copied into each Space by build.py and guarded byte-for-byte by
# ci/test_theme_parity.py. Do not inline a second copy here.
from thox_theme import THOX_THEME, THOX_CSS, THOX_GREEN, THOX_ACCENT  # noqa: E402

CSS = THOX_CSS


def _ui_respond(message: str, history: list[dict]) -> Iterator[str]:
    convo = _normalise_messages([*history, {"role": "user", "content": message}])
    acc = ""
    for kind, payload in _stream_tokens(convo, 512, 0.7, 0.95):
        if kind == "delta":
            acc += payload
            yield acc


with gr.Blocks(css=CSS, title=f"THOX · {SERVED_MODEL_NAME}", theme=THOX_THEME) as demo:
    gr.HTML(
        f"""<div id="thox-header">
          <h1>THOX &middot; {SERVED_MODEL_NAME}</h1>
          <p>{DESCRIPTION}</p>
          <p>OpenAI-compatible endpoint: <code>/v1/chat/completions</code> &middot;
             Your AI. Your Data. Your Rules.&trade;</p>
        </div>"""
    )
    if CAVEAT:
        # Disclosed in the UI, not just the README: someone chatting here is exactly the
        # person who would otherwise take a confident wrong answer at face value.
        gr.HTML(f'<div id="thox-caveat"><strong>Known defect.</strong> {CAVEAT}</div>')
    gr.ChatInterface(fn=_ui_respond, type="messages")

# Expose the FastAPI app for anyone importing this module (and for local `uvicorn app:app`).
app = api

if __name__ == "__main__":
    # LET GRADIO OWN THE SERVER.
    #
    # The obvious construction -- gr.mount_gradio_app(fastapi_app, demo) plus our own
    # uvicorn.run() -- does not survive on a Gradio SDK Space. The platform launches the
    # Blocks app itself, so our uvicorn came up as a redundant second server and was
    # SIGTERM'd about one second after binding ("Uvicorn running ..." immediately
    # followed by "Shutting down"). Binding the port the platform wanted instead failed
    # with "[Errno 98] address already in use", because the platform's own launch had
    # already taken it. Both failures are the same underlying mistake: competing for the
    # server rather than joining it.
    #
    # So Gradio launches (which is also what ZeroGPU expects), and the OpenAI routes are
    # grafted onto the FastAPI app Gradio builds. Starlette matches each request against
    # a plain list of routes, so inserting ahead of Gradio's catch-all is enough to make
    # /v1/* resolve to us while everything else stays Gradio's.
    demo.queue(max_size=32)
    _, local_url, _ = demo.launch(prevent_thread_lock=True, server_name="0.0.0.0",
                                  ssr_mode=False, show_api=False)
    print(f"[thox] gradio launched at {local_url}", flush=True)

    grafted = []
    for route in api.routes:
        path = getattr(route, "path", "")
        if path.startswith("/v1") or path == "/health":
            demo.app.router.routes.insert(0, route)
            grafted.append(path)
    print(f"[thox] grafted OpenAI routes onto gradio app: {grafted}", flush=True)
    thox_auth.install(demo.app)

    # launch(prevent_thread_lock=True) returns immediately; block so PID 1 stays alive.
    demo.block_thread()