File size: 18,361 Bytes
ad51766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import itertools
import logging
import queue
import threading
import time
from typing import Any, Callable, Iterator, Optional, TypedDict

from config import MODEL, client
from tools import build_tools_list

logger = logging.getLogger(__name__)

# Monotonically-increasing epoch ids stamped on every per-session
# ``ChatState`` and bumped on reset. Streaming handlers capture the
# epoch at entry and check it between yields; if the user clicks "+"
# (``new_chat``) mid-stream, ``reset_state_in_place`` mutates the SAME
# state dict the running generator holds — bumping the epoch — so the
# generator notices on its next iteration and exits without emitting
# any further chat deltas.
_epoch_counter = itertools.count(1)


def _next_epoch() -> int:
    return next(_epoch_counter)

# Floor interval between streaming yields. The per-yield cost on the
# wire is just a small delta payload, so 60ms ≈ 16 yields/sec — close
# to one yield per browser frame, which is the natural ceiling for
# human-perceptible smoothness anyway.
_YIELD_INTERVAL = 0.06

# How often to emit a keep-alive yield when no new chunks arrive. This keeps
# the SSE/WebSocket connection alive through reverse-proxy idle timeouts
# (e.g. HuggingFace Spaces proxy). Without heartbeats, a long pause between
# reasoning and content chunks can cause the proxy to drop the connection,
# silently terminating the generator.
_HEARTBEAT_INTERVAL = 5.0

# Sentinel placed in the chunk queue when the streaming thread finishes.
_STREAM_DONE = object()


def _drain_queue(q: queue.Queue) -> list:
    """Pull every item currently in *q* without blocking. Empty list if none."""
    out: list = []
    while True:
        try:
            out.append(q.get_nowait())
        except queue.Empty:
            return out


class ChatState(TypedDict, total=False):
    messages: list[dict]
    context_start_index: int
    pending_tool_calls: list[dict]
    pending_assistant_msg: Optional[dict]
    submitted_tool_results: list[dict]
    epoch: int


def init_state() -> ChatState:
    """Fresh per-session conversation state.

    Note: there is intentionally NO server-side ``is_streaming`` flag. The
    "model is busy" signal is owned entirely by the UI: a click on Send
    instantly disables the Send button via a ``queue=False`` Gradio chain
    BEFORE the streaming generator is even queued, so a duplicate submission
    is impossible regardless of network latency or queue order.

    ``epoch`` is the cancellation token: bumped by ``reset_state_in_place``
    when the user clicks "+" mid-stream so the running generator can
    detect the reset and abandon further yields.
    """
    return {
        "messages": [],
        "context_start_index": 0,
        "pending_tool_calls": [],
        "pending_assistant_msg": None,
        "submitted_tool_results": [],
        "epoch": _next_epoch(),
    }


def reset_state_in_place(state: ChatState) -> int:
    """Reset *state* in place and bump its epoch. Returns the new epoch.

    Critical: this MUTATES the caller's dict instead of returning a fresh
    one. A streaming generator started before the reset still holds a
    reference to this same dict — the in-place mutation is what lets it
    observe the bumped epoch and stop yielding chat deltas. Returning a
    new dict (and asking Gradio to swap it into the State component)
    would leave the in-flight generator pointed at a stale dict it would
    happily keep streaming into.
    """
    state["messages"] = []
    state["context_start_index"] = 0
    state["pending_tool_calls"] = []
    state["pending_assistant_msg"] = None
    state["submitted_tool_results"] = []
    state["epoch"] = _next_epoch()
    return state["epoch"]


def get_context_messages(state: ChatState) -> list[dict]:
    return state["messages"][state["context_start_index"]:]


def build_messages_for_api(state: ChatState, system_prompt: str) -> list[dict]:
    context = get_context_messages(state)
    if system_prompt and system_prompt.strip():
        return [{"role": "system", "content": system_prompt.strip()}] + context
    return list(context)


def build_api_kwargs(
    state: ChatState,
    system_prompt: str,
    functions_json_str: Optional[str],
    think_level: Optional[str],
    temperature: Optional[float],
    max_tokens: Optional[int],
    top_p: Optional[float],
    preserved_thinking: Optional[bool] = None,
) -> dict:
    """Build the kwargs dict passed to ``client.chat.completions.create``.

    Each knob is omitted from the request entirely when "unset" so the
    server applies its own default — but the meaning of "unset" differs:

    * ``temperature`` is tristate: ``None`` means unset (omit the field),
      while any float — including ``0``, which selects greedy decoding —
      is sent literally. The UI exposes this via a "Use model default"
      checkbox sitting next to the slider; the headless ``api_chat``
      surface uses ``temperature=None`` as its default.
    * ``max_tokens`` and ``top_p`` collapse "unset" and ``0`` into a
      single sentinel: a value of ``0`` (or ``None``) is treated as unset
      and the field is omitted. They have no UI checkbox because explicit
      ``0`` for either knob is not a useful operating point.
    * ``preserved_thinking`` is a tristate boolean like ``temperature``:
      ``None`` means unset (omit the field), while ``True`` / ``False``
      are sent literally. It is a non-standard extension, so it rides in
      ``extra_body`` rather than as a top-level kwarg (the OpenAI SDK
      would reject an unknown top-level argument). The UI exposes it via
      a "Use model default" checkbox next to an on/off toggle.
    """
    api_messages = build_messages_for_api(state, system_prompt)
    tools = build_tools_list(functions_json_str)

    kwargs: dict = dict(
        model=MODEL,
        messages=api_messages,
        stream=True,
        reasoning_effort=think_level or "no_think",
    )
    if max_tokens is not None and int(max_tokens) != 0:
        kwargs["max_tokens"] = int(max_tokens)
    if temperature is not None:
        kwargs["temperature"] = float(temperature)
    if top_p is not None and float(top_p) != 0:
        kwargs["top_p"] = float(top_p)
    if preserved_thinking is not None:
        kwargs["extra_body"] = {"preserved_thinking": bool(preserved_thinking)}
    if tools:
        kwargs["tools"] = tools
    return kwargs


def _accumulate_tool_call(tool_calls_acc: list[dict], delta_tcs: list[Any]) -> None:
    """Merge streamed tool-call deltas into the accumulator."""
    for tc in delta_tcs:
        idx = getattr(tc, "index", 0) or 0
        while len(tool_calls_acc) <= idx:
            tool_calls_acc.append(
                {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
            )
        if tc.id:
            tool_calls_acc[idx]["id"] = tc.id
        if tc.function:
            if tc.function.name:
                tool_calls_acc[idx]["function"]["name"] += tc.function.name
            if tc.function.arguments:
                tool_calls_acc[idx]["function"]["arguments"] += tc.function.arguments


def _stream_worker(
    kwargs: dict,
    chunk_queue: queue.Queue,
) -> None:
    """Background thread: run the API call and feed chunks into *chunk_queue*."""
    try:
        stream = client.chat.completions.create(**kwargs)
        for chunk in stream:
            chunk_queue.put(chunk)
    except Exception as exc:
        chunk_queue.put(exc)
    finally:
        chunk_queue.put(_STREAM_DONE)


# Hard ceilings: if no chunk has arrived for this long AND the worker thread
# hasn't terminated, we abandon the stream so the UI lock can release. With a
# healthy heartbeat the worker normally posts STREAM_DONE within seconds of
# the model finishing, but reverse proxies / network blips can occasionally
# leave the SSE connection in a half-open state that hangs ``for chunk in
# stream`` indefinitely. Capping the wait guarantees ``send_message`` always
# reaches its final yield (and therefore re-enables the Send button).
#
# Two separate ceilings because the two phases have very different shapes:
#   * Before the first chunk the model may be doing reasoning / queueing /
#     KV-cache warmup, so we allow a generous 30s first-token budget.
#   * Once tokens are flowing we expect them to keep flowing; a 15s gap with
#     nothing arriving (and no STREAM_DONE) almost certainly means the SSE
#     socket is dead.
_FIRST_CHUNK_TIMEOUT = 60.0
_INTER_CHUNK_TIMEOUT = 15.0


# Op type constants — keep in sync with static/chat.js.
OP_REASONING_DELTA = "reasoning_delta"
OP_CONTENT_DELTA = "content_delta"
OP_TOOL_CALLS = "tool_calls"


def stream_response(
    kwargs: dict,
    is_cancelled: Optional[Callable[[], bool]] = None,
) -> Iterator[tuple[list[dict], str, str, list[dict], str]]:
    """Stream chunks from the API and yield delta-op batches.

    The actual HTTP stream runs in a daemon thread so that the generator can
    emit keep-alive yields during API-side pauses (model thinking, network
    hiccups, etc.). Without these heartbeats the SSE connection between the
    browser and a reverse proxy (e.g. HuggingFace Spaces) may be dropped
    for inactivity, silently killing the generator mid-response.

    Drain coalescing
    ----------------
    Each iteration drains EVERY chunk currently buffered into a single batch
    and emits one yield reflecting the merged deltas. Under back-pressure the
    yield rate naturally collapses (more chunks per yield) without losing
    data — the deltas accumulate in ``pending_*`` strings until the next
    successful yield can drain them.

    Two early-exit paths protect the UI from getting stuck:

    * As soon as we see ``finish_reason`` we drain whatever is already in
      the queue without blocking, then break. The model has logically
      finished; waiting on the SSE socket close would only lengthen the
      visible "stuck" window.
    * Two timeout safety nets force a break if the stream stalls while
      the worker is still technically alive.

    Yields ``(ops, assistant_total, reasoning_total, tool_calls, request_id)``
    where ``ops`` is the list of delta dicts since the previous yield.
    Heartbeat yields produce an empty ``ops`` list — callers should treat
    that as "no new content but the stream is still healthy".
    """
    assistant_content = ""
    reasoning_content = ""
    tool_calls_acc: list[dict] = []
    request_id = ""

    # Pending-since-last-yield deltas. Persist across drain iterations so
    # a throttle-suppressed yield doesn't lose the chars; the next yield
    # picks them up.
    pending_reasoning = ""
    pending_content = ""
    tool_calls_dirty = False

    chunk_q: queue.Queue = queue.Queue()
    worker = threading.Thread(
        target=_stream_worker, args=(kwargs, chunk_q), daemon=True,
    )
    worker.start()

    saw_finish_reason = False

    def take_ops() -> list[dict]:
        """Drain pending deltas into an ops list; return [] if nothing pending."""
        nonlocal pending_reasoning, pending_content, tool_calls_dirty
        ops: list[dict] = []
        if pending_reasoning:
            ops.append({"type": OP_REASONING_DELTA, "delta": pending_reasoning})
            pending_reasoning = ""
        if pending_content:
            ops.append({"type": OP_CONTENT_DELTA, "delta": pending_content})
            pending_content = ""
        if tool_calls_dirty:
            ops.append({"type": OP_TOOL_CALLS, "tool_calls": list(tool_calls_acc)})
            tool_calls_dirty = False
        return ops

    def apply_chunk(chunk) -> bool:
        """Fold a single API chunk into the accumulators.

        Returns True when this chunk produced visible-state changes
        (content, reasoning, or tool-call deltas). Sets the outer
        ``saw_finish_reason`` / ``request_id`` as a side effect.
        """
        nonlocal request_id, reasoning_content, assistant_content
        nonlocal pending_reasoning, pending_content, tool_calls_dirty
        nonlocal saw_finish_reason
        if not request_id and getattr(chunk, "id", None):
            request_id = chunk.id
        if not chunk.choices:
            return False
        choice = chunk.choices[0]
        delta = choice.delta
        if getattr(choice, "finish_reason", None):
            saw_finish_reason = True

        changed = False
        rc = getattr(delta, "reasoning_content", None)
        if rc:
            reasoning_content += rc
            pending_reasoning += rc
            changed = True
        if delta.content:
            assistant_content += delta.content
            pending_content += delta.content
            changed = True
        if getattr(delta, "tool_calls", None):
            _accumulate_tool_call(tool_calls_acc, delta.tool_calls)
            tool_calls_dirty = True
            changed = True
        return changed

    last_yield_at = 0.0
    last_chunk_at = time.monotonic()
    got_first_chunk = False
    yielded = False
    done = False

    while not done:
        # Cancellation check — caller (e.g. ``new_chat``) bumped the
        # session epoch, so abandon the stream WITHOUT a final yield.
        # The worker thread keeps running until the upstream API closes
        # the connection, but its chunks pile harmlessly into the
        # garbage-collected queue once we return.
        if is_cancelled is not None and is_cancelled():
            logger.debug("stream cancelled by caller, abandoning")
            return

        # ── block for the next item, with heartbeat / stall guards ──
        try:
            first = chunk_q.get(timeout=_HEARTBEAT_INTERVAL)
        except queue.Empty:
            if not worker.is_alive() and chunk_q.empty():
                break
            stall_budget = (
                _INTER_CHUNK_TIMEOUT if got_first_chunk else _FIRST_CHUNK_TIMEOUT
            )
            if time.monotonic() - last_chunk_at > stall_budget:
                logger.warning(
                    "stream stalled %.1fs with no chunks (%s), abandoning",
                    stall_budget,
                    "inter-chunk" if got_first_chunk else "first-chunk",
                )
                break
            # Heartbeat: re-emit current state with empty ops so Gradio
            # ships an SSE frame and the upstream proxy doesn't consider
            # the channel idle. The empty-ops frame is ~70 bytes and the
            # client treats it as a noop.
            yield [], assistant_content, reasoning_content, tool_calls_acc, request_id
            yielded = True
            last_yield_at = time.monotonic()
            continue

        # ── coalesce: pull every chunk currently buffered ──
        batch = [first] + _drain_queue(chunk_q)

        for item in batch:
            if item is _STREAM_DONE:
                done = True
                continue
            if isinstance(item, Exception):
                raise item
            last_chunk_at = time.monotonic()
            got_first_chunk = True
            apply_chunk(item)

        # ── one throttled yield per drained batch ──
        # Force-emit on done / finish so the final state always ships.
        if pending_reasoning or pending_content or tool_calls_dirty:
            now = time.monotonic()
            if done or saw_finish_reason or now - last_yield_at >= _YIELD_INTERVAL:
                ops = take_ops()
                yield ops, assistant_content, reasoning_content, tool_calls_acc, request_id
                yielded = True
                last_yield_at = now

        # ── finish_reason fast-exit ──
        # Model has logically finished. Drain anything still buffered and
        # exit. Don't wait on the SSE socket close.
        if saw_finish_reason and not done:
            for item in _drain_queue(chunk_q):
                if item is _STREAM_DONE:
                    break
                if isinstance(item, Exception):
                    raise item
                apply_chunk(item)
            ops = take_ops()
            if ops:
                yield ops, assistant_content, reasoning_content, tool_calls_acc, request_id
                yielded = True
            break

    # Final flush — guarantee callers always observe terminal accumulator
    # values, even when every prior content yield was suppressed by the
    # throttle (e.g. a tiny response that finished within the floor).
    ops = take_ops()
    if ops or not yielded:
        yield ops, assistant_content, reasoning_content, tool_calls_acc, request_id


def finalize_response(
    state: ChatState,
    assistant_content: str,
    reasoning_content: str,
    tool_calls_acc: list[dict],
) -> tuple[bool, list[dict]]:
    """Persist the final assistant message into ``state``.

    Returns ``(has_pending_tool_calls, pending_tool_calls)``. The Gradio
    adapter is responsible for turning ``pending_tool_calls`` into UI
    updates (see ``chat.py``).
    """
    assistant_msg: dict = {"role": "assistant", "content": assistant_content or None}
    if reasoning_content:
        assistant_msg["reasoning_content"] = reasoning_content

    if tool_calls_acc:
        assistant_msg["tool_calls"] = tool_calls_acc
        state["messages"].append(assistant_msg)
        state["pending_tool_calls"] = list(tool_calls_acc)
        state["submitted_tool_results"] = []
        state["pending_assistant_msg"] = assistant_msg
        logger.debug("queued %d tool call(s)", len(tool_calls_acc))
        return True, list(tool_calls_acc)

    state["messages"].append(assistant_msg)
    return False, []


def record_tool_result(state: ChatState, tool_call: Any, result_text: str) -> None:
    """Record a single tool-call result in the pending queue."""
    tc_id = tool_call["id"] if isinstance(tool_call, dict) else tool_call.id
    state.setdefault("submitted_tool_results", []).append({
        "role": "tool",
        "tool_call_id": tc_id,
        "content": result_text or "",
    })


def flush_tool_results(state: ChatState) -> None:
    """Move queued tool results into the main message log."""
    for msg in state.get("submitted_tool_results", []):
        state["messages"].append(msg)
    state["submitted_tool_results"] = []
    state["pending_assistant_msg"] = None