Spaces:
Running
Running
Delete core
Browse files- core/__init__.py +0 -23
- core/__pycache__/__init__.cpython-39.pyc +0 -0
- core/__pycache__/chat.cpython-39.pyc +0 -0
- core/chat.py +0 -463
core/__init__.py
DELETED
|
@@ -1,23 +0,0 @@
|
|
| 1 |
-
"""Pure-Python core: conversation state + streaming logic, no Gradio."""
|
| 2 |
-
|
| 3 |
-
from .chat import (
|
| 4 |
-
ChatState,
|
| 5 |
-
init_state,
|
| 6 |
-
build_messages_for_api,
|
| 7 |
-
build_api_kwargs,
|
| 8 |
-
stream_response,
|
| 9 |
-
finalize_response,
|
| 10 |
-
record_tool_result,
|
| 11 |
-
flush_tool_results,
|
| 12 |
-
)
|
| 13 |
-
|
| 14 |
-
__all__ = [
|
| 15 |
-
"ChatState",
|
| 16 |
-
"init_state",
|
| 17 |
-
"build_messages_for_api",
|
| 18 |
-
"build_api_kwargs",
|
| 19 |
-
"stream_response",
|
| 20 |
-
"finalize_response",
|
| 21 |
-
"record_tool_result",
|
| 22 |
-
"flush_tool_results",
|
| 23 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core/__pycache__/__init__.cpython-39.pyc
DELETED
|
Binary file (472 Bytes)
|
|
|
core/__pycache__/chat.cpython-39.pyc
DELETED
|
Binary file (12.1 kB)
|
|
|
core/chat.py
DELETED
|
@@ -1,463 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import itertools
|
| 4 |
-
import logging
|
| 5 |
-
import queue
|
| 6 |
-
import threading
|
| 7 |
-
import time
|
| 8 |
-
from typing import Any, Callable, Iterator, Optional, TypedDict
|
| 9 |
-
|
| 10 |
-
from config import MODEL, client
|
| 11 |
-
from tools import build_tools_list
|
| 12 |
-
|
| 13 |
-
logger = logging.getLogger(__name__)
|
| 14 |
-
|
| 15 |
-
# Monotonically-increasing epoch ids stamped on every per-session
|
| 16 |
-
# ``ChatState`` and bumped on reset. Streaming handlers capture the
|
| 17 |
-
# epoch at entry and check it between yields; if the user clicks "+"
|
| 18 |
-
# (``new_chat``) mid-stream, ``reset_state_in_place`` mutates the SAME
|
| 19 |
-
# state dict the running generator holds — bumping the epoch — so the
|
| 20 |
-
# generator notices on its next iteration and exits without emitting
|
| 21 |
-
# any further chat deltas.
|
| 22 |
-
_epoch_counter = itertools.count(1)
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def _next_epoch() -> int:
|
| 26 |
-
return next(_epoch_counter)
|
| 27 |
-
|
| 28 |
-
# Floor interval between streaming yields. The per-yield cost on the
|
| 29 |
-
# wire is just a small delta payload, so 60ms ≈ 16 yields/sec — close
|
| 30 |
-
# to one yield per browser frame, which is the natural ceiling for
|
| 31 |
-
# human-perceptible smoothness anyway.
|
| 32 |
-
_YIELD_INTERVAL = 0.06
|
| 33 |
-
|
| 34 |
-
# How often to emit a keep-alive yield when no new chunks arrive. This keeps
|
| 35 |
-
# the SSE/WebSocket connection alive through reverse-proxy idle timeouts
|
| 36 |
-
# (e.g. HuggingFace Spaces proxy). Without heartbeats, a long pause between
|
| 37 |
-
# reasoning and content chunks can cause the proxy to drop the connection,
|
| 38 |
-
# silently terminating the generator.
|
| 39 |
-
_HEARTBEAT_INTERVAL = 5.0
|
| 40 |
-
|
| 41 |
-
# Sentinel placed in the chunk queue when the streaming thread finishes.
|
| 42 |
-
_STREAM_DONE = object()
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def _drain_queue(q: queue.Queue) -> list:
|
| 46 |
-
"""Pull every item currently in *q* without blocking. Empty list if none."""
|
| 47 |
-
out: list = []
|
| 48 |
-
while True:
|
| 49 |
-
try:
|
| 50 |
-
out.append(q.get_nowait())
|
| 51 |
-
except queue.Empty:
|
| 52 |
-
return out
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
class ChatState(TypedDict, total=False):
|
| 56 |
-
messages: list[dict]
|
| 57 |
-
context_start_index: int
|
| 58 |
-
pending_tool_calls: list[dict]
|
| 59 |
-
pending_assistant_msg: Optional[dict]
|
| 60 |
-
submitted_tool_results: list[dict]
|
| 61 |
-
epoch: int
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def init_state() -> ChatState:
|
| 65 |
-
"""Fresh per-session conversation state.
|
| 66 |
-
|
| 67 |
-
Note: there is intentionally NO server-side ``is_streaming`` flag. The
|
| 68 |
-
"model is busy" signal is owned entirely by the UI: a click on Send
|
| 69 |
-
instantly disables the Send button via a ``queue=False`` Gradio chain
|
| 70 |
-
BEFORE the streaming generator is even queued, so a duplicate submission
|
| 71 |
-
is impossible regardless of network latency or queue order.
|
| 72 |
-
|
| 73 |
-
``epoch`` is the cancellation token: bumped by ``reset_state_in_place``
|
| 74 |
-
when the user clicks "+" mid-stream so the running generator can
|
| 75 |
-
detect the reset and abandon further yields.
|
| 76 |
-
"""
|
| 77 |
-
return {
|
| 78 |
-
"messages": [],
|
| 79 |
-
"context_start_index": 0,
|
| 80 |
-
"pending_tool_calls": [],
|
| 81 |
-
"pending_assistant_msg": None,
|
| 82 |
-
"submitted_tool_results": [],
|
| 83 |
-
"epoch": _next_epoch(),
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def reset_state_in_place(state: ChatState) -> int:
|
| 88 |
-
"""Reset *state* in place and bump its epoch. Returns the new epoch.
|
| 89 |
-
|
| 90 |
-
Critical: this MUTATES the caller's dict instead of returning a fresh
|
| 91 |
-
one. A streaming generator started before the reset still holds a
|
| 92 |
-
reference to this same dict — the in-place mutation is what lets it
|
| 93 |
-
observe the bumped epoch and stop yielding chat deltas. Returning a
|
| 94 |
-
new dict (and asking Gradio to swap it into the State component)
|
| 95 |
-
would leave the in-flight generator pointed at a stale dict it would
|
| 96 |
-
happily keep streaming into.
|
| 97 |
-
"""
|
| 98 |
-
state["messages"] = []
|
| 99 |
-
state["context_start_index"] = 0
|
| 100 |
-
state["pending_tool_calls"] = []
|
| 101 |
-
state["pending_assistant_msg"] = None
|
| 102 |
-
state["submitted_tool_results"] = []
|
| 103 |
-
state["epoch"] = _next_epoch()
|
| 104 |
-
return state["epoch"]
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def get_context_messages(state: ChatState) -> list[dict]:
|
| 108 |
-
return state["messages"][state["context_start_index"]:]
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def build_messages_for_api(state: ChatState, system_prompt: str) -> list[dict]:
|
| 112 |
-
context = get_context_messages(state)
|
| 113 |
-
if system_prompt and system_prompt.strip():
|
| 114 |
-
return [{"role": "system", "content": system_prompt.strip()}] + context
|
| 115 |
-
return list(context)
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def build_api_kwargs(
|
| 119 |
-
state: ChatState,
|
| 120 |
-
system_prompt: str,
|
| 121 |
-
functions_json_str: Optional[str],
|
| 122 |
-
think_level: Optional[str],
|
| 123 |
-
temperature: Optional[float],
|
| 124 |
-
max_tokens: Optional[int],
|
| 125 |
-
top_p: Optional[float],
|
| 126 |
-
preserved_thinking: Optional[bool] = None,
|
| 127 |
-
) -> dict:
|
| 128 |
-
"""Build the kwargs dict passed to ``client.chat.completions.create``.
|
| 129 |
-
|
| 130 |
-
Each knob is omitted from the request entirely when "unset" so the
|
| 131 |
-
server applies its own default — but the meaning of "unset" differs:
|
| 132 |
-
|
| 133 |
-
* ``temperature`` is tristate: ``None`` means unset (omit the field),
|
| 134 |
-
while any float — including ``0``, which selects greedy decoding —
|
| 135 |
-
is sent literally. The UI exposes this via a "Use model default"
|
| 136 |
-
checkbox sitting next to the slider; the headless ``api_chat``
|
| 137 |
-
surface uses ``temperature=None`` as its default.
|
| 138 |
-
* ``max_tokens`` and ``top_p`` collapse "unset" and ``0`` into a
|
| 139 |
-
single sentinel: a value of ``0`` (or ``None``) is treated as unset
|
| 140 |
-
and the field is omitted. They have no UI checkbox because explicit
|
| 141 |
-
``0`` for either knob is not a useful operating point.
|
| 142 |
-
* ``preserved_thinking`` is a tristate boolean like ``temperature``:
|
| 143 |
-
``None`` means unset (omit the field), while ``True`` / ``False``
|
| 144 |
-
are sent literally. It is a non-standard extension, so it rides in
|
| 145 |
-
``extra_body`` rather than as a top-level kwarg (the OpenAI SDK
|
| 146 |
-
would reject an unknown top-level argument). The UI exposes it via
|
| 147 |
-
a "Use model default" checkbox next to an on/off toggle.
|
| 148 |
-
"""
|
| 149 |
-
api_messages = build_messages_for_api(state, system_prompt)
|
| 150 |
-
tools = build_tools_list(functions_json_str)
|
| 151 |
-
|
| 152 |
-
kwargs: dict = dict(
|
| 153 |
-
model=MODEL,
|
| 154 |
-
messages=api_messages,
|
| 155 |
-
stream=True,
|
| 156 |
-
reasoning_effort=think_level or "no_think",
|
| 157 |
-
)
|
| 158 |
-
if max_tokens is not None and int(max_tokens) != 0:
|
| 159 |
-
kwargs["max_tokens"] = int(max_tokens)
|
| 160 |
-
if temperature is not None:
|
| 161 |
-
kwargs["temperature"] = float(temperature)
|
| 162 |
-
if top_p is not None and float(top_p) != 0:
|
| 163 |
-
kwargs["top_p"] = float(top_p)
|
| 164 |
-
if preserved_thinking is not None:
|
| 165 |
-
kwargs["extra_body"] = {"preserved_thinking": bool(preserved_thinking)}
|
| 166 |
-
if tools:
|
| 167 |
-
kwargs["tools"] = tools
|
| 168 |
-
return kwargs
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def _accumulate_tool_call(tool_calls_acc: list[dict], delta_tcs: list[Any]) -> None:
|
| 172 |
-
"""Merge streamed tool-call deltas into the accumulator."""
|
| 173 |
-
for tc in delta_tcs:
|
| 174 |
-
idx = getattr(tc, "index", 0) or 0
|
| 175 |
-
while len(tool_calls_acc) <= idx:
|
| 176 |
-
tool_calls_acc.append(
|
| 177 |
-
{"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
|
| 178 |
-
)
|
| 179 |
-
if tc.id:
|
| 180 |
-
tool_calls_acc[idx]["id"] = tc.id
|
| 181 |
-
if tc.function:
|
| 182 |
-
if tc.function.name:
|
| 183 |
-
tool_calls_acc[idx]["function"]["name"] += tc.function.name
|
| 184 |
-
if tc.function.arguments:
|
| 185 |
-
tool_calls_acc[idx]["function"]["arguments"] += tc.function.arguments
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
def _stream_worker(
|
| 189 |
-
kwargs: dict,
|
| 190 |
-
chunk_queue: queue.Queue,
|
| 191 |
-
) -> None:
|
| 192 |
-
"""Background thread: run the API call and feed chunks into *chunk_queue*."""
|
| 193 |
-
try:
|
| 194 |
-
stream = client.chat.completions.create(**kwargs)
|
| 195 |
-
for chunk in stream:
|
| 196 |
-
chunk_queue.put(chunk)
|
| 197 |
-
except Exception as exc:
|
| 198 |
-
chunk_queue.put(exc)
|
| 199 |
-
finally:
|
| 200 |
-
chunk_queue.put(_STREAM_DONE)
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
# Hard ceilings: if no chunk has arrived for this long AND the worker thread
|
| 204 |
-
# hasn't terminated, we abandon the stream so the UI lock can release. With a
|
| 205 |
-
# healthy heartbeat the worker normally posts STREAM_DONE within seconds of
|
| 206 |
-
# the model finishing, but reverse proxies / network blips can occasionally
|
| 207 |
-
# leave the SSE connection in a half-open state that hangs ``for chunk in
|
| 208 |
-
# stream`` indefinitely. Capping the wait guarantees ``send_message`` always
|
| 209 |
-
# reaches its final yield (and therefore re-enables the Send button).
|
| 210 |
-
#
|
| 211 |
-
# Two separate ceilings because the two phases have very different shapes:
|
| 212 |
-
# * Before the first chunk the model may be doing reasoning / queueing /
|
| 213 |
-
# KV-cache warmup, so we allow a generous 30s first-token budget.
|
| 214 |
-
# * Once tokens are flowing we expect them to keep flowing; a 15s gap with
|
| 215 |
-
# nothing arriving (and no STREAM_DONE) almost certainly means the SSE
|
| 216 |
-
# socket is dead.
|
| 217 |
-
_FIRST_CHUNK_TIMEOUT = 60.0
|
| 218 |
-
_INTER_CHUNK_TIMEOUT = 15.0
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
# Op type constants — keep in sync with static/chat.js.
|
| 222 |
-
OP_REASONING_DELTA = "reasoning_delta"
|
| 223 |
-
OP_CONTENT_DELTA = "content_delta"
|
| 224 |
-
OP_TOOL_CALLS = "tool_calls"
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
def stream_response(
|
| 228 |
-
kwargs: dict,
|
| 229 |
-
is_cancelled: Optional[Callable[[], bool]] = None,
|
| 230 |
-
) -> Iterator[tuple[list[dict], str, str, list[dict], str]]:
|
| 231 |
-
"""Stream chunks from the API and yield delta-op batches.
|
| 232 |
-
|
| 233 |
-
The actual HTTP stream runs in a daemon thread so that the generator can
|
| 234 |
-
emit keep-alive yields during API-side pauses (model thinking, network
|
| 235 |
-
hiccups, etc.). Without these heartbeats the SSE connection between the
|
| 236 |
-
browser and a reverse proxy (e.g. HuggingFace Spaces) may be dropped
|
| 237 |
-
for inactivity, silently killing the generator mid-response.
|
| 238 |
-
|
| 239 |
-
Drain coalescing
|
| 240 |
-
----------------
|
| 241 |
-
Each iteration drains EVERY chunk currently buffered into a single batch
|
| 242 |
-
and emits one yield reflecting the merged deltas. Under back-pressure the
|
| 243 |
-
yield rate naturally collapses (more chunks per yield) without losing
|
| 244 |
-
data — the deltas accumulate in ``pending_*`` strings until the next
|
| 245 |
-
successful yield can drain them.
|
| 246 |
-
|
| 247 |
-
Two early-exit paths protect the UI from getting stuck:
|
| 248 |
-
|
| 249 |
-
* As soon as we see ``finish_reason`` we drain whatever is already in
|
| 250 |
-
the queue without blocking, then break. The model has logically
|
| 251 |
-
finished; waiting on the SSE socket close would only lengthen the
|
| 252 |
-
visible "stuck" window.
|
| 253 |
-
* Two timeout safety nets force a break if the stream stalls while
|
| 254 |
-
the worker is still technically alive.
|
| 255 |
-
|
| 256 |
-
Yields ``(ops, assistant_total, reasoning_total, tool_calls, request_id)``
|
| 257 |
-
where ``ops`` is the list of delta dicts since the previous yield.
|
| 258 |
-
Heartbeat yields produce an empty ``ops`` list — callers should treat
|
| 259 |
-
that as "no new content but the stream is still healthy".
|
| 260 |
-
"""
|
| 261 |
-
assistant_content = ""
|
| 262 |
-
reasoning_content = ""
|
| 263 |
-
tool_calls_acc: list[dict] = []
|
| 264 |
-
request_id = ""
|
| 265 |
-
|
| 266 |
-
# Pending-since-last-yield deltas. Persist across drain iterations so
|
| 267 |
-
# a throttle-suppressed yield doesn't lose the chars; the next yield
|
| 268 |
-
# picks them up.
|
| 269 |
-
pending_reasoning = ""
|
| 270 |
-
pending_content = ""
|
| 271 |
-
tool_calls_dirty = False
|
| 272 |
-
|
| 273 |
-
chunk_q: queue.Queue = queue.Queue()
|
| 274 |
-
worker = threading.Thread(
|
| 275 |
-
target=_stream_worker, args=(kwargs, chunk_q), daemon=True,
|
| 276 |
-
)
|
| 277 |
-
worker.start()
|
| 278 |
-
|
| 279 |
-
saw_finish_reason = False
|
| 280 |
-
|
| 281 |
-
def take_ops() -> list[dict]:
|
| 282 |
-
"""Drain pending deltas into an ops list; return [] if nothing pending."""
|
| 283 |
-
nonlocal pending_reasoning, pending_content, tool_calls_dirty
|
| 284 |
-
ops: list[dict] = []
|
| 285 |
-
if pending_reasoning:
|
| 286 |
-
ops.append({"type": OP_REASONING_DELTA, "delta": pending_reasoning})
|
| 287 |
-
pending_reasoning = ""
|
| 288 |
-
if pending_content:
|
| 289 |
-
ops.append({"type": OP_CONTENT_DELTA, "delta": pending_content})
|
| 290 |
-
pending_content = ""
|
| 291 |
-
if tool_calls_dirty:
|
| 292 |
-
ops.append({"type": OP_TOOL_CALLS, "tool_calls": list(tool_calls_acc)})
|
| 293 |
-
tool_calls_dirty = False
|
| 294 |
-
return ops
|
| 295 |
-
|
| 296 |
-
def apply_chunk(chunk) -> bool:
|
| 297 |
-
"""Fold a single API chunk into the accumulators.
|
| 298 |
-
|
| 299 |
-
Returns True when this chunk produced visible-state changes
|
| 300 |
-
(content, reasoning, or tool-call deltas). Sets the outer
|
| 301 |
-
``saw_finish_reason`` / ``request_id`` as a side effect.
|
| 302 |
-
"""
|
| 303 |
-
nonlocal request_id, reasoning_content, assistant_content
|
| 304 |
-
nonlocal pending_reasoning, pending_content, tool_calls_dirty
|
| 305 |
-
nonlocal saw_finish_reason
|
| 306 |
-
if not request_id and getattr(chunk, "id", None):
|
| 307 |
-
request_id = chunk.id
|
| 308 |
-
if not chunk.choices:
|
| 309 |
-
return False
|
| 310 |
-
choice = chunk.choices[0]
|
| 311 |
-
delta = choice.delta
|
| 312 |
-
if getattr(choice, "finish_reason", None):
|
| 313 |
-
saw_finish_reason = True
|
| 314 |
-
|
| 315 |
-
changed = False
|
| 316 |
-
rc = getattr(delta, "reasoning_content", None)
|
| 317 |
-
if rc:
|
| 318 |
-
reasoning_content += rc
|
| 319 |
-
pending_reasoning += rc
|
| 320 |
-
changed = True
|
| 321 |
-
if delta.content:
|
| 322 |
-
assistant_content += delta.content
|
| 323 |
-
pending_content += delta.content
|
| 324 |
-
changed = True
|
| 325 |
-
if getattr(delta, "tool_calls", None):
|
| 326 |
-
_accumulate_tool_call(tool_calls_acc, delta.tool_calls)
|
| 327 |
-
tool_calls_dirty = True
|
| 328 |
-
changed = True
|
| 329 |
-
return changed
|
| 330 |
-
|
| 331 |
-
last_yield_at = 0.0
|
| 332 |
-
last_chunk_at = time.monotonic()
|
| 333 |
-
got_first_chunk = False
|
| 334 |
-
yielded = False
|
| 335 |
-
done = False
|
| 336 |
-
|
| 337 |
-
while not done:
|
| 338 |
-
# Cancellation check — caller (e.g. ``new_chat``) bumped the
|
| 339 |
-
# session epoch, so abandon the stream WITHOUT a final yield.
|
| 340 |
-
# The worker thread keeps running until the upstream API closes
|
| 341 |
-
# the connection, but its chunks pile harmlessly into the
|
| 342 |
-
# garbage-collected queue once we return.
|
| 343 |
-
if is_cancelled is not None and is_cancelled():
|
| 344 |
-
logger.debug("stream cancelled by caller, abandoning")
|
| 345 |
-
return
|
| 346 |
-
|
| 347 |
-
# ── block for the next item, with heartbeat / stall guards ──
|
| 348 |
-
try:
|
| 349 |
-
first = chunk_q.get(timeout=_HEARTBEAT_INTERVAL)
|
| 350 |
-
except queue.Empty:
|
| 351 |
-
if not worker.is_alive() and chunk_q.empty():
|
| 352 |
-
break
|
| 353 |
-
stall_budget = (
|
| 354 |
-
_INTER_CHUNK_TIMEOUT if got_first_chunk else _FIRST_CHUNK_TIMEOUT
|
| 355 |
-
)
|
| 356 |
-
if time.monotonic() - last_chunk_at > stall_budget:
|
| 357 |
-
logger.warning(
|
| 358 |
-
"stream stalled %.1fs with no chunks (%s), abandoning",
|
| 359 |
-
stall_budget,
|
| 360 |
-
"inter-chunk" if got_first_chunk else "first-chunk",
|
| 361 |
-
)
|
| 362 |
-
break
|
| 363 |
-
# Heartbeat: re-emit current state with empty ops so Gradio
|
| 364 |
-
# ships an SSE frame and the upstream proxy doesn't consider
|
| 365 |
-
# the channel idle. The empty-ops frame is ~70 bytes and the
|
| 366 |
-
# client treats it as a noop.
|
| 367 |
-
yield [], assistant_content, reasoning_content, tool_calls_acc, request_id
|
| 368 |
-
yielded = True
|
| 369 |
-
last_yield_at = time.monotonic()
|
| 370 |
-
continue
|
| 371 |
-
|
| 372 |
-
# ── coalesce: pull every chunk currently buffered ──
|
| 373 |
-
batch = [first] + _drain_queue(chunk_q)
|
| 374 |
-
|
| 375 |
-
for item in batch:
|
| 376 |
-
if item is _STREAM_DONE:
|
| 377 |
-
done = True
|
| 378 |
-
continue
|
| 379 |
-
if isinstance(item, Exception):
|
| 380 |
-
raise item
|
| 381 |
-
last_chunk_at = time.monotonic()
|
| 382 |
-
got_first_chunk = True
|
| 383 |
-
apply_chunk(item)
|
| 384 |
-
|
| 385 |
-
# ── one throttled yield per drained batch ──
|
| 386 |
-
# Force-emit on done / finish so the final state always ships.
|
| 387 |
-
if pending_reasoning or pending_content or tool_calls_dirty:
|
| 388 |
-
now = time.monotonic()
|
| 389 |
-
if done or saw_finish_reason or now - last_yield_at >= _YIELD_INTERVAL:
|
| 390 |
-
ops = take_ops()
|
| 391 |
-
yield ops, assistant_content, reasoning_content, tool_calls_acc, request_id
|
| 392 |
-
yielded = True
|
| 393 |
-
last_yield_at = now
|
| 394 |
-
|
| 395 |
-
# ── finish_reason fast-exit ──
|
| 396 |
-
# Model has logically finished. Drain anything still buffered and
|
| 397 |
-
# exit. Don't wait on the SSE socket close.
|
| 398 |
-
if saw_finish_reason and not done:
|
| 399 |
-
for item in _drain_queue(chunk_q):
|
| 400 |
-
if item is _STREAM_DONE:
|
| 401 |
-
break
|
| 402 |
-
if isinstance(item, Exception):
|
| 403 |
-
raise item
|
| 404 |
-
apply_chunk(item)
|
| 405 |
-
ops = take_ops()
|
| 406 |
-
if ops:
|
| 407 |
-
yield ops, assistant_content, reasoning_content, tool_calls_acc, request_id
|
| 408 |
-
yielded = True
|
| 409 |
-
break
|
| 410 |
-
|
| 411 |
-
# Final flush — guarantee callers always observe terminal accumulator
|
| 412 |
-
# values, even when every prior content yield was suppressed by the
|
| 413 |
-
# throttle (e.g. a tiny response that finished within the floor).
|
| 414 |
-
ops = take_ops()
|
| 415 |
-
if ops or not yielded:
|
| 416 |
-
yield ops, assistant_content, reasoning_content, tool_calls_acc, request_id
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
def finalize_response(
|
| 420 |
-
state: ChatState,
|
| 421 |
-
assistant_content: str,
|
| 422 |
-
reasoning_content: str,
|
| 423 |
-
tool_calls_acc: list[dict],
|
| 424 |
-
) -> tuple[bool, list[dict]]:
|
| 425 |
-
"""Persist the final assistant message into ``state``.
|
| 426 |
-
|
| 427 |
-
Returns ``(has_pending_tool_calls, pending_tool_calls)``. The Gradio
|
| 428 |
-
adapter is responsible for turning ``pending_tool_calls`` into UI
|
| 429 |
-
updates (see ``chat.py``).
|
| 430 |
-
"""
|
| 431 |
-
assistant_msg: dict = {"role": "assistant", "content": assistant_content or None}
|
| 432 |
-
if reasoning_content:
|
| 433 |
-
assistant_msg["reasoning_content"] = reasoning_content
|
| 434 |
-
|
| 435 |
-
if tool_calls_acc:
|
| 436 |
-
assistant_msg["tool_calls"] = tool_calls_acc
|
| 437 |
-
state["messages"].append(assistant_msg)
|
| 438 |
-
state["pending_tool_calls"] = list(tool_calls_acc)
|
| 439 |
-
state["submitted_tool_results"] = []
|
| 440 |
-
state["pending_assistant_msg"] = assistant_msg
|
| 441 |
-
logger.debug("queued %d tool call(s)", len(tool_calls_acc))
|
| 442 |
-
return True, list(tool_calls_acc)
|
| 443 |
-
|
| 444 |
-
state["messages"].append(assistant_msg)
|
| 445 |
-
return False, []
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
def record_tool_result(state: ChatState, tool_call: Any, result_text: str) -> None:
|
| 449 |
-
"""Record a single tool-call result in the pending queue."""
|
| 450 |
-
tc_id = tool_call["id"] if isinstance(tool_call, dict) else tool_call.id
|
| 451 |
-
state.setdefault("submitted_tool_results", []).append({
|
| 452 |
-
"role": "tool",
|
| 453 |
-
"tool_call_id": tc_id,
|
| 454 |
-
"content": result_text or "",
|
| 455 |
-
})
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
def flush_tool_results(state: ChatState) -> None:
|
| 459 |
-
"""Move queued tool results into the main message log."""
|
| 460 |
-
for msg in state.get("submitted_tool_results", []):
|
| 461 |
-
state["messages"].append(msg)
|
| 462 |
-
state["submitted_tool_results"] = []
|
| 463 |
-
state["pending_assistant_msg"] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|