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.
Files changed (51) hide show
  1. pygpt_net/CHANGELOG.txt +8 -0
  2. pygpt_net/__init__.py +2 -2
  3. pygpt_net/controller/chat/handler/worker.py +9 -31
  4. pygpt_net/controller/chat/handler/xai_stream.py +621 -52
  5. pygpt_net/controller/debug/fixtures.py +3 -2
  6. pygpt_net/controller/files/files.py +65 -4
  7. pygpt_net/core/filesystem/url.py +4 -1
  8. pygpt_net/core/render/web/body.py +3 -2
  9. pygpt_net/core/types/chunk.py +27 -0
  10. pygpt_net/data/config/config.json +2 -2
  11. pygpt_net/data/config/models.json +2 -2
  12. pygpt_net/data/config/settings.json +1 -1
  13. pygpt_net/data/js/app/template.js +1 -1
  14. pygpt_net/data/js/app.min.js +2 -2
  15. pygpt_net/data/locale/locale.de.ini +3 -0
  16. pygpt_net/data/locale/locale.en.ini +3 -0
  17. pygpt_net/data/locale/locale.es.ini +3 -0
  18. pygpt_net/data/locale/locale.fr.ini +3 -0
  19. pygpt_net/data/locale/locale.it.ini +3 -0
  20. pygpt_net/data/locale/locale.pl.ini +3 -0
  21. pygpt_net/data/locale/locale.uk.ini +3 -0
  22. pygpt_net/data/locale/locale.zh.ini +3 -0
  23. pygpt_net/data/locale/plugin.cmd_mouse_control.en.ini +2 -2
  24. pygpt_net/item/ctx.py +3 -5
  25. pygpt_net/js_rc.py +2449 -2447
  26. pygpt_net/plugin/cmd_mouse_control/config.py +8 -7
  27. pygpt_net/plugin/cmd_mouse_control/plugin.py +3 -4
  28. pygpt_net/provider/api/anthropic/__init__.py +10 -8
  29. pygpt_net/provider/api/google/__init__.py +6 -5
  30. pygpt_net/provider/api/google/chat.py +1 -2
  31. pygpt_net/provider/api/openai/__init__.py +7 -3
  32. pygpt_net/provider/api/openai/responses.py +0 -0
  33. pygpt_net/provider/api/x_ai/__init__.py +10 -9
  34. pygpt_net/provider/api/x_ai/chat.py +272 -102
  35. pygpt_net/tools/image_viewer/ui/dialogs.py +298 -12
  36. pygpt_net/tools/text_editor/ui/widgets.py +5 -1
  37. pygpt_net/ui/base/context_menu.py +44 -1
  38. pygpt_net/ui/layout/toolbox/indexes.py +22 -19
  39. pygpt_net/ui/layout/toolbox/model.py +28 -5
  40. pygpt_net/ui/widget/image/display.py +25 -8
  41. pygpt_net/ui/widget/tabs/output.py +9 -1
  42. pygpt_net/ui/widget/textarea/editor.py +14 -1
  43. pygpt_net/ui/widget/textarea/input.py +20 -7
  44. pygpt_net/ui/widget/textarea/notepad.py +24 -1
  45. pygpt_net/ui/widget/textarea/output.py +23 -1
  46. pygpt_net/ui/widget/textarea/web.py +16 -1
  47. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.6.dist-info}/METADATA +10 -2
  48. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.6.dist-info}/RECORD +50 -49
  49. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.6.dist-info}/LICENSE +0 -0
  50. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.6.dist-info}/WHEEL +0 -0
  51. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.6.dist-info}/entry_points.txt +0 -0
@@ -6,10 +6,270 @@
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 00:00:00 #
9
+ # Updated Date: 2026.01.03 17:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from typing import Optional
12
+ from typing import Optional, List, Dict, Any
13
+ import base64
14
+ import re
15
+
16
+
17
+ def _stringify_content(content) -> Optional[str]:
18
+ """
19
+ Convert various xAI content shapes into a plain text string.
20
+ Handles:
21
+ - str
22
+ - list of parts (dicts with {'type':'text','text':...} or str)
23
+ - objects with .text or .content attributes
24
+ - dict with 'text' or nested shapes
25
+ """
26
+ try:
27
+ if content is None:
28
+ return None
29
+ if isinstance(content, str):
30
+ return content
31
+ if isinstance(content, list):
32
+ buf: List[str] = []
33
+ for p in content:
34
+ if isinstance(p, str):
35
+ buf.append(p)
36
+ elif isinstance(p, dict):
37
+ if isinstance(p.get("text"), str):
38
+ buf.append(p["text"])
39
+ elif isinstance(p.get("content"), str):
40
+ buf.append(p["content"])
41
+ elif isinstance(p.get("delta"), str):
42
+ buf.append(p["delta"])
43
+ else:
44
+ t = getattr(p, "text", None)
45
+ if isinstance(t, str):
46
+ buf.append(t)
47
+ return "".join(buf) if buf else None
48
+ if isinstance(content, dict):
49
+ if isinstance(content.get("text"), str):
50
+ return content["text"]
51
+ if isinstance(content.get("content"), str):
52
+ return content["content"]
53
+ if isinstance(content.get("delta"), str):
54
+ return content["delta"]
55
+ t = getattr(content, "text", None)
56
+ if isinstance(t, str):
57
+ return t
58
+ c = getattr(content, "content", None)
59
+ if isinstance(c, str):
60
+ return c
61
+ return str(content)
62
+ except Exception:
63
+ return None
64
+
65
+
66
+ def _extract_http_urls_from_text(text: Optional[str]) -> List[str]:
67
+ """
68
+ Extract http(s) URLs from plain text.
69
+ """
70
+ if not text or not isinstance(text, str):
71
+ return []
72
+ # Basic, conservative URL regex
73
+ pattern = re.compile(r"(https?://[^\s)>\]\"']+)", re.IGNORECASE)
74
+ urls = pattern.findall(text)
75
+ # Deduplicate while preserving order
76
+ out, seen = [], set()
77
+ for u in urls:
78
+ if u not in seen:
79
+ out.append(u)
80
+ seen.add(u)
81
+ return out
82
+
83
+
84
+ def _append_urls(ctx, state, urls: List[str]):
85
+ """
86
+ Merge a list of URLs into state.citations and ctx.urls (unique, http/https only).
87
+ """
88
+ if not urls:
89
+ return
90
+ if not isinstance(state.citations, list):
91
+ state.citations = []
92
+ if ctx.urls is None:
93
+ ctx.urls = []
94
+ seen = set(state.citations) | set(ctx.urls)
95
+ for u in urls:
96
+ if not isinstance(u, str):
97
+ continue
98
+ if not (u.startswith("http://") or u.startswith("https://")):
99
+ continue
100
+ if u in seen:
101
+ continue
102
+ state.citations.append(u)
103
+ ctx.urls.append(u)
104
+ seen.add(u)
105
+
106
+
107
+ def _try_save_data_url_image(core, ctx, data_url: str) -> Optional[str]:
108
+ """
109
+ Save data:image/*;base64,... to file and return path.
110
+ """
111
+ try:
112
+ if not data_url.startswith("data:image/"):
113
+ return None
114
+ header, b64 = data_url.split(",", 1)
115
+ ext = "png"
116
+ if ";base64" in header:
117
+ mime = header.split(";")[0].split(":")[1].lower()
118
+ if "jpeg" in mime or "jpg" in mime:
119
+ ext = "jpg"
120
+ elif "png" in mime:
121
+ ext = "png"
122
+ img_bytes = base64.b64decode(b64)
123
+ save_path = core.image.gen_unique_path(ctx, ext=ext)
124
+ with open(save_path, "wb") as f:
125
+ f.write(img_bytes)
126
+ return save_path
127
+ except Exception:
128
+ return None
129
+
130
+
131
+ def _process_message_content_for_outputs(core, ctx, state, content):
132
+ """
133
+ Inspect assistant message content (list of parts) for image_url outputs and URLs.
134
+ - If image_url.url is data:... -> save to file and append to state.image_paths + ctx.images
135
+ - If image_url.url is http(s) -> append to ctx.urls
136
+ - Extract URLs from adjacent text parts conservatively
137
+ """
138
+ if not isinstance(content, list):
139
+ return
140
+ any_image = False
141
+ for p in content:
142
+ if not isinstance(p, dict):
143
+ continue
144
+ ptype = p.get("type")
145
+ if ptype == "image_url":
146
+ img = p.get("image_url") or {}
147
+ url = img.get("url")
148
+ if isinstance(url, str):
149
+ if url.startswith("data:image/"):
150
+ path = _try_save_data_url_image(core, ctx, url)
151
+ if path:
152
+ if not isinstance(ctx.images, list):
153
+ ctx.images = []
154
+ ctx.images.append(path)
155
+ state.image_paths.append(path)
156
+ any_image = True
157
+ elif url.startswith("http://") or url.startswith("https://"):
158
+ _append_urls(ctx, state, [url])
159
+ elif ptype == "text":
160
+ t = p.get("text")
161
+ if isinstance(t, str):
162
+ urls = _extract_http_urls_from_text(t)
163
+ if urls:
164
+ _append_urls(ctx, state, urls)
165
+ # If images were added, mark flag similarly to Google path
166
+ if any_image:
167
+ try:
168
+ state.has_xai_inline_image = True
169
+ except Exception:
170
+ pass
171
+
172
+
173
+ def _merge_tool_calls(state, new_calls: List[Dict[str, Any]]):
174
+ """
175
+ Merge a list of tool_calls (dict-like) into state.tool_calls with de-duplication and streaming concat of arguments.
176
+ """
177
+ if not new_calls:
178
+ return
179
+ if not isinstance(state.tool_calls, list):
180
+ state.tool_calls = []
181
+
182
+ def _norm(tc: Dict[str, Any]) -> Dict[str, Any]:
183
+ fn = tc.get("function") or {}
184
+ args = fn.get("arguments")
185
+ if isinstance(args, (dict, list)):
186
+ try:
187
+ import json as _json
188
+ args = _json.dumps(args, ensure_ascii=False)
189
+ except Exception:
190
+ args = str(args)
191
+ return {
192
+ "id": tc.get("id") or "",
193
+ "type": "function",
194
+ "function": {
195
+ "name": fn.get("name") or "",
196
+ "arguments": args or "",
197
+ },
198
+ }
199
+
200
+ def _find_existing(key_id: str, key_name: str) -> Optional[Dict[str, Any]]:
201
+ if not state.tool_calls:
202
+ return None
203
+ for ex in state.tool_calls:
204
+ if key_id and ex.get("id") == key_id:
205
+ return ex
206
+ if key_name and ex.get("function", {}).get("name") == key_name:
207
+ # name match as fallback for SDKs that stream without ids
208
+ return ex
209
+ return None
210
+
211
+ for tc in new_calls:
212
+ if not isinstance(tc, dict):
213
+ continue
214
+ nid = tc.get("id") or ""
215
+ fn = tc.get("function") or {}
216
+ nname = fn.get("name") or ""
217
+ nargs = fn.get("arguments")
218
+ if isinstance(nargs, (dict, list)):
219
+ try:
220
+ import json as _json
221
+ nargs = _json.dumps(nargs, ensure_ascii=False)
222
+ except Exception:
223
+ nargs = str(nargs)
224
+
225
+ existing = _find_existing(nid, nname)
226
+ if existing is None:
227
+ state.tool_calls.append(_norm(tc))
228
+ else:
229
+ if nname:
230
+ existing["function"]["name"] = nname
231
+ if nargs:
232
+ existing["function"]["arguments"] = (existing["function"].get("arguments", "") or "") + str(nargs)
233
+
234
+
235
+ def _maybe_collect_tail_meta(state, obj: Dict[str, Any], ctx=None):
236
+ """
237
+ Collect tail metadata like citations and usage into state (and ctx for urls), if these fields exist.
238
+ """
239
+ try:
240
+ if not isinstance(obj, dict):
241
+ return
242
+ if "citations" in obj:
243
+ c = obj.get("citations") or []
244
+ if isinstance(c, list):
245
+ if ctx is not None:
246
+ _append_urls(ctx, state, [u for u in c if isinstance(u, str)])
247
+ else:
248
+ try:
249
+ setattr(state, "xai_stream_citations", c)
250
+ except Exception:
251
+ pass
252
+ if "usage" in obj and isinstance(obj["usage"], dict):
253
+ try:
254
+ state.usage_vendor = "xai"
255
+ u = obj["usage"]
256
+ def _as_int(v):
257
+ try:
258
+ return int(v)
259
+ except Exception:
260
+ try:
261
+ return int(float(v))
262
+ except Exception:
263
+ return 0
264
+ p = _as_int(u.get("prompt_tokens") or u.get("input_tokens") or 0)
265
+ c = _as_int(u.get("completion_tokens") or u.get("output_tokens") or 0)
266
+ r = _as_int(u.get("reasoning_tokens") or 0)
267
+ t = _as_int(u.get("total_tokens") or (p + c + r))
268
+ state.usage_payload = {"in": p, "out": max(0, t - p) if t else c, "reasoning": r, "total": t}
269
+ except Exception:
270
+ pass
271
+ except Exception:
272
+ pass
13
273
 
14
274
 
15
275
  def process_xai_sdk_chunk(ctx, core, state, item) -> Optional[str]:
@@ -19,34 +279,294 @@ def process_xai_sdk_chunk(ctx, core, state, item) -> Optional[str]:
19
279
  :param ctx: Chat context
20
280
  :param core: Core controller
21
281
  :param state: Chat state
22
- :param item: Incoming streaming chunk (tuple of (response, chunk))
282
+ :param item: Incoming streaming chunk; supports:
283
+ - tuple(response, chunk) [old/new SDK style]
284
+ - chunk object with .delta/.content/.tool_calls/.tool_outputs/.citations
285
+ - dict/SimpleNamespace with OpenAI-like choices[0].delta.content
286
+ - dict event style with root 'delta' or 'message'
23
287
  :return: Extracted text delta or None
24
288
  """
289
+ response = None
290
+ chunk = None
25
291
  try:
26
- response, chunk = item
292
+ if isinstance(item, (list, tuple)) and len(item) == 2:
293
+ response, chunk = item
294
+ else:
295
+ chunk = item
27
296
  except Exception:
28
297
  return None
29
298
 
30
- state.xai_last_response = response
299
+ try:
300
+ if response is not None:
301
+ state.xai_last_response = response
302
+ except Exception:
303
+ pass
31
304
 
305
+ # Citations at chunk-level (last chunk in live search)
32
306
  try:
33
- if hasattr(chunk, "content") and chunk.content is not None:
34
- return str(chunk.content)
35
- if isinstance(chunk, str):
36
- return chunk
307
+ cites = getattr(chunk, "citations", None)
308
+ if cites and isinstance(cites, list):
309
+ _append_urls(ctx, state, [u for u in cites if isinstance(u, str)])
37
310
  except Exception:
38
311
  pass
312
+
313
+ # Tool calls emitted as dedicated SDK objects
314
+ try:
315
+ tc_list = getattr(chunk, "tool_calls", None) or []
316
+ if tc_list:
317
+ norm_list = []
318
+ for tc in tc_list:
319
+ if tc is None:
320
+ continue
321
+ fn = getattr(tc, "function", None)
322
+ name = getattr(fn, "name", "") if fn is not None else ""
323
+ args = getattr(fn, "arguments", "") if fn is not None else ""
324
+ if isinstance(args, (dict, list)):
325
+ try:
326
+ import json as _json
327
+ args = _json.dumps(args, ensure_ascii=False)
328
+ except Exception:
329
+ args = str(args)
330
+ norm_list.append({
331
+ "id": getattr(tc, "id", "") or "",
332
+ "type": "function",
333
+ "function": {"name": name or "", "arguments": args or ""},
334
+ })
335
+ if norm_list:
336
+ _merge_tool_calls(state, norm_list)
337
+ state.force_func_call = True
338
+ except Exception:
339
+ pass
340
+
341
+ # Tool outputs: scan for URLs to enrich ctx.urls (best effort)
342
+ try:
343
+ to_list = getattr(chunk, "tool_outputs", None) or []
344
+ for to in to_list:
345
+ content = getattr(to, "content", None)
346
+ if isinstance(content, str):
347
+ _append_urls(ctx, state, _extract_http_urls_from_text(content))
348
+ except Exception:
349
+ pass
350
+
351
+ # 1) Direct .content (SDK chunk)
352
+ try:
353
+ if hasattr(chunk, "content"):
354
+ t = _stringify_content(getattr(chunk, "content"))
355
+ if t:
356
+ # collect URLs from text content conservatively
357
+ _append_urls(ctx, state, _extract_http_urls_from_text(t))
358
+ return str(t)
359
+ except Exception:
360
+ pass
361
+
362
+ # 2) .delta object or dict
363
+ try:
364
+ delta = getattr(chunk, "delta", None)
365
+ if delta is not None:
366
+ try:
367
+ tc = delta.get("tool_calls") if isinstance(delta, dict) else getattr(delta, "tool_calls", None)
368
+ if tc:
369
+ if isinstance(tc, list):
370
+ # already OpenAI-like dicts
371
+ _merge_tool_calls(state, tc)
372
+ state.force_func_call = True
373
+ except Exception:
374
+ pass
375
+
376
+ dc = delta.get("content") if isinstance(delta, dict) else getattr(delta, "content", None)
377
+ if dc is not None:
378
+ t = _stringify_content(dc)
379
+ if t:
380
+ _append_urls(ctx, state, _extract_http_urls_from_text(t))
381
+ return str(t)
382
+ if isinstance(delta, str) and delta:
383
+ _append_urls(ctx, state, _extract_http_urls_from_text(delta))
384
+ return delta
385
+ except Exception:
386
+ pass
387
+
388
+ # 3) OpenAI-like dict with choices[0].delta or choices[0].message
389
+ try:
390
+ if isinstance(chunk, dict):
391
+ # tools/citations/usage meta
392
+ try:
393
+ chs = chunk.get("choices") or []
394
+ if chs:
395
+ candidate = chs[0] or {}
396
+ tc = (candidate.get("delta") or {}).get("tool_calls") or candidate.get("tool_calls") or []
397
+ if tc:
398
+ _merge_tool_calls(state, tc)
399
+ state.force_func_call = True
400
+ except Exception:
401
+ pass
402
+
403
+ # text delta/message content
404
+ chs = chunk.get("choices") or []
405
+ if chs:
406
+ first = chs[0] or {}
407
+ d = first.get("delta") or {}
408
+ m = first.get("message") or {}
409
+ if "content" in d and d["content"] is not None:
410
+ t = _stringify_content(d["content"])
411
+ if t:
412
+ _append_urls(ctx, state, _extract_http_urls_from_text(t))
413
+ return str(t)
414
+ if "content" in m and m["content"] is not None:
415
+ mc = m["content"]
416
+ # inspect for image_url outputs and URLs
417
+ _process_message_content_for_outputs(core, ctx, state, mc if isinstance(mc, list) else [])
418
+ if isinstance(mc, str):
419
+ _append_urls(ctx, state, _extract_http_urls_from_text(mc))
420
+ return mc
421
+ elif isinstance(mc, list):
422
+ out_parts: List[str] = []
423
+ for p in mc:
424
+ if isinstance(p, dict) and p.get("type") == "text":
425
+ t = p.get("text")
426
+ if isinstance(t, str):
427
+ out_parts.append(t)
428
+ if out_parts:
429
+ txt = "".join(out_parts)
430
+ _append_urls(ctx, state, _extract_http_urls_from_text(txt))
431
+ return txt
432
+
433
+ # root-level delta/message
434
+ if isinstance(chunk.get("delta"), dict) and "content" in chunk["delta"]:
435
+ t = _stringify_content(chunk["delta"]["content"])
436
+ if t:
437
+ _append_urls(ctx, state, _extract_http_urls_from_text(t))
438
+ return str(t)
439
+ if isinstance(chunk.get("message"), dict) and "content" in chunk["message"]:
440
+ mc = chunk["message"]["content"]
441
+ _process_message_content_for_outputs(core, ctx, state, mc if isinstance(mc, list) else [])
442
+ if isinstance(mc, str):
443
+ _append_urls(ctx, state, _extract_http_urls_from_text(mc))
444
+ return mc
445
+
446
+ # tail metadata: citations and usage
447
+ _maybe_collect_tail_meta(state, chunk, ctx=ctx)
448
+ except Exception:
449
+ pass
450
+
451
+ # 4) SimpleNamespace with choices[0].delta/message
452
+ try:
453
+ chs = getattr(chunk, "choices", None)
454
+ if chs:
455
+ first = chs[0]
456
+ delta = getattr(first, "delta", None)
457
+ message = getattr(first, "message", None)
458
+ if delta is not None:
459
+ try:
460
+ tc = getattr(delta, "tool_calls", None)
461
+ if tc:
462
+ _merge_tool_calls(state, tc if isinstance(tc, list) else [])
463
+ state.force_func_call = True
464
+ except Exception:
465
+ pass
466
+ c = getattr(delta, "content", None)
467
+ if c is not None:
468
+ t = _stringify_content(c)
469
+ if t:
470
+ _append_urls(ctx, state, _extract_http_urls_from_text(t))
471
+ return str(t)
472
+ if message is not None:
473
+ c = getattr(message, "content", None)
474
+ if c is not None:
475
+ if isinstance(c, list):
476
+ _process_message_content_for_outputs(core, ctx, state, c)
477
+ # optional: also extract text parts
478
+ out_parts: List[str] = []
479
+ for p in c:
480
+ if isinstance(p, dict) and p.get("type") == "text":
481
+ t = p.get("text")
482
+ if isinstance(t, str):
483
+ out_parts.append(t)
484
+ if out_parts:
485
+ txt = "".join(out_parts)
486
+ _append_urls(ctx, state, _extract_http_urls_from_text(txt))
487
+ return txt
488
+ else:
489
+ t = _stringify_content(c)
490
+ if t:
491
+ _append_urls(ctx, state, _extract_http_urls_from_text(t))
492
+ return str(t)
493
+ except Exception:
494
+ pass
495
+
496
+ # 5) Plain string
497
+ if isinstance(chunk, str):
498
+ _append_urls(ctx, state, _extract_http_urls_from_text(chunk))
499
+ return chunk
500
+
39
501
  return None
40
502
 
41
503
 
42
504
  def xai_extract_tool_calls(response) -> list[dict]:
43
505
  """
44
- Extract tool calls from xAI SDK final response (proto).
506
+ Extract tool calls from xAI final response (proto or modern SDK/message shapes).
45
507
 
46
- :param response: xAI final response object
508
+ :param response: xAI final response object or dict
47
509
  :return: List of tool calls in normalized dict format
48
510
  """
49
511
  out: list[dict] = []
512
+
513
+ def _append_from_msg(msg_obj):
514
+ try:
515
+ if not msg_obj:
516
+ return
517
+ tcs = getattr(msg_obj, "tool_calls", None)
518
+ if not tcs and isinstance(msg_obj, dict):
519
+ tcs = msg_obj.get("tool_calls")
520
+ if not tcs:
521
+ return
522
+ for tc in tcs:
523
+ try:
524
+ fn = getattr(tc, "function", None)
525
+ if isinstance(tc, dict):
526
+ fn = tc.get("function", fn)
527
+ name = getattr(fn, "name", None) if fn is not None else None
528
+ args = getattr(fn, "arguments", None) if fn is not None else None
529
+ if isinstance(fn, dict):
530
+ name = fn.get("name", name)
531
+ args = fn.get("arguments", args)
532
+ if isinstance(args, (dict, list)):
533
+ try:
534
+ import json as _json
535
+ args = _json.dumps(args, ensure_ascii=False)
536
+ except Exception:
537
+ args = str(args)
538
+ out.append({
539
+ "id": (getattr(tc, "id", None) if not isinstance(tc, dict) else tc.get("id")) or "",
540
+ "type": "function",
541
+ "function": {"name": name or "", "arguments": args or "{}"},
542
+ })
543
+ except Exception:
544
+ continue
545
+ except Exception:
546
+ pass
547
+
548
+ try:
549
+ if isinstance(response, dict):
550
+ ch = (response.get("choices") or [])
551
+ if ch:
552
+ _append_from_msg(ch[0].get("message") or {})
553
+ if "message" in response:
554
+ _append_from_msg(response.get("message"))
555
+ if "output_message" in response:
556
+ _append_from_msg(response.get("output_message"))
557
+ if out:
558
+ return out
559
+ except Exception:
560
+ pass
561
+
562
+ try:
563
+ _append_from_msg(getattr(response, "message", None))
564
+ _append_from_msg(getattr(response, "output_message", None))
565
+ if out:
566
+ return out
567
+ except Exception:
568
+ pass
569
+
50
570
  try:
51
571
  proto = getattr(response, "proto", None)
52
572
  if not proto:
@@ -55,20 +575,7 @@ def xai_extract_tool_calls(response) -> list[dict]:
55
575
  if not choices:
56
576
  return out
57
577
  msg = getattr(choices[0], "message", None)
58
- if not msg:
59
- return out
60
- tool_calls = getattr(msg, "tool_calls", None) or []
61
- for tc in tool_calls:
62
- try:
63
- name = getattr(getattr(tc, "function", None), "name", "") or ""
64
- args = getattr(getattr(tc, "function", None), "arguments", "") or "{}"
65
- out.append({
66
- "id": getattr(tc, "id", "") or "",
67
- "type": "function",
68
- "function": {"name": name, "arguments": args},
69
- })
70
- except Exception:
71
- continue
578
+ _append_from_msg(msg)
72
579
  except Exception:
73
580
  pass
74
581
  return out
@@ -81,24 +588,63 @@ def xai_extract_citations(response) -> list[str]:
81
588
  :param response: xAI final response object
82
589
  :return: List of citation URLs
83
590
  """
591
+ def _norm_urls(raw) -> List[str]:
592
+ urls: List[str] = []
593
+ seen = set()
594
+ if isinstance(raw, list):
595
+ for it in raw:
596
+ u = None
597
+ if isinstance(it, str):
598
+ u = it
599
+ elif isinstance(it, dict):
600
+ u = (it.get("url") or it.get("uri") or
601
+ (it.get("source") or {}).get("url") or (it.get("source") or {}).get("uri"))
602
+ if isinstance(u, str) and (u.startswith("http://") or u.startswith("https://")):
603
+ if u not in seen:
604
+ urls.append(u); seen.add(u)
605
+ return urls
606
+
84
607
  urls: list[str] = []
85
608
  try:
86
609
  cites = getattr(response, "citations", None)
87
- if isinstance(cites, (list, tuple)):
88
- for u in cites:
89
- if isinstance(u, str) and (u.startswith("http://") or u.startswith("https://")):
90
- if u not in urls:
91
- urls.append(u)
610
+ if cites is None and isinstance(response, dict):
611
+ cites = response.get("citations")
612
+ urls.extend([u for u in _norm_urls(cites or []) if u not in urls])
613
+ except Exception:
614
+ pass
615
+
616
+ try:
617
+ msg = getattr(response, "message", None)
618
+ if msg is None and isinstance(response, dict):
619
+ msg = response.get("message")
620
+ if msg:
621
+ mc = getattr(msg, "citations", None)
622
+ if mc is None and isinstance(msg, dict):
623
+ mc = msg.get("citations")
624
+ urls.extend([u for u in _norm_urls(mc or []) if u not in urls])
92
625
  except Exception:
93
626
  pass
627
+
628
+ try:
629
+ out_msg = getattr(response, "output_message", None)
630
+ if out_msg:
631
+ mc = getattr(out_msg, "citations", None)
632
+ if mc is None and isinstance(out_msg, dict):
633
+ mc = out_msg.get("citations")
634
+ urls.extend([u for u in _norm_urls(mc or []) if u not in urls])
635
+ except Exception:
636
+ pass
637
+
94
638
  try:
95
639
  proto = getattr(response, "proto", None)
96
640
  if proto:
97
641
  proto_cites = getattr(proto, "citations", None) or []
98
- for u in proto_cites:
99
- if isinstance(u, str) and (u.startswith("http://") or u.startswith("https://")):
100
- if u not in urls:
101
- urls.append(u)
642
+ urls.extend([u for u in _norm_urls(proto_cites) if u not in urls])
643
+ choices = getattr(proto, "choices", None) or []
644
+ if choices:
645
+ m = getattr(choices[0], "message", None)
646
+ if m:
647
+ urls.extend([u for u in _norm_urls(getattr(m, "citations", None) or []) if u not in urls])
102
648
  except Exception:
103
649
  pass
104
650
  return urls
@@ -106,30 +652,53 @@ def xai_extract_citations(response) -> list[str]:
106
652
 
107
653
  def xai_extract_usage(response) -> dict:
108
654
  """
109
- Extract usage from xAI final response via proto. Return {'in','out','reasoning','total'}.
655
+ Extract usage from xAI final response via proto or modern usage fields. Return {'in','out','reasoning','total'}.
110
656
 
111
657
  :param response: xAI final response object
112
658
  :return: Usage dict
113
659
  """
114
- try:
115
- proto = getattr(response, "proto", None)
116
- usage = getattr(proto, "usage", None) if proto else None
117
- if not usage:
118
- return {}
119
-
120
- def _as_int(v):
660
+ def _as_int(v):
661
+ try:
662
+ return int(v)
663
+ except Exception:
121
664
  try:
122
- return int(v)
665
+ return int(float(v))
123
666
  except Exception:
124
- try:
125
- return int(float(v))
126
- except Exception:
127
- return 0
667
+ return 0
128
668
 
129
- p = _as_int(getattr(usage, "prompt_tokens", 0) or 0)
130
- c = _as_int(getattr(usage, "completion_tokens", 0) or 0)
131
- t = _as_int(getattr(usage, "total_tokens", (p + c)) or (p + c))
669
+ def _from_usage_dict(usage: Dict[str, Any]) -> dict:
670
+ p = usage.get("prompt_tokens", usage.get("input_tokens", 0)) or 0
671
+ c = usage.get("completion_tokens", usage.get("output_tokens", 0)) or 0
672
+ r = usage.get("reasoning_tokens", 0) or 0
673
+ t = usage.get("total_tokens")
674
+ p = _as_int(p); c = _as_int(c); r = _as_int(r)
675
+ t = _as_int(t if t is not None else (p + c + r))
132
676
  out_total = max(0, t - p) if t else c
133
- return {"in": p, "out": out_total, "reasoning": 0, "total": t}
677
+ return {"in": p, "out": out_total, "reasoning": r, "total": t}
678
+
679
+ if isinstance(response, dict):
680
+ u = response.get("usage")
681
+ if isinstance(u, dict):
682
+ return _from_usage_dict(u)
683
+
684
+ try:
685
+ u = getattr(response, "usage", None)
686
+ if isinstance(u, dict):
687
+ return _from_usage_dict(u)
134
688
  except Exception:
135
- return {}
689
+ pass
690
+
691
+ try:
692
+ proto = getattr(response, "proto", None)
693
+ usage = getattr(proto, "usage", None) if proto else None
694
+ if usage:
695
+ p = _as_int(getattr(usage, "prompt_tokens", 0) or 0)
696
+ c = _as_int(getattr(usage, "completion_tokens", 0) or 0)
697
+ r = _as_int(getattr(usage, "reasoning_tokens", 0) or 0)
698
+ t = _as_int(getattr(usage, "total_tokens", (p + c + r)) or (p + c + r))
699
+ out_total = max(0, t - p) if t else c
700
+ return {"in": p, "out": out_total, "reasoning": r, "total": t}
701
+ except Exception:
702
+ pass
703
+
704
+ return {}