File size: 7,553 Bytes
ddcbb6b
 
 
69efd3e
ddcbb6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69efd3e
 
ddcbb6b
 
 
 
 
 
2aed3b3
 
 
ddcbb6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2aed3b3
 
 
 
 
 
 
 
69efd3e
 
2aed3b3
 
 
 
 
 
 
ddcbb6b
 
 
 
 
 
2aed3b3
ddcbb6b
 
 
 
 
 
 
 
 
 
 
 
 
2aed3b3
ddcbb6b
 
 
 
 
2aed3b3
 
 
 
ddcbb6b
2aed3b3
ddcbb6b
 
 
 
 
 
 
 
2aed3b3
 
 
 
 
 
 
32c3dcb
2aed3b3
 
 
 
 
ddcbb6b
 
69efd3e
 
 
 
 
 
 
 
 
ddcbb6b
69efd3e
ddcbb6b
 
69efd3e
ddcbb6b
 
2aed3b3
ddcbb6b
 
2aed3b3
ddcbb6b
 
2aed3b3
ddcbb6b
 
 
 
 
 
 
 
 
 
 
2aed3b3
ddcbb6b
 
 
 
 
2aed3b3
ddcbb6b
 
 
 
 
 
 
 
 
 
2aed3b3
ddcbb6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Ornith-1.0-9B — a gradio.Server chat app.

This Space demonstrates `deepreinforce-ai/Ornith-1.0-9B`, an agentic coding
(qwen3.5-based) reasoning model, served through `gradio.Server`.

`gradio.Server` extends FastAPI and adds Gradio's API engine on top
(queuing, concurrency control, SSE streaming, gradio_client compatibility).
Instead of Gradio's component UI we ship a custom HTML/CSS/JS chat page
(see `index.html`) served at `GET /`, while the model inference is exposed
as a streaming `@app.api()` endpoint at `/gradio_api/call/generate`.

Run locally (needs a CUDA GPU + transformers >= 5.8.1):
    python app.py
On a Hugging Face ZeroGPU Space, `@spaces.GPU` handles device allocation.
"""

import json
import os
from threading import Thread
from typing import Optional

import torch
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    TextIteratorStreamer,
)

from fastapi.responses import HTMLResponse
from gradio import Server

# `spaces` is only available on Hugging Face Spaces. Guard the import so the
# file still parses/imports on a plain machine (the GPU decorator becomes a
# no-op locally).
try:
    import spaces  # type: ignore

    GPU = spaces.GPU
except ImportError:  # local, non-Spaces environment
    def GPU(*args, **kwargs):
        def decorator(fn):
            return fn

        return decorator


MODEL_ID = "deepreinforce-ai/Ornith-1.0-9B"

# Recommended sampling for Ornith (see model card): temp=0.6, top_p=0.95,
# top_k=20. temp=1.0 reproduces the reported benchmark setup.
DEFAULT_TEMPERATURE = 0.6
DEFAULT_MAX_NEW_TOKENS = 2048

# Ornith is a reasoning model: the assistant turn opens with a chain-of-
# thought block before the final answer. We split the two so the UI can
# render the reasoning separately from the answer. The delimiters are read
# from the tokenizer config at runtime (Qwen3-style reasoning markers, as
# Ornith ships a qwen3 reasoning parser) rather than hardcoded, so the
# raw special-token bytes never need to live in source.
THINK_OPEN = None   # resolved lazily from tokenizer config in _split_think
THINK_CLOSE = None  # resolved lazily from tokenizer config in _split_think


# --------------------------------------------------------------------------- #

# --------------------------------------------------------------------------- #
# Model loading: at module level on cuda (per ZeroGPU docs)
# --------------------------------------------------------------------------- #
# ZeroGPU runs in PyTorch CUDA-emulation mode outside @spaces.GPU functions,
# so a real GPU is unavailable at import time. We load the model at startup
# and call .to("cuda") once - the CUDA emulation handles that safely. Real
# CUDA then becomes available inside the @spaces.GPU-decorated function.
print(f"[ornith] loading {MODEL_ID} ...", flush=True)
_tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
_model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    torch_dtype=torch.bfloat16,
    low_cpu_mem_usage=True,
)
_model.to("cuda")
_model.eval()
print("[ornith] model ready on cuda", flush=True)


def _resolve_markers():
    open_tok = None
    close_tok = None
    try:
        added = getattr(_tokenizer, "added_tokens_decoder", {}) or {}
        for entry in added.values():
            content = getattr(entry, "content", None)
            special = getattr(entry, "special", False)
            if not (special and content):
                continue
            low = content.lower()
            if "think" in low and not content.startswith("</"):
                open_tok = content
            elif "think" in low and content.startswith("</"):
                close_tok = content
    except Exception:
        pass
    if not open_tok or not close_tok:
        # Qwen3 defaults (resolved from char codes so they survive this toolchain).
        open_tok = open_tok or "".join(chr(c) for c in (60, 116, 104, 105, 110, 107, 62))
        close_tok = close_tok or "".join(chr(c) for c in (60, 47, 116, 104, 105, 110, 107, 62))
    return open_tok, close_tok


def _split_think(text, open_tok, close_tok):
    if close_tok and close_tok in text:
        head, _, tail = text.partition(close_tok)
        reasoning = head.replace(open_tok or "", "").strip()
        return reasoning, tail.strip()
    return text.replace(open_tok or "", "").strip(), ""


# --------------------------------------------------------------------------- #
# App + streaming endpoint
# --------------------------------------------------------------------------- #
app = Server()


@app.api(name="generate", concurrency_limit=1, stream_every=0.5)
@GPU(duration=300)
def generate(
    message: str,
    history: list = None,
    max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS,
    temperature: float = DEFAULT_TEMPERATURE,
) -> str:
    # Stream an Ornith-1.0-9B completion; yields JSON strings with
    # {reasoning, answer, status, error}.
    if history is None:
        history = []
    open_tok, close_tok = _resolve_markers()

    try:
        messages = []
        for turn in history:
            role = turn.get("role")
            content = turn.get("content")
            if role in ("system", "user", "assistant") and content:
                messages.append({"role": role, "content": content})
        messages.append({"role": "user", "content": message or ""})

        prompt = _tokenizer.apply_chat_template(
            messages,
            tokenize=False,
            add_generation_prompt=True,
        )
        inputs = _tokenizer(prompt, return_tensors="pt").to("cuda")

        streamer = TextIteratorStreamer(
            _tokenizer,
            skip_prompt=True,
            skip_special_tokens=True,
            timeout=120.0,
        )

        pad_token_id = getattr(_tokenizer, "eos_token_id", None)
        gen_kwargs = dict(
            **inputs,
            streamer=streamer,
            max_new_tokens=int(max_new_tokens),
            do_sample=True,
            temperature=float(temperature),
            top_p=0.95,
            top_k=20,
            pad_token_id=pad_token_id,
        )

        thread = Thread(target=_model.generate, kwargs=gen_kwargs)
        thread.start()

        accumulated = ""
        for text in streamer:
            accumulated += text
            reasoning, answer = _split_think(accumulated, open_tok, close_tok)
            yield json.dumps(
                {
                    "reasoning": reasoning,
                    "answer": answer,
                    "status": "generating",
                    "error": None,
                }
            )

        thread.join()
        reasoning, answer = _split_think(accumulated, open_tok, close_tok)
        yield json.dumps(
            {
                "reasoning": reasoning,
                "answer": answer,
                "status": "complete",
                "error": None,
            }
        )
    except Exception as exc:  # noqa: BLE001 - surface any failure to the UI
        yield json.dumps(
            {
                "reasoning": "",
                "answer": "",
                "status": "error",
                "error": str(exc),
            }
        )


@app.get("/", response_class=HTMLResponse)
async def homepage():
    """Serve the custom chat frontend; this overrides Gradio's default UI."""
    html_path = os.path.join(
        os.path.dirname(os.path.abspath(__file__)), "index.html"
    )
    with open(html_path, "r", encoding="utf-8") as f:
        return f.read()


if __name__ == "__main__":
    app.launch(show_error=True)