Spaces:
Sleeping
Sleeping
Commit ·
e49928e
1
Parent(s): a9a3f80
added changes
Browse files- app.py +46 -3
- config.py +39 -1
- src/clients.py +105 -12
app.py
CHANGED
|
@@ -10,6 +10,7 @@ Prompts/intros may contain {VARIABLE} tokens — those are surfaced as fill-in f
|
|
| 10 |
in the sidebar and substituted into the effective prompt when a session starts.
|
| 11 |
"""
|
| 12 |
|
|
|
|
| 13 |
import queue
|
| 14 |
import re
|
| 15 |
import threading
|
|
@@ -102,6 +103,8 @@ def format_metrics(m: dict) -> str:
|
|
| 102 |
if m.get("error"):
|
| 103 |
return f"⚠️ {m['error']}"
|
| 104 |
parts = []
|
|
|
|
|
|
|
| 105 |
if m.get("prompt_tokens") is not None:
|
| 106 |
parts.append(f"In: {m['prompt_tokens']}")
|
| 107 |
if m.get("completion_tokens") is not None:
|
|
@@ -113,6 +116,28 @@ def format_metrics(m: dict) -> str:
|
|
| 113 |
return " · ".join(parts)
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def backend_messages(system_prompt: str, conversation: list) -> list:
|
| 117 |
"""Build the request messages for one backend: system + its own thread."""
|
| 118 |
msgs = []
|
|
@@ -186,7 +211,7 @@ def respond_all(user_msg, state, temperature, max_tokens):
|
|
| 186 |
if not user_msg or not user_msg.strip():
|
| 187 |
chatbots = [render_history(histories[k]) for k in KEYS]
|
| 188 |
metrics = [gr.update() for _ in KEYS]
|
| 189 |
-
yield (state, *chatbots, *metrics, "", None)
|
| 190 |
return
|
| 191 |
|
| 192 |
user_msg = user_msg.strip()
|
|
@@ -199,6 +224,7 @@ def respond_all(user_msg, state, temperature, max_tokens):
|
|
| 199 |
|
| 200 |
acc = {k: "" for k in KEYS}
|
| 201 |
mstate = {k: {} for k in KEYS}
|
|
|
|
| 202 |
q: queue.Queue = queue.Queue()
|
| 203 |
|
| 204 |
def worker(key):
|
|
@@ -207,6 +233,8 @@ def respond_all(user_msg, state, temperature, max_tokens):
|
|
| 207 |
for item in stream_backend(backend, req_msgs[key], temperature, max_tokens):
|
| 208 |
if isinstance(item, dict) and item.get("__metrics__"):
|
| 209 |
q.put((key, "metrics", item))
|
|
|
|
|
|
|
| 210 |
else:
|
| 211 |
q.put((key, "delta", item))
|
| 212 |
finally:
|
|
@@ -222,7 +250,8 @@ def respond_all(user_msg, state, temperature, max_tokens):
|
|
| 222 |
histories[k][-1]["content"] = acc[k]
|
| 223 |
chatbots.append(render_history(histories[k]))
|
| 224 |
metrics = [format_metrics(mstate[k]) for k in KEYS]
|
| 225 |
-
|
|
|
|
| 226 |
|
| 227 |
remaining = set(KEYS)
|
| 228 |
yield snapshot()
|
|
@@ -236,6 +265,8 @@ def respond_all(user_msg, state, temperature, max_tokens):
|
|
| 236 |
acc[key] += payload
|
| 237 |
elif kind == "metrics":
|
| 238 |
mstate[key] = payload
|
|
|
|
|
|
|
| 239 |
elif kind == "done":
|
| 240 |
remaining.discard(key)
|
| 241 |
yield snapshot()
|
|
@@ -266,6 +297,7 @@ def respond_all(user_msg, state, temperature, max_tokens):
|
|
| 266 |
"total_tokens",
|
| 267 |
"cached_tokens",
|
| 268 |
"latency_s",
|
|
|
|
| 269 |
)
|
| 270 |
},
|
| 271 |
"error": mstate[k].get("error"),
|
|
@@ -443,6 +475,17 @@ with gr.Blocks(
|
|
| 443 |
with gr.Row():
|
| 444 |
send_btn = gr.Button("Send", variant="primary")
|
| 445 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
# Pick the best response for this turn and save it to the dataset.
|
| 447 |
with gr.Group(visible=SHOW_COMPARE):
|
| 448 |
gr.Markdown("### 💾 Save preferred response")
|
|
@@ -458,7 +501,7 @@ with gr.Blocks(
|
|
| 458 |
|
| 459 |
# Wiring -----------------------------------------------------------------
|
| 460 |
respond_inputs = [msg, state, temperature, max_tokens]
|
| 461 |
-
respond_outputs = [state, *chatbots, *metrics, msg, preferred]
|
| 462 |
|
| 463 |
preset_outputs = [system_prompt, intro, var_names, *var_boxes]
|
| 464 |
|
|
|
|
| 10 |
in the sidebar and substituted into the effective prompt when a session starts.
|
| 11 |
"""
|
| 12 |
|
| 13 |
+
import json
|
| 14 |
import queue
|
| 15 |
import re
|
| 16 |
import threading
|
|
|
|
| 103 |
if m.get("error"):
|
| 104 |
return f"⚠️ {m['error']}"
|
| 105 |
parts = []
|
| 106 |
+
if m.get("tool_called"):
|
| 107 |
+
parts.append(f"📞 tool: `{m['tool_called']}`")
|
| 108 |
if m.get("prompt_tokens") is not None:
|
| 109 |
parts.append(f"In: {m['prompt_tokens']}")
|
| 110 |
if m.get("completion_tokens") is not None:
|
|
|
|
| 116 |
return " · ".join(parts)
|
| 117 |
|
| 118 |
|
| 119 |
+
def format_debug(acc: dict, mstate: dict, rstate: dict) -> str:
|
| 120 |
+
"""Render the exact request + model response(s) for the current turn as JSON.
|
| 121 |
+
|
| 122 |
+
Shows, per backend: the exact request body sent to the model, the
|
| 123 |
+
live-streamed text, and — once the turn completes — the raw assistant
|
| 124 |
+
message the model produced (`content` + any `tool_calls` with verbatim
|
| 125 |
+
arguments), so both sides of the exchange are visible exactly as sent.
|
| 126 |
+
"""
|
| 127 |
+
payload = {}
|
| 128 |
+
for k in KEYS:
|
| 129 |
+
m = mstate.get(k) or {}
|
| 130 |
+
payload[ANON[k]] = {
|
| 131 |
+
"label": BY_KEY[k]["label"],
|
| 132 |
+
"request": rstate.get(k),
|
| 133 |
+
"streamed_text": acc.get(k, ""),
|
| 134 |
+
"raw_response": m.get("raw_response"),
|
| 135 |
+
"tool_called": m.get("tool_called"),
|
| 136 |
+
"error": m.get("error"),
|
| 137 |
+
}
|
| 138 |
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
def backend_messages(system_prompt: str, conversation: list) -> list:
|
| 142 |
"""Build the request messages for one backend: system + its own thread."""
|
| 143 |
msgs = []
|
|
|
|
| 211 |
if not user_msg or not user_msg.strip():
|
| 212 |
chatbots = [render_history(histories[k]) for k in KEYS]
|
| 213 |
metrics = [gr.update() for _ in KEYS]
|
| 214 |
+
yield (state, *chatbots, *metrics, "", None, gr.update())
|
| 215 |
return
|
| 216 |
|
| 217 |
user_msg = user_msg.strip()
|
|
|
|
| 224 |
|
| 225 |
acc = {k: "" for k in KEYS}
|
| 226 |
mstate = {k: {} for k in KEYS}
|
| 227 |
+
rstate = {k: None for k in KEYS} # exact request payload sent per backend
|
| 228 |
q: queue.Queue = queue.Queue()
|
| 229 |
|
| 230 |
def worker(key):
|
|
|
|
| 233 |
for item in stream_backend(backend, req_msgs[key], temperature, max_tokens):
|
| 234 |
if isinstance(item, dict) and item.get("__metrics__"):
|
| 235 |
q.put((key, "metrics", item))
|
| 236 |
+
elif isinstance(item, dict) and item.get("__request__"):
|
| 237 |
+
q.put((key, "request", item.get("payload")))
|
| 238 |
else:
|
| 239 |
q.put((key, "delta", item))
|
| 240 |
finally:
|
|
|
|
| 250 |
histories[k][-1]["content"] = acc[k]
|
| 251 |
chatbots.append(render_history(histories[k]))
|
| 252 |
metrics = [format_metrics(mstate[k]) for k in KEYS]
|
| 253 |
+
debug = format_debug(acc, mstate, rstate)
|
| 254 |
+
return (state, *chatbots, *metrics, "", None, debug)
|
| 255 |
|
| 256 |
remaining = set(KEYS)
|
| 257 |
yield snapshot()
|
|
|
|
| 265 |
acc[key] += payload
|
| 266 |
elif kind == "metrics":
|
| 267 |
mstate[key] = payload
|
| 268 |
+
elif kind == "request":
|
| 269 |
+
rstate[key] = payload
|
| 270 |
elif kind == "done":
|
| 271 |
remaining.discard(key)
|
| 272 |
yield snapshot()
|
|
|
|
| 297 |
"total_tokens",
|
| 298 |
"cached_tokens",
|
| 299 |
"latency_s",
|
| 300 |
+
"tool_called",
|
| 301 |
)
|
| 302 |
},
|
| 303 |
"error": mstate[k].get("error"),
|
|
|
|
| 475 |
with gr.Row():
|
| 476 |
send_btn = gr.Button("Send", variant="primary")
|
| 477 |
|
| 478 |
+
# Debug: the exact request body sent and the raw response the model
|
| 479 |
+
# produced this turn (text + any tool calls with verbatim
|
| 480 |
+
# arguments), per backend. Collapsed by default.
|
| 481 |
+
with gr.Accordion("🐞 Debug — exact request & response", open=False):
|
| 482 |
+
debug_box = gr.Code(
|
| 483 |
+
label="Exact request & raw response (per backend)",
|
| 484 |
+
language="json",
|
| 485 |
+
value="",
|
| 486 |
+
interactive=False,
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
# Pick the best response for this turn and save it to the dataset.
|
| 490 |
with gr.Group(visible=SHOW_COMPARE):
|
| 491 |
gr.Markdown("### 💾 Save preferred response")
|
|
|
|
| 501 |
|
| 502 |
# Wiring -----------------------------------------------------------------
|
| 503 |
respond_inputs = [msg, state, temperature, max_tokens]
|
| 504 |
+
respond_outputs = [state, *chatbots, *metrics, msg, preferred, debug_box]
|
| 505 |
|
| 506 |
preset_outputs = [system_prompt, intro, var_names, *var_boxes]
|
| 507 |
|
config.py
CHANGED
|
@@ -119,6 +119,42 @@ DEFAULT_MAX_TOKENS = 1000
|
|
| 119 |
DEFAULT_STREAM = True
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
# ---------------------------------------------------------------------------
|
| 123 |
# Preset system-prompt / intro pairs people can pick to try the playground.
|
| 124 |
# The first entry is the default loaded on startup. Each preset declares a fixed
|
|
@@ -403,7 +439,9 @@ def get_auth():
|
|
| 403 |
pairs.append((user, pwd))
|
| 404 |
password = os.environ.get("APP_PASSWORD", "").strip()
|
| 405 |
if password:
|
| 406 |
-
pairs.append(
|
|
|
|
|
|
|
| 407 |
if not pairs:
|
| 408 |
# Dev fallback — override via env in any shared deployment.
|
| 409 |
pairs.append(("user", "slm-demo"))
|
|
|
|
| 119 |
DEFAULT_STREAM = True
|
| 120 |
|
| 121 |
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Tools exposed to the conversational backend (Qwen, Backend A). The preset
|
| 124 |
+
# system prompts instruct the model to emit `end_call` when the conversation
|
| 125 |
+
# reaches its natural conclusion, placing the goodbye line in `final_message`.
|
| 126 |
+
# The tool is sent in the request body (OpenAI-style `tools` array) so the
|
| 127 |
+
# model can actually call it. Only the custom backend receives these tools.
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
END_CALL_TOOL = {
|
| 130 |
+
"type": "function",
|
| 131 |
+
"function": {
|
| 132 |
+
"name": "end_call",
|
| 133 |
+
"description": (
|
| 134 |
+
"End the current call when the conversation has reached a natural "
|
| 135 |
+
"conclusion or user says bye or tells to cut the call or speak with "
|
| 136 |
+
"you later as they are busy."
|
| 137 |
+
),
|
| 138 |
+
"parameters": {
|
| 139 |
+
"type": "object",
|
| 140 |
+
"properties": {
|
| 141 |
+
"final_message": {
|
| 142 |
+
"type": "string",
|
| 143 |
+
"description": (
|
| 144 |
+
"The final message to say to the user before ending the "
|
| 145 |
+
"call. Keep it short and less than 15 words."
|
| 146 |
+
),
|
| 147 |
+
}
|
| 148 |
+
},
|
| 149 |
+
"required": ["final_message"],
|
| 150 |
+
},
|
| 151 |
+
},
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
# Tools sent to the custom (Qwen) backend on every request.
|
| 155 |
+
TOOLS = [END_CALL_TOOL]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
# ---------------------------------------------------------------------------
|
| 159 |
# Preset system-prompt / intro pairs people can pick to try the playground.
|
| 160 |
# The first entry is the default loaded on startup. Each preset declares a fixed
|
|
|
|
| 439 |
pairs.append((user, pwd))
|
| 440 |
password = os.environ.get("APP_PASSWORD", "").strip()
|
| 441 |
if password:
|
| 442 |
+
pairs.append(
|
| 443 |
+
(os.environ.get("APP_USERNAME", "user").strip() or "user", password)
|
| 444 |
+
)
|
| 445 |
if not pairs:
|
| 446 |
# Dev fallback — override via env in any shared deployment.
|
| 447 |
pairs.append(("user", "slm-demo"))
|
src/clients.py
CHANGED
|
@@ -73,6 +73,13 @@ def _empty_metrics(**overrides) -> dict:
|
|
| 73 |
"total_tokens": None,
|
| 74 |
"cached_tokens": None,
|
| 75 |
"latency_s": None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
"error": None,
|
| 77 |
}
|
| 78 |
base.update(overrides)
|
|
@@ -125,8 +132,14 @@ def chat_completion(
|
|
| 125 |
endpoint_id: str,
|
| 126 |
temperature: float,
|
| 127 |
max_tokens: int,
|
|
|
|
| 128 |
) -> tuple:
|
| 129 |
-
"""Return (content, usage) from a non-streaming chat completion.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
payload = {
|
| 131 |
"messages": messages,
|
| 132 |
"model": model,
|
|
@@ -135,6 +148,8 @@ def chat_completion(
|
|
| 135 |
"max_tokens": int(max_tokens),
|
| 136 |
"stream": False,
|
| 137 |
}
|
|
|
|
|
|
|
| 138 |
headers = {"Content-Type": "application/json", "x-api-key": get_api_key()}
|
| 139 |
resp = requests.post(config.BASE_URL, headers=headers, json=payload, timeout=120)
|
| 140 |
if resp.status_code != 200:
|
|
@@ -143,20 +158,47 @@ def chat_completion(
|
|
| 143 |
choices = data.get("choices") or []
|
| 144 |
content = ""
|
| 145 |
if choices:
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
| 147 |
usage = data.get("usage") or {}
|
| 148 |
return content, usage
|
| 149 |
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
def stream_chat(
|
| 152 |
messages: list,
|
| 153 |
model: str,
|
| 154 |
endpoint_id: str,
|
| 155 |
temperature: float,
|
| 156 |
max_tokens: int,
|
|
|
|
| 157 |
):
|
| 158 |
"""Yield response text deltas, then a final metrics sentinel.
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
Token counts come from a lightweight max_tokens=1 probe fired AFTER the
|
| 161 |
stream (so it never competes with streaming for GPU); completion tokens are
|
| 162 |
the streamed-piece count.
|
|
@@ -169,11 +211,20 @@ def stream_chat(
|
|
| 169 |
"max_tokens": int(max_tokens),
|
| 170 |
"stream": True,
|
| 171 |
}
|
|
|
|
|
|
|
| 172 |
headers = {"Content-Type": "application/json", "x-api-key": get_api_key()}
|
| 173 |
|
|
|
|
|
|
|
|
|
|
| 174 |
t_start = time.monotonic()
|
| 175 |
piece_count = 0
|
| 176 |
ttfb = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
with requests.post(
|
| 178 |
config.BASE_URL, headers=headers, json=payload, stream=True, timeout=120
|
| 179 |
) as resp:
|
|
@@ -201,12 +252,35 @@ def stream_chat(
|
|
| 201 |
piece = delta.get("content")
|
| 202 |
if piece:
|
| 203 |
piece_count += 1
|
|
|
|
| 204 |
yield piece
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
# Probe for token counts now that streaming is done.
|
| 207 |
try:
|
| 208 |
_, probe = chat_completion(
|
| 209 |
-
messages, model, endpoint_id, temperature, max_tokens=1
|
| 210 |
)
|
| 211 |
except Exception:
|
| 212 |
probe = {}
|
|
@@ -215,18 +289,32 @@ def stream_chat(
|
|
| 215 |
cached_tokens = details.get("cached_tokens")
|
| 216 |
total = (prompt_tokens + piece_count) if prompt_tokens is not None else None
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
yield _empty_metrics(
|
| 219 |
prompt_tokens=prompt_tokens,
|
| 220 |
completion_tokens=piece_count,
|
| 221 |
total_tokens=total,
|
| 222 |
cached_tokens=cached_tokens,
|
| 223 |
latency_s=ttfb,
|
|
|
|
|
|
|
| 224 |
)
|
| 225 |
|
| 226 |
|
| 227 |
def _stream_custom(backend, messages, temperature, max_tokens):
|
|
|
|
| 228 |
yield from stream_chat(
|
| 229 |
-
messages,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
)
|
| 231 |
|
| 232 |
|
|
@@ -255,14 +343,17 @@ def _stream_azure(backend, messages, temperature, max_tokens):
|
|
| 255 |
t_start = time.monotonic()
|
| 256 |
usage = None
|
| 257 |
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
| 266 |
for chunk in stream:
|
| 267 |
if getattr(chunk, "usage", None):
|
| 268 |
usage = chunk.usage
|
|
@@ -272,6 +363,7 @@ def _stream_azure(backend, messages, temperature, max_tokens):
|
|
| 272 |
delta = choices[0].delta
|
| 273 |
piece = getattr(delta, "content", None) if delta else None
|
| 274 |
if piece:
|
|
|
|
| 275 |
yield piece
|
| 276 |
|
| 277 |
prompt_tokens = completion_tokens = total_tokens = cached_tokens = None
|
|
@@ -289,6 +381,7 @@ def _stream_azure(backend, messages, temperature, max_tokens):
|
|
| 289 |
total_tokens=total_tokens,
|
| 290 |
cached_tokens=cached_tokens,
|
| 291 |
latency_s=round(time.monotonic() - t_start, 3),
|
|
|
|
| 292 |
)
|
| 293 |
|
| 294 |
|
|
|
|
| 73 |
"total_tokens": None,
|
| 74 |
"cached_tokens": None,
|
| 75 |
"latency_s": None,
|
| 76 |
+
# Name of the tool the model invoked this turn (e.g. "end_call"), or
|
| 77 |
+
# None when the reply was ordinary text. Lets the UI/logs flag tool use.
|
| 78 |
+
"tool_called": None,
|
| 79 |
+
# The exact assistant message the model produced this turn, as an
|
| 80 |
+
# OpenAI-style {"content", "tool_calls"} dict (raw tool arguments kept
|
| 81 |
+
# verbatim). Powers the debug panel; None on error.
|
| 82 |
+
"raw_response": None,
|
| 83 |
"error": None,
|
| 84 |
}
|
| 85 |
base.update(overrides)
|
|
|
|
| 132 |
endpoint_id: str,
|
| 133 |
temperature: float,
|
| 134 |
max_tokens: int,
|
| 135 |
+
tools: list | None = None,
|
| 136 |
) -> tuple:
|
| 137 |
+
"""Return (content, usage) from a non-streaming chat completion.
|
| 138 |
+
|
| 139 |
+
When `tools` is provided it is sent in the body so the model may call a
|
| 140 |
+
tool. If the model responds with an `end_call` tool call instead of plain
|
| 141 |
+
text, its `final_message` argument is returned as the content.
|
| 142 |
+
"""
|
| 143 |
payload = {
|
| 144 |
"messages": messages,
|
| 145 |
"model": model,
|
|
|
|
| 148 |
"max_tokens": int(max_tokens),
|
| 149 |
"stream": False,
|
| 150 |
}
|
| 151 |
+
if tools:
|
| 152 |
+
payload["tools"] = tools
|
| 153 |
headers = {"Content-Type": "application/json", "x-api-key": get_api_key()}
|
| 154 |
resp = requests.post(config.BASE_URL, headers=headers, json=payload, timeout=120)
|
| 155 |
if resp.status_code != 200:
|
|
|
|
| 158 |
choices = data.get("choices") or []
|
| 159 |
content = ""
|
| 160 |
if choices:
|
| 161 |
+
message = choices[0].get("message") or {}
|
| 162 |
+
content = message.get("content") or ""
|
| 163 |
+
if not content:
|
| 164 |
+
content = _extract_end_call_message(message.get("tool_calls"))
|
| 165 |
usage = data.get("usage") or {}
|
| 166 |
return content, usage
|
| 167 |
|
| 168 |
|
| 169 |
+
def _extract_end_call_message(tool_calls) -> str:
|
| 170 |
+
"""Return the `final_message` from an `end_call` tool call, or "".
|
| 171 |
+
|
| 172 |
+
Accepts the non-streaming `tool_calls` list from a chat message. Other tool
|
| 173 |
+
calls (or malformed arguments) yield an empty string.
|
| 174 |
+
"""
|
| 175 |
+
for call in tool_calls or []:
|
| 176 |
+
fn = call.get("function") or {}
|
| 177 |
+
if fn.get("name") != "end_call":
|
| 178 |
+
continue
|
| 179 |
+
try:
|
| 180 |
+
args = json.loads(fn.get("arguments") or "{}")
|
| 181 |
+
except json.JSONDecodeError:
|
| 182 |
+
return ""
|
| 183 |
+
return (args.get("final_message") or "").strip()
|
| 184 |
+
return ""
|
| 185 |
+
|
| 186 |
+
|
| 187 |
def stream_chat(
|
| 188 |
messages: list,
|
| 189 |
model: str,
|
| 190 |
endpoint_id: str,
|
| 191 |
temperature: float,
|
| 192 |
max_tokens: int,
|
| 193 |
+
tools: list | None = None,
|
| 194 |
):
|
| 195 |
"""Yield response text deltas, then a final metrics sentinel.
|
| 196 |
|
| 197 |
+
When `tools` is provided it is sent in the body so the model may call a
|
| 198 |
+
tool. A streamed `end_call` tool call has no text content, so its
|
| 199 |
+
`final_message` argument (assembled from the streamed argument fragments)
|
| 200 |
+
is yielded as the reply once the stream ends.
|
| 201 |
+
|
| 202 |
Token counts come from a lightweight max_tokens=1 probe fired AFTER the
|
| 203 |
stream (so it never competes with streaming for GPU); completion tokens are
|
| 204 |
the streamed-piece count.
|
|
|
|
| 211 |
"max_tokens": int(max_tokens),
|
| 212 |
"stream": True,
|
| 213 |
}
|
| 214 |
+
if tools:
|
| 215 |
+
payload["tools"] = tools
|
| 216 |
headers = {"Content-Type": "application/json", "x-api-key": get_api_key()}
|
| 217 |
|
| 218 |
+
# Surface the exact request body (no secret header) for the debug panel.
|
| 219 |
+
yield {"__request__": True, "payload": payload}
|
| 220 |
+
|
| 221 |
t_start = time.monotonic()
|
| 222 |
piece_count = 0
|
| 223 |
ttfb = 0
|
| 224 |
+
tool_args = "" # concatenated `end_call` argument fragments
|
| 225 |
+
tool_name = None # name of the tool the model invoked, if any
|
| 226 |
+
content_buf = "" # raw text the model streamed (before any tool handling)
|
| 227 |
+
saw_end_call = False
|
| 228 |
with requests.post(
|
| 229 |
config.BASE_URL, headers=headers, json=payload, stream=True, timeout=120
|
| 230 |
) as resp:
|
|
|
|
| 252 |
piece = delta.get("content")
|
| 253 |
if piece:
|
| 254 |
piece_count += 1
|
| 255 |
+
content_buf += piece
|
| 256 |
yield piece
|
| 257 |
+
# An `end_call` tool call streams as `tool_calls` deltas whose
|
| 258 |
+
# `arguments` strings must be concatenated, then parsed at the end.
|
| 259 |
+
for call in delta.get("tool_calls") or []:
|
| 260 |
+
fn = call.get("function") or {}
|
| 261 |
+
if fn.get("name"):
|
| 262 |
+
tool_name = fn["name"]
|
| 263 |
+
if tool_name == "end_call":
|
| 264 |
+
saw_end_call = True
|
| 265 |
+
frag = fn.get("arguments")
|
| 266 |
+
if frag:
|
| 267 |
+
saw_end_call = True
|
| 268 |
+
tool_args += frag
|
| 269 |
+
|
| 270 |
+
# If the model ended the call via the tool, surface the goodbye line.
|
| 271 |
+
if saw_end_call:
|
| 272 |
+
try:
|
| 273 |
+
final_message = (json.loads(tool_args or "{}").get("final_message") or "").strip()
|
| 274 |
+
except json.JSONDecodeError:
|
| 275 |
+
final_message = ""
|
| 276 |
+
if final_message:
|
| 277 |
+
piece_count += 1
|
| 278 |
+
yield final_message
|
| 279 |
|
| 280 |
# Probe for token counts now that streaming is done.
|
| 281 |
try:
|
| 282 |
_, probe = chat_completion(
|
| 283 |
+
messages, model, endpoint_id, temperature, max_tokens=1, tools=tools
|
| 284 |
)
|
| 285 |
except Exception:
|
| 286 |
probe = {}
|
|
|
|
| 289 |
cached_tokens = details.get("cached_tokens")
|
| 290 |
total = (prompt_tokens + piece_count) if prompt_tokens is not None else None
|
| 291 |
|
| 292 |
+
raw_response = {"content": content_buf or None}
|
| 293 |
+
if tool_name:
|
| 294 |
+
raw_response["tool_calls"] = [
|
| 295 |
+
{"function": {"name": tool_name, "arguments": tool_args}}
|
| 296 |
+
]
|
| 297 |
+
|
| 298 |
yield _empty_metrics(
|
| 299 |
prompt_tokens=prompt_tokens,
|
| 300 |
completion_tokens=piece_count,
|
| 301 |
total_tokens=total,
|
| 302 |
cached_tokens=cached_tokens,
|
| 303 |
latency_s=ttfb,
|
| 304 |
+
tool_called=tool_name,
|
| 305 |
+
raw_response=raw_response,
|
| 306 |
)
|
| 307 |
|
| 308 |
|
| 309 |
def _stream_custom(backend, messages, temperature, max_tokens):
|
| 310 |
+
tools = backend.get("tools", config.TOOLS)
|
| 311 |
yield from stream_chat(
|
| 312 |
+
messages,
|
| 313 |
+
backend["model"],
|
| 314 |
+
backend["endpoint_id"],
|
| 315 |
+
temperature,
|
| 316 |
+
max_tokens,
|
| 317 |
+
tools=tools,
|
| 318 |
)
|
| 319 |
|
| 320 |
|
|
|
|
| 343 |
t_start = time.monotonic()
|
| 344 |
usage = None
|
| 345 |
|
| 346 |
+
content_buf = ""
|
| 347 |
+
request_payload = {
|
| 348 |
+
"model": backend["deployment"],
|
| 349 |
+
"messages": messages,
|
| 350 |
+
"temperature": float(temperature),
|
| 351 |
+
"max_completion_tokens": int(max_tokens),
|
| 352 |
+
"stream": True,
|
| 353 |
+
"stream_options": {"include_usage": True},
|
| 354 |
+
}
|
| 355 |
+
yield {"__request__": True, "payload": request_payload}
|
| 356 |
+
stream = client.chat.completions.create(**request_payload)
|
| 357 |
for chunk in stream:
|
| 358 |
if getattr(chunk, "usage", None):
|
| 359 |
usage = chunk.usage
|
|
|
|
| 363 |
delta = choices[0].delta
|
| 364 |
piece = getattr(delta, "content", None) if delta else None
|
| 365 |
if piece:
|
| 366 |
+
content_buf += piece
|
| 367 |
yield piece
|
| 368 |
|
| 369 |
prompt_tokens = completion_tokens = total_tokens = cached_tokens = None
|
|
|
|
| 381 |
total_tokens=total_tokens,
|
| 382 |
cached_tokens=cached_tokens,
|
| 383 |
latency_s=round(time.monotonic() - t_start, 3),
|
| 384 |
+
raw_response={"content": content_buf or None},
|
| 385 |
)
|
| 386 |
|
| 387 |
|