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