pygpt-net 2.7.5__py3-none-any.whl → 2.7.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pygpt_net/CHANGELOG.txt +8 -0
- pygpt_net/__init__.py +2 -2
- pygpt_net/controller/chat/handler/worker.py +9 -31
- pygpt_net/controller/chat/handler/xai_stream.py +621 -52
- pygpt_net/controller/debug/fixtures.py +3 -2
- pygpt_net/controller/files/files.py +65 -4
- pygpt_net/core/filesystem/url.py +4 -1
- pygpt_net/core/render/web/body.py +3 -2
- pygpt_net/core/types/chunk.py +27 -0
- pygpt_net/data/config/config.json +2 -2
- pygpt_net/data/config/models.json +2 -2
- pygpt_net/data/config/settings.json +1 -1
- pygpt_net/data/js/app/template.js +1 -1
- pygpt_net/data/js/app.min.js +2 -2
- pygpt_net/data/locale/locale.de.ini +3 -0
- pygpt_net/data/locale/locale.en.ini +3 -0
- pygpt_net/data/locale/locale.es.ini +3 -0
- pygpt_net/data/locale/locale.fr.ini +3 -0
- pygpt_net/data/locale/locale.it.ini +3 -0
- pygpt_net/data/locale/locale.pl.ini +3 -0
- pygpt_net/data/locale/locale.uk.ini +3 -0
- pygpt_net/data/locale/locale.zh.ini +3 -0
- pygpt_net/data/locale/plugin.cmd_mouse_control.en.ini +2 -2
- pygpt_net/item/ctx.py +3 -5
- pygpt_net/js_rc.py +2449 -2447
- pygpt_net/plugin/cmd_mouse_control/config.py +8 -7
- pygpt_net/plugin/cmd_mouse_control/plugin.py +3 -4
- pygpt_net/provider/api/anthropic/__init__.py +10 -8
- pygpt_net/provider/api/google/__init__.py +6 -5
- pygpt_net/provider/api/google/chat.py +1 -2
- pygpt_net/provider/api/openai/__init__.py +7 -3
- pygpt_net/provider/api/openai/responses.py +0 -0
- pygpt_net/provider/api/x_ai/__init__.py +10 -9
- pygpt_net/provider/api/x_ai/chat.py +272 -102
- pygpt_net/tools/image_viewer/ui/dialogs.py +298 -12
- pygpt_net/tools/text_editor/ui/widgets.py +5 -1
- pygpt_net/ui/base/context_menu.py +44 -1
- pygpt_net/ui/layout/toolbox/indexes.py +22 -19
- pygpt_net/ui/layout/toolbox/model.py +28 -5
- pygpt_net/ui/widget/image/display.py +25 -8
- pygpt_net/ui/widget/tabs/output.py +9 -1
- pygpt_net/ui/widget/textarea/editor.py +14 -1
- pygpt_net/ui/widget/textarea/input.py +20 -7
- pygpt_net/ui/widget/textarea/notepad.py +24 -1
- pygpt_net/ui/widget/textarea/output.py +23 -1
- pygpt_net/ui/widget/textarea/web.py +16 -1
- {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.6.dist-info}/METADATA +10 -2
- {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.6.dist-info}/RECORD +50 -49
- {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.6.dist-info}/LICENSE +0 -0
- {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.6.dist-info}/WHEEL +0 -0
- {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.6.dist-info}/entry_points.txt +0 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
7
|
# MIT License #
|
|
8
8
|
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date:
|
|
9
|
+
# Updated Date: 2026.01.03 17:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
@@ -116,7 +116,16 @@ class Chat:
|
|
|
116
116
|
# NON-STREAM: prefer SDK only for plain chat (no tools/search/tool-turns/images)
|
|
117
117
|
if sdk_live and not tools_prepared and http_params is None and not has_tool_turns and not has_images:
|
|
118
118
|
chat = client.chat.create(model=model_id, messages=sdk_messages)
|
|
119
|
-
|
|
119
|
+
try:
|
|
120
|
+
if hasattr(chat, "sample"):
|
|
121
|
+
return chat.sample()
|
|
122
|
+
if hasattr(chat, "output_text"):
|
|
123
|
+
return chat
|
|
124
|
+
if hasattr(chat, "message"):
|
|
125
|
+
return chat
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
return chat
|
|
120
129
|
|
|
121
130
|
# Otherwise HTTP non-stream for tools/search/vision/tool-turns
|
|
122
131
|
text, calls, citations, usage = self.call_http_nonstream(
|
|
@@ -142,7 +151,7 @@ class Chat:
|
|
|
142
151
|
|
|
143
152
|
def unpack_response(self, mode: str, response, ctx: CtxItem):
|
|
144
153
|
"""
|
|
145
|
-
Unpack non-streaming xAI response into ctx (text, tool calls, usage, citations).
|
|
154
|
+
Unpack non-streaming xAI response into ctx (text, tool calls, usage, citations, images).
|
|
146
155
|
|
|
147
156
|
:param mode: mode (chat, etc)
|
|
148
157
|
:param response: Response object from SDK or HTTP (dict)
|
|
@@ -152,6 +161,37 @@ class Chat:
|
|
|
152
161
|
txt = getattr(response, "content", None)
|
|
153
162
|
if not txt and isinstance(response, dict):
|
|
154
163
|
txt = response.get("output_text") or ""
|
|
164
|
+
if not txt:
|
|
165
|
+
try:
|
|
166
|
+
ch = (response.get("choices") or [])
|
|
167
|
+
if ch:
|
|
168
|
+
msg = (ch[0].get("message") or {})
|
|
169
|
+
txt = self._message_to_text(msg)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
if not txt and not isinstance(response, dict):
|
|
174
|
+
try:
|
|
175
|
+
msg = getattr(response, "message", None) or getattr(response, "output_message", None)
|
|
176
|
+
if msg is not None:
|
|
177
|
+
mc = getattr(msg, "content", None)
|
|
178
|
+
if mc is None and isinstance(msg, dict):
|
|
179
|
+
mc = msg.get("content")
|
|
180
|
+
txt = self._content_to_text(mc)
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
if not txt:
|
|
184
|
+
try:
|
|
185
|
+
proto = getattr(response, "proto", None)
|
|
186
|
+
if proto:
|
|
187
|
+
choices = getattr(proto, "choices", None) or []
|
|
188
|
+
if choices:
|
|
189
|
+
m = getattr(choices[0], "message", None)
|
|
190
|
+
if m:
|
|
191
|
+
txt = self._content_to_text(getattr(m, "content", None))
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
155
195
|
ctx.output = (str(txt or "")).strip()
|
|
156
196
|
|
|
157
197
|
# Tool calls
|
|
@@ -160,7 +200,42 @@ class Chat:
|
|
|
160
200
|
calls = response["tool_calls"]
|
|
161
201
|
|
|
162
202
|
if not calls:
|
|
163
|
-
|
|
203
|
+
try:
|
|
204
|
+
msg = getattr(response, "message", None) or getattr(response, "output_message", None)
|
|
205
|
+
except Exception:
|
|
206
|
+
msg = None
|
|
207
|
+
try:
|
|
208
|
+
if msg:
|
|
209
|
+
tcs = getattr(msg, "tool_calls", None)
|
|
210
|
+
if tcs is None and isinstance(msg, dict):
|
|
211
|
+
tcs = msg.get("tool_calls")
|
|
212
|
+
if tcs:
|
|
213
|
+
out = []
|
|
214
|
+
for tc in tcs:
|
|
215
|
+
fn = getattr(tc, "function", None)
|
|
216
|
+
if isinstance(tc, dict):
|
|
217
|
+
fn = tc.get("function", fn)
|
|
218
|
+
args = getattr(fn, "arguments", None) if fn is not None else None
|
|
219
|
+
if isinstance(fn, dict):
|
|
220
|
+
args = fn.get("arguments", args)
|
|
221
|
+
if isinstance(args, (dict, list)):
|
|
222
|
+
try:
|
|
223
|
+
args = json.dumps(args, ensure_ascii=False)
|
|
224
|
+
except Exception:
|
|
225
|
+
args = str(args)
|
|
226
|
+
out.append({
|
|
227
|
+
"id": (getattr(tc, "id", None) if not isinstance(tc, dict) else tc.get("id")) or "",
|
|
228
|
+
"type": "function",
|
|
229
|
+
"function": {
|
|
230
|
+
"name": (getattr(fn, "name", None) if not isinstance(fn, dict) else fn.get("name")) or "",
|
|
231
|
+
"arguments": args or "{}",
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
calls = out
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
if not calls:
|
|
164
239
|
try:
|
|
165
240
|
proto = getattr(response, "proto", None)
|
|
166
241
|
tool_calls = getattr(getattr(getattr(proto, "choices", [None])[0], "message", None), "tool_calls", None)
|
|
@@ -182,14 +257,42 @@ class Chat:
|
|
|
182
257
|
if calls:
|
|
183
258
|
ctx.tool_calls = calls
|
|
184
259
|
|
|
185
|
-
# Citations
|
|
260
|
+
# Citations and URLs
|
|
186
261
|
try:
|
|
187
262
|
urls: List[str] = []
|
|
188
263
|
if isinstance(response, dict):
|
|
189
264
|
urls = self._extract_urls(response.get("citations"))
|
|
265
|
+
if not urls:
|
|
266
|
+
choices = response.get("choices") or []
|
|
267
|
+
if choices:
|
|
268
|
+
msg = choices[0].get("message") or {}
|
|
269
|
+
urls = self._extract_urls(msg.get("citations"))
|
|
270
|
+
# Additionally scan assistant message content for http(s) links in text parts
|
|
271
|
+
try:
|
|
272
|
+
choices = response.get("choices") or []
|
|
273
|
+
if choices:
|
|
274
|
+
msg = choices[0].get("message") or {}
|
|
275
|
+
parts = msg.get("content")
|
|
276
|
+
if isinstance(parts, list):
|
|
277
|
+
for p in parts:
|
|
278
|
+
if isinstance(p, dict) and p.get("type") == "text":
|
|
279
|
+
t = p.get("text")
|
|
280
|
+
if isinstance(t, str):
|
|
281
|
+
for u in self._extract_urls([t]):
|
|
282
|
+
if u not in urls:
|
|
283
|
+
urls.append(u)
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
190
286
|
else:
|
|
191
287
|
cits = getattr(response, "citations", None)
|
|
192
288
|
urls = self._extract_urls(cits)
|
|
289
|
+
if not urls:
|
|
290
|
+
msg = getattr(response, "message", None) or getattr(response, "output_message", None)
|
|
291
|
+
if msg:
|
|
292
|
+
mc = getattr(msg, "citations", None)
|
|
293
|
+
if mc is None and isinstance(msg, dict):
|
|
294
|
+
mc = msg.get("citations")
|
|
295
|
+
urls = self._extract_urls(mc)
|
|
193
296
|
if urls:
|
|
194
297
|
if ctx.urls is None:
|
|
195
298
|
ctx.urls = []
|
|
@@ -199,6 +302,23 @@ class Chat:
|
|
|
199
302
|
except Exception:
|
|
200
303
|
pass
|
|
201
304
|
|
|
305
|
+
# Images possibly returned in assistant content as image_url parts
|
|
306
|
+
try:
|
|
307
|
+
parts = None
|
|
308
|
+
if isinstance(response, dict):
|
|
309
|
+
choices = response.get("choices") or []
|
|
310
|
+
if choices:
|
|
311
|
+
msg = choices[0].get("message") or {}
|
|
312
|
+
parts = msg.get("content")
|
|
313
|
+
else:
|
|
314
|
+
msg = getattr(response, "message", None) or getattr(response, "output_message", None)
|
|
315
|
+
if msg:
|
|
316
|
+
parts = getattr(msg, "content", None)
|
|
317
|
+
if isinstance(parts, list):
|
|
318
|
+
self._collect_images_from_message_parts(parts, ctx)
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
|
|
202
322
|
# Usage
|
|
203
323
|
try:
|
|
204
324
|
if isinstance(response, dict) and response.get("usage"):
|
|
@@ -216,12 +336,29 @@ class Chat:
|
|
|
216
336
|
}
|
|
217
337
|
return
|
|
218
338
|
|
|
339
|
+
uattr = getattr(response, "usage", None)
|
|
340
|
+
if isinstance(uattr, dict):
|
|
341
|
+
u = self._normalize_usage(uattr)
|
|
342
|
+
if u:
|
|
343
|
+
ctx.set_tokens(u.get("in", 0), u.get("out", 0))
|
|
344
|
+
if not isinstance(ctx.extra, dict):
|
|
345
|
+
ctx.extra = {}
|
|
346
|
+
ctx.extra["usage"] = {
|
|
347
|
+
"vendor": "xai",
|
|
348
|
+
"input_tokens": u.get("in", 0),
|
|
349
|
+
"output_tokens": u.get("out", 0),
|
|
350
|
+
"reasoning_tokens": u.get("reasoning", 0),
|
|
351
|
+
"total_reported": u.get("total"),
|
|
352
|
+
}
|
|
353
|
+
return
|
|
354
|
+
|
|
219
355
|
proto = getattr(response, "proto", None)
|
|
220
356
|
usage = getattr(proto, "usage", None)
|
|
221
357
|
if usage:
|
|
222
358
|
p = int(getattr(usage, "prompt_tokens", 0) or 0)
|
|
223
359
|
c = int(getattr(usage, "completion_tokens", 0) or 0)
|
|
224
|
-
|
|
360
|
+
r = int(getattr(usage, "reasoning_tokens", 0) or 0)
|
|
361
|
+
t = int(getattr(usage, "total_tokens", (p + c + r)) or (p + c + r))
|
|
225
362
|
out_tok = max(0, t - p) if t else c
|
|
226
363
|
ctx.set_tokens(p, out_tok)
|
|
227
364
|
if not isinstance(ctx.extra, dict):
|
|
@@ -230,7 +367,7 @@ class Chat:
|
|
|
230
367
|
"vendor": "xai",
|
|
231
368
|
"input_tokens": p,
|
|
232
369
|
"output_tokens": out_tok,
|
|
233
|
-
"reasoning_tokens":
|
|
370
|
+
"reasoning_tokens": r,
|
|
234
371
|
"total_reported": t,
|
|
235
372
|
}
|
|
236
373
|
except Exception:
|
|
@@ -274,7 +411,6 @@ class Chat:
|
|
|
274
411
|
if item.final_output:
|
|
275
412
|
out.append(xassistant(str(item.final_output)))
|
|
276
413
|
|
|
277
|
-
# Current user message with optional images (SDK accepts text first or images first)
|
|
278
414
|
parts = [str(prompt or "")]
|
|
279
415
|
for img in self.window.core.api.xai.vision.build_images_for_chat(attachments):
|
|
280
416
|
parts.append(ximage(img))
|
|
@@ -299,18 +435,6 @@ class Chat:
|
|
|
299
435
|
"""
|
|
300
436
|
Non-streaming HTTP Chat Completions call to xAI with optional tools, Live Search, and vision.
|
|
301
437
|
Returns (text, tool_calls, citations, usage).
|
|
302
|
-
|
|
303
|
-
:param model: Model ID
|
|
304
|
-
:param prompt: Current user prompt
|
|
305
|
-
:param system_prompt: System prompt
|
|
306
|
-
:param history: List of CtxItem for context
|
|
307
|
-
:param attachments: Dict of AttachmentItem for images
|
|
308
|
-
:param multimodal_ctx: MultimodalContext (not used)
|
|
309
|
-
:param tools: List of tool dicts
|
|
310
|
-
:param search_parameters: Live Search parameters dict
|
|
311
|
-
:param temperature: Temperature float
|
|
312
|
-
:param max_tokens: Max tokens int
|
|
313
|
-
:return: (output text, tool calls list, citations list, usage dict)
|
|
314
438
|
"""
|
|
315
439
|
import requests
|
|
316
440
|
|
|
@@ -318,7 +442,6 @@ class Chat:
|
|
|
318
442
|
api_key = cfg.get("api_key_xai") or ""
|
|
319
443
|
base_url = self._build_base_url(cfg.get("api_endpoint_xai"))
|
|
320
444
|
|
|
321
|
-
# unified HTTP messages (with tool-turns + images)
|
|
322
445
|
messages = self._build_http_messages(
|
|
323
446
|
model_id=model,
|
|
324
447
|
system_prompt=system_prompt,
|
|
@@ -357,7 +480,6 @@ class Chat:
|
|
|
357
480
|
if not resp.encoding:
|
|
358
481
|
resp.encoding = "utf-8"
|
|
359
482
|
if resp.status_code >= 400:
|
|
360
|
-
# Log server error body for diagnostics
|
|
361
483
|
self.window.core.debug.error(f"[xai.http] {resp.status_code} {resp.reason}: {resp.text}")
|
|
362
484
|
resp.raise_for_status()
|
|
363
485
|
data = resp.json() if resp.content else {}
|
|
@@ -365,7 +487,6 @@ class Chat:
|
|
|
365
487
|
self.window.core.debug.error(f"[xai.http] error: {e}")
|
|
366
488
|
return "", [], [], None
|
|
367
489
|
|
|
368
|
-
# Text + tool calls
|
|
369
490
|
text = ""
|
|
370
491
|
calls: List[dict] = []
|
|
371
492
|
try:
|
|
@@ -384,19 +505,23 @@ class Chat:
|
|
|
384
505
|
out_parts.append(t)
|
|
385
506
|
text = "".join(out_parts).strip()
|
|
386
507
|
|
|
387
|
-
# tool calls
|
|
388
508
|
tlist = msg.get("tool_calls") or []
|
|
389
509
|
for t in tlist:
|
|
390
510
|
fn = t.get("function") or {}
|
|
511
|
+
args = fn.get("arguments")
|
|
512
|
+
if isinstance(args, (dict, list)):
|
|
513
|
+
try:
|
|
514
|
+
args = json.dumps(args, ensure_ascii=False)
|
|
515
|
+
except Exception:
|
|
516
|
+
args = str(args)
|
|
391
517
|
calls.append({
|
|
392
518
|
"id": t.get("id") or "",
|
|
393
519
|
"type": "function",
|
|
394
|
-
"function": {"name": fn.get("name") or "", "arguments":
|
|
520
|
+
"function": {"name": fn.get("name") or "", "arguments": args or "{}"},
|
|
395
521
|
})
|
|
396
522
|
except Exception:
|
|
397
523
|
pass
|
|
398
524
|
|
|
399
|
-
# Citations
|
|
400
525
|
citations: List[str] = []
|
|
401
526
|
try:
|
|
402
527
|
citations = self._extract_urls(data.get("citations")) or []
|
|
@@ -408,7 +533,6 @@ class Chat:
|
|
|
408
533
|
except Exception:
|
|
409
534
|
citations = citations or []
|
|
410
535
|
|
|
411
|
-
# Usage
|
|
412
536
|
usage: Optional[dict] = None
|
|
413
537
|
try:
|
|
414
538
|
usage = self._normalize_usage(data.get("usage"))
|
|
@@ -425,7 +549,6 @@ class Chat:
|
|
|
425
549
|
search_parameters: Optional[Dict[str, Any]] = None,
|
|
426
550
|
temperature: Optional[float] = None,
|
|
427
551
|
max_tokens: Optional[int] = None,
|
|
428
|
-
# fallback rebuild inputs if needed:
|
|
429
552
|
system_prompt: Optional[str] = None,
|
|
430
553
|
history: Optional[List[CtxItem]] = None,
|
|
431
554
|
attachments: Optional[Dict[str, AttachmentItem]] = None,
|
|
@@ -435,16 +558,6 @@ class Chat:
|
|
|
435
558
|
Streaming HTTP Chat Completions (SSE) for xAI.
|
|
436
559
|
Sends OpenAI-compatible JSON; rebuilds messages when given SDK objects.
|
|
437
560
|
|
|
438
|
-
:param model: Model ID
|
|
439
|
-
:param messages: List of messages (SDK objects or HTTP-style dicts)
|
|
440
|
-
:param tools: List of tool dicts
|
|
441
|
-
:param search_parameters: Live Search parameters dict
|
|
442
|
-
:param temperature: Temperature float
|
|
443
|
-
:param max_tokens: Max tokens int
|
|
444
|
-
:param system_prompt: System prompt (used if messages need rebuilding)
|
|
445
|
-
:param history: List of CtxItem for context (used if messages need rebuilding)
|
|
446
|
-
:param attachments: Dict of AttachmentItem for images (used if messages need rebuilding)
|
|
447
|
-
:param prompt: Current user prompt (used if messages need rebuilding)
|
|
448
561
|
:return: Iterable/generator yielding SDK-like response chunks with 'content', 'tool_calls', 'citations', 'usage'
|
|
449
562
|
"""
|
|
450
563
|
import requests
|
|
@@ -455,7 +568,6 @@ class Chat:
|
|
|
455
568
|
api_key = cfg.get("api_key_xai") or ""
|
|
456
569
|
base_url = self._build_base_url(cfg.get("api_endpoint_xai"))
|
|
457
570
|
|
|
458
|
-
# Ensure HTTP messages are JSON-serializable dicts
|
|
459
571
|
if not self._looks_like_http_messages(messages):
|
|
460
572
|
messages = self._build_http_messages(
|
|
461
573
|
model_id=model,
|
|
@@ -535,7 +647,7 @@ class Chat:
|
|
|
535
647
|
except Exception:
|
|
536
648
|
continue
|
|
537
649
|
|
|
538
|
-
#
|
|
650
|
+
# OpenAI-style choices path
|
|
539
651
|
try:
|
|
540
652
|
chs = obj.get("choices") or []
|
|
541
653
|
if chs:
|
|
@@ -547,14 +659,48 @@ class Chat:
|
|
|
547
659
|
mc = message["content"]
|
|
548
660
|
if isinstance(mc, str):
|
|
549
661
|
yield _mk_chunk(delta_text=mc)
|
|
662
|
+
elif isinstance(mc, list):
|
|
663
|
+
out_parts: List[str] = []
|
|
664
|
+
for p in mc:
|
|
665
|
+
if isinstance(p, dict) and p.get("type") == "text":
|
|
666
|
+
t = p.get("text")
|
|
667
|
+
if isinstance(t, str):
|
|
668
|
+
out_parts.append(t)
|
|
669
|
+
if out_parts:
|
|
670
|
+
yield _mk_chunk(delta_text="".join(out_parts))
|
|
550
671
|
tc = delta.get("tool_calls") or message.get("tool_calls") or []
|
|
551
672
|
if tc:
|
|
552
673
|
yield _mk_chunk(tool_calls=tc)
|
|
674
|
+
u = obj.get("usage")
|
|
675
|
+
cits = self_outer._extract_urls(obj.get("citations"))
|
|
676
|
+
if u or cits:
|
|
677
|
+
yield _mk_chunk(delta_text="", citations=cits if cits else None, usage=u if u else None)
|
|
678
|
+
continue
|
|
553
679
|
except Exception:
|
|
554
680
|
pass
|
|
555
681
|
|
|
556
|
-
#
|
|
682
|
+
# Event-style root-level delta/message
|
|
557
683
|
try:
|
|
684
|
+
if isinstance(obj.get("delta"), dict):
|
|
685
|
+
d = obj["delta"]
|
|
686
|
+
if "content" in d and d["content"] is not None:
|
|
687
|
+
yield _mk_chunk(delta_text=str(d["content"]))
|
|
688
|
+
tc = d.get("tool_calls") or []
|
|
689
|
+
if tc:
|
|
690
|
+
yield _mk_chunk(tool_calls=tc)
|
|
691
|
+
if isinstance(obj.get("message"), dict) and "content" in obj["message"]:
|
|
692
|
+
mc = obj["message"]["content"]
|
|
693
|
+
if isinstance(mc, str):
|
|
694
|
+
yield _mk_chunk(delta_text=mc)
|
|
695
|
+
elif isinstance(mc, list):
|
|
696
|
+
out_parts: List[str] = []
|
|
697
|
+
for p in mc:
|
|
698
|
+
if isinstance(p, dict) and p.get("type") == "text":
|
|
699
|
+
t = p.get("text")
|
|
700
|
+
if isinstance(t, str):
|
|
701
|
+
out_parts.append(t)
|
|
702
|
+
if out_parts:
|
|
703
|
+
yield _mk_chunk(delta_text="".join(out_parts))
|
|
558
704
|
u = obj.get("usage")
|
|
559
705
|
cits = self_outer._extract_urls(obj.get("citations"))
|
|
560
706
|
if u or cits:
|
|
@@ -583,7 +729,6 @@ class Chat:
|
|
|
583
729
|
if not resp.encoding:
|
|
584
730
|
resp.encoding = "utf-8"
|
|
585
731
|
if resp.status_code >= 400:
|
|
586
|
-
# Log server error body for diagnostics
|
|
587
732
|
try:
|
|
588
733
|
body = resp.text
|
|
589
734
|
except Exception:
|
|
@@ -608,9 +753,6 @@ class Chat:
|
|
|
608
753
|
def _fit_ctx(self, model: ModelItem) -> int:
|
|
609
754
|
"""
|
|
610
755
|
Fit to max model tokens (uses model.ctx if present).
|
|
611
|
-
|
|
612
|
-
:param model: ModelItem
|
|
613
|
-
:return: Max tokens int
|
|
614
756
|
"""
|
|
615
757
|
max_ctx_tokens = self.window.core.config.get('max_total_tokens')
|
|
616
758
|
if model and model.ctx and 0 < model.ctx < max_ctx_tokens:
|
|
@@ -626,12 +768,6 @@ class Chat:
|
|
|
626
768
|
) -> List[dict]:
|
|
627
769
|
"""
|
|
628
770
|
Build simple messages structure for local token estimation.
|
|
629
|
-
|
|
630
|
-
:param prompt: Current user prompt
|
|
631
|
-
:param system_prompt: System prompt
|
|
632
|
-
:param model: ModelItem
|
|
633
|
-
:param history: List of CtxItem for context
|
|
634
|
-
:return: List of dicts with 'role' and 'content'
|
|
635
771
|
"""
|
|
636
772
|
messages = []
|
|
637
773
|
if system_prompt:
|
|
@@ -651,16 +787,50 @@ class Chat:
|
|
|
651
787
|
messages.append({"role": "user", "content": str(prompt)})
|
|
652
788
|
return messages
|
|
653
789
|
|
|
790
|
+
def _content_to_text(self, content) -> str:
|
|
791
|
+
"""
|
|
792
|
+
Convert message content (SDK or HTTP shapes) into plain text.
|
|
793
|
+
"""
|
|
794
|
+
if content is None:
|
|
795
|
+
return ""
|
|
796
|
+
if isinstance(content, str):
|
|
797
|
+
return content
|
|
798
|
+
if isinstance(content, list):
|
|
799
|
+
out: List[str] = []
|
|
800
|
+
for p in content:
|
|
801
|
+
if isinstance(p, dict) and p.get("type") == "text":
|
|
802
|
+
t = p.get("text")
|
|
803
|
+
if isinstance(t, str):
|
|
804
|
+
out.append(t)
|
|
805
|
+
elif isinstance(p, str):
|
|
806
|
+
out.append(p)
|
|
807
|
+
else:
|
|
808
|
+
t = getattr(p, "text", None)
|
|
809
|
+
if isinstance(t, str):
|
|
810
|
+
out.append(t)
|
|
811
|
+
return "".join(out)
|
|
812
|
+
if isinstance(content, dict):
|
|
813
|
+
if isinstance(content.get("text"), str):
|
|
814
|
+
return content["text"]
|
|
815
|
+
if isinstance(content.get("content"), str):
|
|
816
|
+
return content["content"]
|
|
817
|
+
t = getattr(content, "text", None)
|
|
818
|
+
if isinstance(t, str):
|
|
819
|
+
return t
|
|
820
|
+
return str(content)
|
|
821
|
+
|
|
822
|
+
def _message_to_text(self, msg: Dict[str, Any]) -> str:
|
|
823
|
+
"""
|
|
824
|
+
Extract text from a dict message with 'content' possibly being str or list of parts.
|
|
825
|
+
"""
|
|
826
|
+
if not isinstance(msg, dict):
|
|
827
|
+
return ""
|
|
828
|
+
mc = msg.get("content")
|
|
829
|
+
return self._content_to_text(mc)
|
|
830
|
+
|
|
654
831
|
def _extract_urls(self, raw) -> List[str]:
|
|
655
832
|
"""
|
|
656
833
|
Normalize list of citations into a list of HTTP(S) URLs.
|
|
657
|
-
|
|
658
|
-
Accepts either:
|
|
659
|
-
- A list of strings (URLs)
|
|
660
|
-
- A list of dicts with 'url' or 'uri' keys
|
|
661
|
-
|
|
662
|
-
:param raw: Raw citations input
|
|
663
|
-
:return: List of unique HTTP(S) URLs
|
|
664
834
|
"""
|
|
665
835
|
urls: List[str] = []
|
|
666
836
|
seen = set()
|
|
@@ -685,9 +855,6 @@ class Chat:
|
|
|
685
855
|
Accepts either:
|
|
686
856
|
- {'input_tokens','output_tokens','total_tokens'}
|
|
687
857
|
- {'prompt_tokens','completion_tokens','total_tokens'}
|
|
688
|
-
|
|
689
|
-
:param raw: Raw usage input
|
|
690
|
-
:return: Normalized usage dict or None
|
|
691
858
|
"""
|
|
692
859
|
if not isinstance(raw, dict):
|
|
693
860
|
return None
|
|
@@ -703,19 +870,18 @@ class Chat:
|
|
|
703
870
|
|
|
704
871
|
in_tok = raw.get("input_tokens") if "input_tokens" in raw else raw.get("prompt_tokens")
|
|
705
872
|
out_tok = raw.get("output_tokens") if "output_tokens" in raw else raw.get("completion_tokens")
|
|
873
|
+
reasoning_tok = raw.get("reasoning_tokens", 0)
|
|
706
874
|
tot = raw.get("total_tokens")
|
|
707
875
|
|
|
708
876
|
i = _as_int(in_tok or 0)
|
|
709
877
|
o = _as_int(out_tok or 0)
|
|
710
|
-
|
|
711
|
-
|
|
878
|
+
r = _as_int(reasoning_tok or 0)
|
|
879
|
+
t = _as_int(tot if tot is not None else (i + o + r))
|
|
880
|
+
return {"in": i, "out": max(0, t - i) if t else o, "reasoning": r, "total": t}
|
|
712
881
|
|
|
713
882
|
def _looks_like_http_messages(self, messages) -> bool:
|
|
714
883
|
"""
|
|
715
884
|
Return True if 'messages' looks like an OpenAI-style array of dicts with 'role' and 'content'.
|
|
716
|
-
|
|
717
|
-
:param messages: Messages input
|
|
718
|
-
:return: True if looks like HTTP messages
|
|
719
885
|
"""
|
|
720
886
|
if not isinstance(messages, list):
|
|
721
887
|
return False
|
|
@@ -739,20 +905,12 @@ class Chat:
|
|
|
739
905
|
- If images present: content is a list of parts, with image parts first, then text part.
|
|
740
906
|
- Only JPEG/PNG allowed; size validated; on violation we raise clear error in logs.
|
|
741
907
|
- Tool-turns from ctx are injected like OpenAI (assistant.tool_calls + tool).
|
|
742
|
-
|
|
743
|
-
:param model_id: Model ID
|
|
744
|
-
:param system_prompt: System prompt
|
|
745
|
-
:param history: List of CtxItem for context
|
|
746
|
-
:param prompt: Current user prompt
|
|
747
|
-
:param attachments: Dict of AttachmentItem for images
|
|
748
|
-
:return: List of dicts with 'role' and 'content' (and optional tool_calls)
|
|
749
908
|
"""
|
|
750
909
|
self.window.core.api.xai.vision.reset()
|
|
751
910
|
messages: List[dict] = []
|
|
752
911
|
if system_prompt:
|
|
753
912
|
messages.append({"role": "system", "content": system_prompt})
|
|
754
913
|
|
|
755
|
-
# history as plain user/assistant turns
|
|
756
914
|
items: List[CtxItem] = []
|
|
757
915
|
if self.window.core.config.get('use_context'):
|
|
758
916
|
used = self.window.core.tokens.from_user(prompt or "", system_prompt or "")
|
|
@@ -764,14 +922,11 @@ class Chat:
|
|
|
764
922
|
if it.final_output:
|
|
765
923
|
messages.append({"role": "assistant", "content": str(it.final_output)})
|
|
766
924
|
|
|
767
|
-
# Inject tool-turns from last ctx item
|
|
768
925
|
self._append_tool_turns_from_ctx(messages, items)
|
|
769
926
|
|
|
770
|
-
# Current user content (images first -> then text), validated
|
|
771
927
|
parts: List[dict] = []
|
|
772
928
|
img_found = False
|
|
773
929
|
if attachments:
|
|
774
|
-
# image constraints
|
|
775
930
|
cfg = self.window.core.config
|
|
776
931
|
max_bytes = int(cfg.get("xai_image_max_bytes") or self.default_image_max_bytes)
|
|
777
932
|
|
|
@@ -782,11 +937,9 @@ class Chat:
|
|
|
782
937
|
if not self.window.core.api.xai.vision.is_image(att.path):
|
|
783
938
|
continue
|
|
784
939
|
mime = self.window.core.api.xai.vision.guess_mime(att.path)
|
|
785
|
-
# Enforce allowed MIME
|
|
786
940
|
if mime not in self.allowed_mimes:
|
|
787
941
|
self.window.core.debug.error(f"[xai.vision] Unsupported image MIME: {mime}. Use JPEG/PNG.")
|
|
788
942
|
continue
|
|
789
|
-
# Enforce size
|
|
790
943
|
try:
|
|
791
944
|
fsz = os.path.getsize(att.path)
|
|
792
945
|
if fsz > max_bytes:
|
|
@@ -807,7 +960,6 @@ class Chat:
|
|
|
807
960
|
self.window.core.debug.error(f"[xai.vision] Error processing image '{getattr(att,'path',None)}': {e}")
|
|
808
961
|
continue
|
|
809
962
|
|
|
810
|
-
# Append text part last when images exist
|
|
811
963
|
if img_found:
|
|
812
964
|
if prompt:
|
|
813
965
|
parts.append({"type": "text", "text": str(prompt)})
|
|
@@ -820,9 +972,6 @@ class Chat:
|
|
|
820
972
|
def _append_tool_turns_from_ctx(self, messages: List[dict], items: List[CtxItem]):
|
|
821
973
|
"""
|
|
822
974
|
Append assistant.tool_calls and subsequent tool messages from last ctx item (OpenAI-compatible).
|
|
823
|
-
|
|
824
|
-
:param messages: Messages list to append to
|
|
825
|
-
:param items: List of CtxItem (history) to extract tool calls from
|
|
826
975
|
"""
|
|
827
976
|
tool_call_native_enabled = self.window.core.config.get('func_call.native', False)
|
|
828
977
|
if not (items and tool_call_native_enabled):
|
|
@@ -835,7 +984,6 @@ class Chat:
|
|
|
835
984
|
if not (tool_calls and isinstance(tool_calls, list)):
|
|
836
985
|
return
|
|
837
986
|
|
|
838
|
-
# find last assistant message to attach tool_calls
|
|
839
987
|
idx = None
|
|
840
988
|
for i in range(len(messages) - 1, -1, -1):
|
|
841
989
|
if messages[i].get("role") == "assistant":
|
|
@@ -857,7 +1005,6 @@ class Chat:
|
|
|
857
1005
|
}
|
|
858
1006
|
})
|
|
859
1007
|
|
|
860
|
-
# append tool messages
|
|
861
1008
|
if tool_output and isinstance(tool_output, list):
|
|
862
1009
|
for out in tool_output:
|
|
863
1010
|
if isinstance(out, dict) and "result" in out:
|
|
@@ -883,9 +1030,6 @@ class Chat:
|
|
|
883
1030
|
def _has_tool_turns(self, history: Optional[List[CtxItem]]) -> bool:
|
|
884
1031
|
"""
|
|
885
1032
|
Return True if the last history item contains tool call(s) with tool output.
|
|
886
|
-
|
|
887
|
-
:param history: List of CtxItem
|
|
888
|
-
:return: True if last item has tool calls and output
|
|
889
1033
|
"""
|
|
890
1034
|
if not history:
|
|
891
1035
|
return False
|
|
@@ -899,9 +1043,6 @@ class Chat:
|
|
|
899
1043
|
def _attachments_have_images(self, attachments: Optional[Dict[str, AttachmentItem]]) -> bool:
|
|
900
1044
|
"""
|
|
901
1045
|
Detect if attachments contain at least one image file.
|
|
902
|
-
|
|
903
|
-
:param attachments: Dict of AttachmentItem
|
|
904
|
-
:return: True if at least one image attachment found
|
|
905
1046
|
"""
|
|
906
1047
|
if not attachments:
|
|
907
1048
|
return False
|
|
@@ -916,9 +1057,6 @@ class Chat:
|
|
|
916
1057
|
def _is_vision_model(self, model: ModelItem) -> bool:
|
|
917
1058
|
"""
|
|
918
1059
|
Heuristic check for vision-capable model IDs.
|
|
919
|
-
|
|
920
|
-
:param model: ModelItem
|
|
921
|
-
:return: True if model ID indicates vision capability
|
|
922
1060
|
"""
|
|
923
1061
|
model_id = (model.id if model and model.id else "").strip()
|
|
924
1062
|
if not model or not model_id:
|
|
@@ -931,9 +1069,6 @@ class Chat:
|
|
|
931
1069
|
def _make_tools_payload(self, tools: Optional[List[dict]]) -> List[dict]:
|
|
932
1070
|
"""
|
|
933
1071
|
Normalize tools to [{"type":"function","function":{...}}] without double-wrapping.
|
|
934
|
-
|
|
935
|
-
:param tools: List of tool dicts or None
|
|
936
|
-
:return: Normalized list of tool dicts
|
|
937
1072
|
"""
|
|
938
1073
|
out: List[dict] = []
|
|
939
1074
|
for t in tools or []:
|
|
@@ -946,15 +1081,52 @@ class Chat:
|
|
|
946
1081
|
def _build_base_url(self, cfg_endpoint: Optional[str]) -> str:
|
|
947
1082
|
"""
|
|
948
1083
|
Return normalized base URL like 'https://api.x.ai/v1' without trailing slash.
|
|
949
|
-
|
|
950
|
-
:param cfg_endpoint: Configured endpoint or None
|
|
951
|
-
:return: Normalized base URL string
|
|
952
1084
|
"""
|
|
953
1085
|
base = (cfg_endpoint or "https://api.x.ai/v1").strip()
|
|
954
1086
|
if base.endswith("/"):
|
|
955
1087
|
base = base[:-1]
|
|
956
1088
|
return base
|
|
957
1089
|
|
|
1090
|
+
def _collect_images_from_message_parts(self, parts: List[dict], ctx: CtxItem):
|
|
1091
|
+
"""
|
|
1092
|
+
Inspect assistant message parts for image_url outputs and store them.
|
|
1093
|
+
For http(s) URLs -> add to ctx.urls; for data URLs -> save to file and add to ctx.images.
|
|
1094
|
+
"""
|
|
1095
|
+
try:
|
|
1096
|
+
if not isinstance(parts, list):
|
|
1097
|
+
return
|
|
1098
|
+
for p in parts:
|
|
1099
|
+
if not isinstance(p, dict):
|
|
1100
|
+
continue
|
|
1101
|
+
if p.get("type") != "image_url":
|
|
1102
|
+
continue
|
|
1103
|
+
img = p.get("image_url") or {}
|
|
1104
|
+
url = img.get("url")
|
|
1105
|
+
if not isinstance(url, str):
|
|
1106
|
+
continue
|
|
1107
|
+
if url.startswith("http://") or url.startswith("https://"):
|
|
1108
|
+
if ctx.urls is None:
|
|
1109
|
+
ctx.urls = []
|
|
1110
|
+
if url not in ctx.urls:
|
|
1111
|
+
ctx.urls.append(url)
|
|
1112
|
+
elif url.startswith("data:image/"):
|
|
1113
|
+
try:
|
|
1114
|
+
header, b64 = url.split(",", 1)
|
|
1115
|
+
mime = header.split(";")[0].split(":")[1].lower()
|
|
1116
|
+
ext = "png"
|
|
1117
|
+
if "jpeg" in mime or "jpg" in mime:
|
|
1118
|
+
ext = "jpg"
|
|
1119
|
+
save_path = self.window.core.image.gen_unique_path(ctx, ext=ext)
|
|
1120
|
+
with open(save_path, "wb") as f:
|
|
1121
|
+
f.write(base64.b64decode(b64))
|
|
1122
|
+
if not isinstance(ctx.images, list):
|
|
1123
|
+
ctx.images = []
|
|
1124
|
+
ctx.images.append(save_path)
|
|
1125
|
+
except Exception:
|
|
1126
|
+
pass
|
|
1127
|
+
except Exception:
|
|
1128
|
+
pass
|
|
1129
|
+
|
|
958
1130
|
def reset_tokens(self):
|
|
959
1131
|
"""Reset input tokens counter."""
|
|
960
1132
|
self.input_tokens = 0
|
|
@@ -962,7 +1134,5 @@ class Chat:
|
|
|
962
1134
|
def get_used_tokens(self) -> int:
|
|
963
1135
|
"""
|
|
964
1136
|
Return the locally estimated input tokens count.
|
|
965
|
-
|
|
966
|
-
:return: Input tokens int
|
|
967
1137
|
"""
|
|
968
1138
|
return self.input_tokens
|