pygpt-net 2.7.5__py3-none-any.whl → 2.7.7__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.
Files changed (82) hide show
  1. pygpt_net/CHANGELOG.txt +14 -0
  2. pygpt_net/__init__.py +4 -4
  3. pygpt_net/controller/chat/remote_tools.py +3 -9
  4. pygpt_net/controller/chat/stream.py +2 -2
  5. pygpt_net/controller/chat/{handler/worker.py → stream_worker.py} +20 -64
  6. pygpt_net/controller/debug/fixtures.py +3 -2
  7. pygpt_net/controller/files/files.py +65 -4
  8. pygpt_net/core/debug/models.py +2 -2
  9. pygpt_net/core/filesystem/url.py +4 -1
  10. pygpt_net/core/render/web/body.py +3 -2
  11. pygpt_net/core/types/chunk.py +27 -0
  12. pygpt_net/data/config/config.json +14 -4
  13. pygpt_net/data/config/models.json +192 -4
  14. pygpt_net/data/config/settings.json +126 -36
  15. pygpt_net/data/js/app/template.js +1 -1
  16. pygpt_net/data/js/app.min.js +2 -2
  17. pygpt_net/data/locale/locale.de.ini +5 -0
  18. pygpt_net/data/locale/locale.en.ini +35 -8
  19. pygpt_net/data/locale/locale.es.ini +5 -0
  20. pygpt_net/data/locale/locale.fr.ini +5 -0
  21. pygpt_net/data/locale/locale.it.ini +5 -0
  22. pygpt_net/data/locale/locale.pl.ini +5 -0
  23. pygpt_net/data/locale/locale.uk.ini +5 -0
  24. pygpt_net/data/locale/locale.zh.ini +5 -0
  25. pygpt_net/data/locale/plugin.cmd_mouse_control.en.ini +2 -2
  26. pygpt_net/item/ctx.py +3 -5
  27. pygpt_net/js_rc.py +2449 -2447
  28. pygpt_net/plugin/cmd_mouse_control/config.py +8 -7
  29. pygpt_net/plugin/cmd_mouse_control/plugin.py +3 -4
  30. pygpt_net/plugin/cmd_mouse_control/worker.py +2 -1
  31. pygpt_net/plugin/cmd_mouse_control/worker_sandbox.py +2 -1
  32. pygpt_net/provider/api/anthropic/__init__.py +16 -9
  33. pygpt_net/provider/api/anthropic/chat.py +259 -11
  34. pygpt_net/provider/api/anthropic/computer.py +844 -0
  35. pygpt_net/provider/api/anthropic/remote_tools.py +172 -0
  36. pygpt_net/{controller/chat/handler/anthropic_stream.py → provider/api/anthropic/stream.py} +24 -10
  37. pygpt_net/provider/api/anthropic/tools.py +32 -77
  38. pygpt_net/provider/api/anthropic/utils.py +30 -0
  39. pygpt_net/provider/api/google/__init__.py +6 -5
  40. pygpt_net/provider/api/google/chat.py +3 -8
  41. pygpt_net/{controller/chat/handler/google_stream.py → provider/api/google/stream.py} +1 -1
  42. pygpt_net/provider/api/google/utils.py +185 -0
  43. pygpt_net/{controller/chat/handler → provider/api/langchain}/__init__.py +0 -0
  44. pygpt_net/{controller/chat/handler/langchain_stream.py → provider/api/langchain/stream.py} +1 -1
  45. pygpt_net/provider/api/llama_index/__init__.py +0 -0
  46. pygpt_net/{controller/chat/handler/llamaindex_stream.py → provider/api/llama_index/stream.py} +1 -1
  47. pygpt_net/provider/api/openai/__init__.py +7 -3
  48. pygpt_net/provider/api/openai/image.py +2 -2
  49. pygpt_net/provider/api/openai/responses.py +0 -0
  50. pygpt_net/{controller/chat/handler/openai_stream.py → provider/api/openai/stream.py} +1 -1
  51. pygpt_net/provider/api/openai/utils.py +69 -3
  52. pygpt_net/provider/api/x_ai/__init__.py +117 -17
  53. pygpt_net/provider/api/x_ai/chat.py +272 -102
  54. pygpt_net/provider/api/x_ai/image.py +149 -47
  55. pygpt_net/provider/api/x_ai/{remote.py → remote_tools.py} +165 -70
  56. pygpt_net/provider/api/x_ai/responses.py +507 -0
  57. pygpt_net/provider/api/x_ai/stream.py +715 -0
  58. pygpt_net/provider/api/x_ai/tools.py +59 -8
  59. pygpt_net/{controller/chat/handler → provider/api/x_ai}/utils.py +1 -2
  60. pygpt_net/provider/api/x_ai/vision.py +1 -4
  61. pygpt_net/provider/core/config/patch.py +22 -1
  62. pygpt_net/provider/core/model/patch.py +26 -1
  63. pygpt_net/tools/image_viewer/ui/dialogs.py +300 -13
  64. pygpt_net/tools/text_editor/ui/dialogs.py +3 -2
  65. pygpt_net/tools/text_editor/ui/widgets.py +5 -1
  66. pygpt_net/ui/base/context_menu.py +44 -1
  67. pygpt_net/ui/layout/toolbox/indexes.py +22 -19
  68. pygpt_net/ui/layout/toolbox/model.py +28 -5
  69. pygpt_net/ui/widget/dialog/base.py +16 -5
  70. pygpt_net/ui/widget/image/display.py +25 -8
  71. pygpt_net/ui/widget/tabs/output.py +9 -1
  72. pygpt_net/ui/widget/textarea/editor.py +14 -1
  73. pygpt_net/ui/widget/textarea/input.py +20 -7
  74. pygpt_net/ui/widget/textarea/notepad.py +24 -1
  75. pygpt_net/ui/widget/textarea/output.py +23 -1
  76. pygpt_net/ui/widget/textarea/web.py +16 -1
  77. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/METADATA +16 -2
  78. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/RECORD +80 -73
  79. pygpt_net/controller/chat/handler/xai_stream.py +0 -135
  80. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/LICENSE +0 -0
  81. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/WHEEL +0 -0
  82. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.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: 2025.09.05 01:00:00 #
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
- return chat.sample()
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
- # SDK proto fallback (defensive)
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
- t = int(getattr(usage, "total_tokens", (p + c)) or (p + c))
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": 0,
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": fn.get("arguments") or "{}"},
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
- # delta or message styles
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
- # usage + citations tail
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
- t = _as_int(tot if tot is not None else (i + o))
711
- return {"in": i, "out": max(0, t - i) if t else o, "reasoning": 0, "total": t}
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