Hy3 / core /chat.py
ericsqin's picture
add hy3
ad51766
Raw
History Blame Contribute Delete
18.4 kB
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