pygpt-net 2.6.36__py3-none-any.whl → 2.6.37__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 (61) hide show
  1. pygpt_net/CHANGELOG.txt +5 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/chat/handler/anthropic_stream.py +166 -0
  4. pygpt_net/controller/chat/handler/google_stream.py +181 -0
  5. pygpt_net/controller/chat/handler/langchain_stream.py +24 -0
  6. pygpt_net/controller/chat/handler/llamaindex_stream.py +47 -0
  7. pygpt_net/controller/chat/handler/openai_stream.py +260 -0
  8. pygpt_net/controller/chat/handler/utils.py +210 -0
  9. pygpt_net/controller/chat/handler/worker.py +566 -0
  10. pygpt_net/controller/chat/handler/xai_stream.py +135 -0
  11. pygpt_net/controller/chat/stream.py +1 -1
  12. pygpt_net/controller/ctx/ctx.py +1 -1
  13. pygpt_net/controller/model/editor.py +3 -0
  14. pygpt_net/core/bridge/context.py +35 -35
  15. pygpt_net/core/bridge/worker.py +40 -16
  16. pygpt_net/core/render/web/body.py +29 -34
  17. pygpt_net/data/config/config.json +10 -3
  18. pygpt_net/data/config/models.json +3 -3
  19. pygpt_net/data/config/settings.json +105 -0
  20. pygpt_net/data/css/style.dark.css +2 -3
  21. pygpt_net/data/css/style.light.css +2 -3
  22. pygpt_net/data/locale/locale.de.ini +3 -1
  23. pygpt_net/data/locale/locale.en.ini +19 -1
  24. pygpt_net/data/locale/locale.es.ini +3 -1
  25. pygpt_net/data/locale/locale.fr.ini +3 -1
  26. pygpt_net/data/locale/locale.it.ini +3 -1
  27. pygpt_net/data/locale/locale.pl.ini +4 -2
  28. pygpt_net/data/locale/locale.uk.ini +3 -1
  29. pygpt_net/data/locale/locale.zh.ini +3 -1
  30. pygpt_net/provider/api/__init__.py +5 -3
  31. pygpt_net/provider/api/anthropic/__init__.py +190 -29
  32. pygpt_net/provider/api/anthropic/audio.py +30 -0
  33. pygpt_net/provider/api/anthropic/chat.py +341 -0
  34. pygpt_net/provider/api/anthropic/image.py +25 -0
  35. pygpt_net/provider/api/anthropic/tools.py +266 -0
  36. pygpt_net/provider/api/anthropic/vision.py +142 -0
  37. pygpt_net/provider/api/google/chat.py +2 -2
  38. pygpt_net/provider/api/google/tools.py +58 -48
  39. pygpt_net/provider/api/google/vision.py +7 -1
  40. pygpt_net/provider/api/openai/chat.py +1 -0
  41. pygpt_net/provider/api/openai/vision.py +6 -0
  42. pygpt_net/provider/api/x_ai/__init__.py +247 -0
  43. pygpt_net/provider/api/x_ai/audio.py +32 -0
  44. pygpt_net/provider/api/x_ai/chat.py +968 -0
  45. pygpt_net/provider/api/x_ai/image.py +208 -0
  46. pygpt_net/provider/api/x_ai/remote.py +262 -0
  47. pygpt_net/provider/api/x_ai/tools.py +120 -0
  48. pygpt_net/provider/api/x_ai/vision.py +119 -0
  49. pygpt_net/provider/core/config/patch.py +28 -0
  50. pygpt_net/provider/llms/anthropic.py +4 -2
  51. pygpt_net/ui/base/config_dialog.py +5 -11
  52. pygpt_net/ui/dialog/models.py +2 -4
  53. pygpt_net/ui/dialog/plugins.py +40 -43
  54. pygpt_net/ui/widget/element/labels.py +19 -3
  55. pygpt_net/ui/widget/textarea/web.py +1 -1
  56. {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.37.dist-info}/METADATA +11 -6
  57. {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.37.dist-info}/RECORD +60 -41
  58. pygpt_net/controller/chat/handler/stream_worker.py +0 -1136
  59. {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.37.dist-info}/LICENSE +0 -0
  60. {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.37.dist-info}/WHEEL +0 -0
  61. {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.37.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,968 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.09.05 01:00:00 #
10
+ # ================================================== #
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import json
16
+ import os
17
+ from typing import Optional, Dict, Any, List, Tuple
18
+
19
+ from pygpt_net.core.types import MODE_CHAT
20
+ from pygpt_net.core.bridge.context import BridgeContext, MultimodalContext
21
+ from pygpt_net.item.attachment import AttachmentItem
22
+ from pygpt_net.item.ctx import CtxItem
23
+ from pygpt_net.item.model import ModelItem
24
+
25
+ from xai_sdk.chat import system as xsystem, user as xuser, assistant as xassistant, image as ximage
26
+
27
+
28
+ class Chat:
29
+ def __init__(self, window=None):
30
+ """
31
+ Chat wrapper for xAI: SDK for plain chat, HTTP for tools/search/stream/vision.
32
+
33
+ :param window: Window instance
34
+ """
35
+ self.window = window
36
+ self.input_tokens = 0
37
+ # Image constraints (can be overridden by config keys below)
38
+ self.allowed_mimes = {"image/jpeg", "image/png"}
39
+ self.default_image_max_bytes = 10 * 1024 * 1024 # 10 MiB default
40
+
41
+ # ---------- SEND ----------
42
+
43
+ def send(self, context: BridgeContext, extra: Optional[Dict[str, Any]] = None):
44
+ """
45
+ Entry point for xAI chat/multimodal.
46
+
47
+ Streaming:
48
+ - Uses HTTP SSE for compatibility with tool calls and Live Search (citations/usage).
49
+
50
+ Non-stream:
51
+ - Uses SDK when safe (no tools, no Live Search, no tool-turns, no images).
52
+ - Otherwise uses HTTP for OpenAI-compatible tool calling/search/vision.
53
+
54
+ :param context: BridgeContext with all parameters
55
+ :param extra: Extra parameters (not used)
56
+ :return: dict with 'output_text', 'tool_calls', 'citations', 'usage' (non-stream)
57
+ or an iterable/generator yielding SDK-like response chunks (stream)
58
+ """
59
+ prompt = context.prompt
60
+ system_prompt = context.system_prompt
61
+ model_item = context.model
62
+ functions = context.external_functions
63
+ attachments = context.attachments
64
+ multimodal_ctx = context.multimodal_ctx
65
+ ctx = context.ctx or CtxItem()
66
+
67
+ client = self.window.core.api.xai.get_client(context.mode, model_item)
68
+
69
+ # Build SDK messages (used only on SDK path)
70
+ sdk_messages = self.build_messages(
71
+ prompt=prompt,
72
+ system_prompt=system_prompt,
73
+ model=model_item,
74
+ history=context.history,
75
+ attachments=attachments,
76
+ multimodal_ctx=multimodal_ctx,
77
+ )
78
+
79
+ # Local tokens estimate
80
+ self.reset_tokens()
81
+ count_msgs = self._build_count_messages(prompt, system_prompt, model_item, context.history)
82
+ self.input_tokens += self.window.core.tokens.from_messages(count_msgs, model_item.id)
83
+
84
+ # Tools and Live Search config
85
+ tools_prepared = self.window.core.api.xai.tools.prepare(functions)
86
+ rt_cfg = self.window.core.api.xai.remote.build_remote_tools(model_item)
87
+ http_params = (rt_cfg.get("http") if isinstance(rt_cfg, dict) else None)
88
+ sdk_live = bool(rt_cfg.get("sdk", {}).get("enabled")) if isinstance(rt_cfg, dict) else False
89
+
90
+ # Detect tool-turns and image attachments
91
+ has_tool_turns = self._has_tool_turns(context.history)
92
+ has_images = self._attachments_have_images(attachments)
93
+
94
+ # If images present and current model is not a vision model, select a safe fallback
95
+ model_id = model_item.id
96
+ if has_images and not self._is_vision_model(model_item):
97
+ fb = self.window.core.config.get("xai_vision_fallback_model") or "grok-2-vision-latest"
98
+ self.window.core.debug.info(f"[xai] Switching to vision model: {fb} (was: {model_id}) due to image input")
99
+ model_id = fb
100
+
101
+ # STREAM: use HTTP SSE for consistent tool/citation/vision handling
102
+ if context.stream:
103
+ return self.call_http_stream(
104
+ model=model_id,
105
+ messages=sdk_messages, # may be SDK objects; HTTP builder will rebuild if needed
106
+ tools=tools_prepared or None,
107
+ search_parameters=http_params,
108
+ temperature=context.temperature,
109
+ max_tokens=context.max_tokens,
110
+ system_prompt=system_prompt,
111
+ history=context.history,
112
+ attachments=attachments,
113
+ prompt=prompt,
114
+ )
115
+
116
+ # NON-STREAM: prefer SDK only for plain chat (no tools/search/tool-turns/images)
117
+ if sdk_live and not tools_prepared and http_params is None and not has_tool_turns and not has_images:
118
+ chat = client.chat.create(model=model_id, messages=sdk_messages)
119
+ return chat.sample()
120
+
121
+ # Otherwise HTTP non-stream for tools/search/vision/tool-turns
122
+ text, calls, citations, usage = self.call_http_nonstream(
123
+ model=model_id,
124
+ prompt=prompt,
125
+ system_prompt=system_prompt,
126
+ history=context.history,
127
+ attachments=attachments,
128
+ multimodal_ctx=multimodal_ctx,
129
+ tools=tools_prepared or [],
130
+ search_parameters=http_params,
131
+ temperature=context.temperature,
132
+ max_tokens=context.max_tokens,
133
+ )
134
+ return {
135
+ "output_text": text or "",
136
+ "tool_calls": calls or [],
137
+ "citations": citations or [],
138
+ "usage": usage or None,
139
+ }
140
+
141
+ # ---------- UNPACK (non-stream) ----------
142
+
143
+ def unpack_response(self, mode: str, response, ctx: CtxItem):
144
+ """
145
+ Unpack non-streaming xAI response into ctx (text, tool calls, usage, citations).
146
+
147
+ :param mode: mode (chat, etc)
148
+ :param response: Response object from SDK or HTTP (dict)
149
+ :param ctx: CtxItem to fill
150
+ """
151
+ # Text
152
+ txt = getattr(response, "content", None)
153
+ if not txt and isinstance(response, dict):
154
+ txt = response.get("output_text") or ""
155
+ ctx.output = (str(txt or "")).strip()
156
+
157
+ # Tool calls
158
+ calls = []
159
+ if isinstance(response, dict) and response.get("tool_calls"):
160
+ calls = response["tool_calls"]
161
+
162
+ if not calls:
163
+ # SDK proto fallback (defensive)
164
+ try:
165
+ proto = getattr(response, "proto", None)
166
+ tool_calls = getattr(getattr(getattr(proto, "choices", [None])[0], "message", None), "tool_calls", None)
167
+ if tool_calls:
168
+ out = []
169
+ for tc in tool_calls:
170
+ out.append({
171
+ "id": getattr(tc, "id", "") or "",
172
+ "type": "function",
173
+ "function": {
174
+ "name": getattr(getattr(tc, "function", None), "name", "") or "",
175
+ "arguments": getattr(getattr(tc, "function", None), "arguments", "") or "{}",
176
+ }
177
+ })
178
+ calls = out
179
+ except Exception:
180
+ pass
181
+
182
+ if calls:
183
+ ctx.tool_calls = calls
184
+
185
+ # Citations
186
+ try:
187
+ urls: List[str] = []
188
+ if isinstance(response, dict):
189
+ urls = self._extract_urls(response.get("citations"))
190
+ else:
191
+ cits = getattr(response, "citations", None)
192
+ urls = self._extract_urls(cits)
193
+ if urls:
194
+ if ctx.urls is None:
195
+ ctx.urls = []
196
+ for u in urls:
197
+ if u not in ctx.urls:
198
+ ctx.urls.append(u)
199
+ except Exception:
200
+ pass
201
+
202
+ # Usage
203
+ try:
204
+ if isinstance(response, dict) and response.get("usage"):
205
+ u = self._normalize_usage(response["usage"])
206
+ if u:
207
+ ctx.set_tokens(u.get("in", 0), u.get("out", 0))
208
+ if not isinstance(ctx.extra, dict):
209
+ ctx.extra = {}
210
+ ctx.extra["usage"] = {
211
+ "vendor": "xai",
212
+ "input_tokens": u.get("in", 0),
213
+ "output_tokens": u.get("out", 0),
214
+ "reasoning_tokens": u.get("reasoning", 0),
215
+ "total_reported": u.get("total"),
216
+ }
217
+ return
218
+
219
+ proto = getattr(response, "proto", None)
220
+ usage = getattr(proto, "usage", None)
221
+ if usage:
222
+ p = int(getattr(usage, "prompt_tokens", 0) or 0)
223
+ c = int(getattr(usage, "completion_tokens", 0) or 0)
224
+ t = int(getattr(usage, "total_tokens", (p + c)) or (p + c))
225
+ out_tok = max(0, t - p) if t else c
226
+ ctx.set_tokens(p, out_tok)
227
+ if not isinstance(ctx.extra, dict):
228
+ ctx.extra = {}
229
+ ctx.extra["usage"] = {
230
+ "vendor": "xai",
231
+ "input_tokens": p,
232
+ "output_tokens": out_tok,
233
+ "reasoning_tokens": 0,
234
+ "total_reported": t,
235
+ }
236
+ except Exception:
237
+ pass
238
+
239
+ # ---------- BUILDERS (SDK + HTTP) ----------
240
+
241
+ def build_messages(
242
+ self,
243
+ prompt: str,
244
+ system_prompt: str,
245
+ model: ModelItem,
246
+ history: Optional[List[CtxItem]] = None,
247
+ attachments: Optional[Dict[str, AttachmentItem]] = None,
248
+ multimodal_ctx: Optional[MultimodalContext] = None,
249
+ ) -> list:
250
+ """
251
+ Build xAI SDK messages list for chat.create(...).
252
+
253
+ :param prompt: Current user prompt
254
+ :param system_prompt: System prompt
255
+ :param model: ModelItem
256
+ :param history: List of CtxItem for context
257
+ :param attachments: Dict of AttachmentItem for images
258
+ :param multimodal_ctx: MultimodalContext (not used)
259
+ :return: List of SDK message objects
260
+ """
261
+ self.window.core.api.xai.vision.reset()
262
+ out = []
263
+ if system_prompt:
264
+ out.append(xsystem(system_prompt))
265
+
266
+ if self.window.core.config.get('use_context'):
267
+ used = self.window.core.tokens.from_user(prompt, system_prompt)
268
+ items = self.window.core.ctx.get_history(
269
+ history, model.id, MODE_CHAT, used, self._fit_ctx(model),
270
+ )
271
+ for item in items:
272
+ if item.final_input:
273
+ out.append(xuser(str(item.final_input)))
274
+ if item.final_output:
275
+ out.append(xassistant(str(item.final_output)))
276
+
277
+ # Current user message with optional images (SDK accepts text first or images first)
278
+ parts = [str(prompt or "")]
279
+ for img in self.window.core.api.xai.vision.build_images_for_chat(attachments):
280
+ parts.append(ximage(img))
281
+ out.append(xuser(*parts))
282
+ return out
283
+
284
+ # ---------- HTTP (tools/search/vision) ----------
285
+
286
+ def call_http_nonstream(
287
+ self,
288
+ model: str,
289
+ prompt: str,
290
+ system_prompt: Optional[str],
291
+ history: Optional[List[CtxItem]],
292
+ attachments: Optional[Dict[str, AttachmentItem]],
293
+ multimodal_ctx: Optional[MultimodalContext],
294
+ tools: List[dict],
295
+ search_parameters: Optional[Dict[str, Any]],
296
+ temperature: Optional[float],
297
+ max_tokens: Optional[int],
298
+ ) -> Tuple[str, List[dict], List[str], Optional[dict]]:
299
+ """
300
+ Non-streaming HTTP Chat Completions call to xAI with optional tools, Live Search, and vision.
301
+ 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
+ """
315
+ import requests
316
+
317
+ cfg = self.window.core.config
318
+ api_key = cfg.get("api_key_xai") or ""
319
+ base_url = self._build_base_url(cfg.get("api_endpoint_xai"))
320
+
321
+ # unified HTTP messages (with tool-turns + images)
322
+ messages = self._build_http_messages(
323
+ model_id=model,
324
+ system_prompt=system_prompt,
325
+ history=history,
326
+ prompt=prompt,
327
+ attachments=attachments,
328
+ )
329
+
330
+ payload: Dict[str, Any] = {
331
+ "model": model,
332
+ "messages": messages,
333
+ "temperature": temperature if temperature is not None else self.window.core.config.get('temperature'),
334
+ }
335
+ if max_tokens:
336
+ payload["max_tokens"] = int(max_tokens)
337
+
338
+ tools_payload = self._make_tools_payload(tools)
339
+ if tools_payload:
340
+ payload["tools"] = tools_payload
341
+ payload["tool_choice"] = "auto"
342
+
343
+ if search_parameters:
344
+ sp = dict(search_parameters)
345
+ if "return_citations" not in sp:
346
+ sp["return_citations"] = True
347
+ payload["search_parameters"] = sp
348
+
349
+ headers = {
350
+ "Content-Type": "application/json",
351
+ "Authorization": f"Bearer {api_key}",
352
+ }
353
+
354
+ data = {}
355
+ try:
356
+ resp = requests.post(f"{base_url}/chat/completions", headers=headers, json=payload, timeout=180)
357
+ if not resp.encoding:
358
+ resp.encoding = "utf-8"
359
+ if resp.status_code >= 400:
360
+ # Log server error body for diagnostics
361
+ self.window.core.debug.error(f"[xai.http] {resp.status_code} {resp.reason}: {resp.text}")
362
+ resp.raise_for_status()
363
+ data = resp.json() if resp.content else {}
364
+ except Exception as e:
365
+ self.window.core.debug.error(f"[xai.http] error: {e}")
366
+ return "", [], [], None
367
+
368
+ # Text + tool calls
369
+ text = ""
370
+ calls: List[dict] = []
371
+ try:
372
+ choices = data.get("choices") or []
373
+ if choices:
374
+ msg = (choices[0].get("message") or {})
375
+ mc = msg.get("content")
376
+ if isinstance(mc, str):
377
+ text = mc.strip()
378
+ elif isinstance(mc, list):
379
+ out_parts: List[str] = []
380
+ for p in mc:
381
+ if isinstance(p, dict) and p.get("type") == "text":
382
+ t = p.get("text")
383
+ if isinstance(t, str):
384
+ out_parts.append(t)
385
+ text = "".join(out_parts).strip()
386
+
387
+ # tool calls
388
+ tlist = msg.get("tool_calls") or []
389
+ for t in tlist:
390
+ fn = t.get("function") or {}
391
+ calls.append({
392
+ "id": t.get("id") or "",
393
+ "type": "function",
394
+ "function": {"name": fn.get("name") or "", "arguments": fn.get("arguments") or "{}"},
395
+ })
396
+ except Exception:
397
+ pass
398
+
399
+ # Citations
400
+ citations: List[str] = []
401
+ try:
402
+ citations = self._extract_urls(data.get("citations")) or []
403
+ if not citations:
404
+ choices = data.get("choices") or []
405
+ if choices:
406
+ msg = choices[0].get("message") or {}
407
+ citations = self._extract_urls(msg.get("citations"))
408
+ except Exception:
409
+ citations = citations or []
410
+
411
+ # Usage
412
+ usage: Optional[dict] = None
413
+ try:
414
+ usage = self._normalize_usage(data.get("usage"))
415
+ except Exception:
416
+ usage = usage or None
417
+
418
+ return text, calls, citations, usage
419
+
420
+ def call_http_stream(
421
+ self,
422
+ model: str,
423
+ messages: Optional[list] = None,
424
+ tools: Optional[List[dict]] = None,
425
+ search_parameters: Optional[Dict[str, Any]] = None,
426
+ temperature: Optional[float] = None,
427
+ max_tokens: Optional[int] = None,
428
+ # fallback rebuild inputs if needed:
429
+ system_prompt: Optional[str] = None,
430
+ history: Optional[List[CtxItem]] = None,
431
+ attachments: Optional[Dict[str, AttachmentItem]] = None,
432
+ prompt: Optional[str] = None,
433
+ ):
434
+ """
435
+ Streaming HTTP Chat Completions (SSE) for xAI.
436
+ Sends OpenAI-compatible JSON; rebuilds messages when given SDK objects.
437
+
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
+ :return: Iterable/generator yielding SDK-like response chunks with 'content', 'tool_calls', 'citations', 'usage'
449
+ """
450
+ import requests
451
+ from types import SimpleNamespace
452
+ import json as _json
453
+
454
+ cfg = self.window.core.config
455
+ api_key = cfg.get("api_key_xai") or ""
456
+ base_url = self._build_base_url(cfg.get("api_endpoint_xai"))
457
+
458
+ # Ensure HTTP messages are JSON-serializable dicts
459
+ if not self._looks_like_http_messages(messages):
460
+ messages = self._build_http_messages(
461
+ model_id=model,
462
+ system_prompt=system_prompt,
463
+ history=history,
464
+ prompt=prompt,
465
+ attachments=attachments,
466
+ )
467
+
468
+ payload: Dict[str, Any] = {
469
+ "model": model,
470
+ "messages": messages,
471
+ "temperature": temperature if temperature is not None else self.window.core.config.get('temperature'),
472
+ "stream": True,
473
+ }
474
+ if max_tokens:
475
+ payload["max_tokens"] = int(max_tokens)
476
+
477
+ tools_payload = self._make_tools_payload(tools or [])
478
+ if tools_payload:
479
+ payload["tools"] = tools_payload
480
+ payload["tool_choice"] = "auto"
481
+
482
+ if search_parameters:
483
+ sp = dict(search_parameters)
484
+ if "return_citations" not in sp:
485
+ sp["return_citations"] = True
486
+ payload["search_parameters"] = sp
487
+
488
+ headers = {
489
+ "Content-Type": "application/json",
490
+ "Authorization": f"Bearer {api_key}",
491
+ }
492
+
493
+ def _mk_chunk(delta_text=None, tool_calls=None, citations=None, usage=None):
494
+ delta_dict: Dict[str, Any] = {}
495
+ if delta_text is not None:
496
+ delta_dict["content"] = delta_text
497
+ if tool_calls is not None:
498
+ delta_dict["tool_calls"] = tool_calls
499
+ choice = SimpleNamespace(delta=SimpleNamespace(**delta_dict))
500
+ obj = SimpleNamespace(choices=[choice])
501
+ if citations is not None:
502
+ setattr(obj, "citations", citations)
503
+ if usage is not None:
504
+ setattr(obj, "usage", usage)
505
+ return obj
506
+
507
+ class XAIHTTPStream:
508
+ __slots__ = ("_resp", "_iter_started")
509
+
510
+ def __init__(self, resp):
511
+ self._resp = resp
512
+ self._iter_started = False
513
+ if not self._resp.encoding:
514
+ self._resp.encoding = "utf-8"
515
+
516
+ def __iter__(self):
517
+ if self._iter_started:
518
+ return
519
+ self._iter_started = True
520
+ try:
521
+ for raw in self._resp.iter_lines(decode_unicode=False):
522
+ if not raw:
523
+ continue
524
+ try:
525
+ s = raw.decode("utf-8", errors="replace").lstrip("\ufeff").strip()
526
+ except Exception:
527
+ continue
528
+ if not s.startswith("data:"):
529
+ continue
530
+ data_str = s[5:].strip()
531
+ if data_str == "[DONE]":
532
+ break
533
+ try:
534
+ obj = _json.loads(data_str)
535
+ except Exception:
536
+ continue
537
+
538
+ # delta or message styles
539
+ try:
540
+ chs = obj.get("choices") or []
541
+ if chs:
542
+ delta = chs[0].get("delta") or {}
543
+ message = chs[0].get("message") or {}
544
+ if "content" in delta and delta["content"] is not None:
545
+ yield _mk_chunk(delta_text=str(delta["content"]))
546
+ elif "content" in message and message["content"] is not None:
547
+ mc = message["content"]
548
+ if isinstance(mc, str):
549
+ yield _mk_chunk(delta_text=mc)
550
+ tc = delta.get("tool_calls") or message.get("tool_calls") or []
551
+ if tc:
552
+ yield _mk_chunk(tool_calls=tc)
553
+ except Exception:
554
+ pass
555
+
556
+ # usage + citations tail
557
+ try:
558
+ u = obj.get("usage")
559
+ cits = self_outer._extract_urls(obj.get("citations"))
560
+ if u or cits:
561
+ yield _mk_chunk(delta_text="", citations=cits if cits else None, usage=u if u else None)
562
+ except Exception:
563
+ pass
564
+ finally:
565
+ try:
566
+ self._resp.close()
567
+ except Exception:
568
+ pass
569
+
570
+ def close(self):
571
+ try:
572
+ self._resp.close()
573
+ except Exception:
574
+ pass
575
+
576
+ def resolve(self):
577
+ return
578
+
579
+ self_outer = self
580
+
581
+ try:
582
+ resp = requests.post(f"{base_url}/chat/completions", headers=headers, json=payload, stream=True, timeout=300)
583
+ if not resp.encoding:
584
+ resp.encoding = "utf-8"
585
+ if resp.status_code >= 400:
586
+ # Log server error body for diagnostics
587
+ try:
588
+ body = resp.text
589
+ except Exception:
590
+ body = ""
591
+ self.window.core.debug.error(f"[xai.http.stream] {resp.status_code} {resp.reason}: {body}")
592
+ resp.raise_for_status()
593
+ return XAIHTTPStream(resp)
594
+ except Exception as e:
595
+ self.window.core.debug.error(f"[xai.http.stream] error: {e}")
596
+
597
+ class _Empty:
598
+ def __iter__(self):
599
+ return iter(())
600
+ def close(self):
601
+ pass
602
+ def resolve(self):
603
+ pass
604
+ return _Empty()
605
+
606
+ # ---------- UTILS ----------
607
+
608
+ def _fit_ctx(self, model: ModelItem) -> int:
609
+ """
610
+ Fit to max model tokens (uses model.ctx if present).
611
+
612
+ :param model: ModelItem
613
+ :return: Max tokens int
614
+ """
615
+ max_ctx_tokens = self.window.core.config.get('max_total_tokens')
616
+ if model and model.ctx and 0 < model.ctx < max_ctx_tokens:
617
+ max_ctx_tokens = model.ctx
618
+ return max_ctx_tokens
619
+
620
+ def _build_count_messages(
621
+ self,
622
+ prompt: str,
623
+ system_prompt: str,
624
+ model: ModelItem,
625
+ history: Optional[List[CtxItem]] = None,
626
+ ) -> List[dict]:
627
+ """
628
+ 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
+ """
636
+ messages = []
637
+ if system_prompt:
638
+ messages.append({"role": "system", "content": system_prompt})
639
+
640
+ if self.window.core.config.get('use_context'):
641
+ used_tokens = self.window.core.tokens.from_user(prompt, system_prompt)
642
+ items = self.window.core.ctx.get_history(
643
+ history, model.id, MODE_CHAT, used_tokens, self._fit_ctx(model),
644
+ )
645
+ for item in items:
646
+ if item.final_input:
647
+ messages.append({"role": "user", "content": str(item.final_input)})
648
+ if item.final_output:
649
+ messages.append({"role": "assistant", "content": str(item.final_output)})
650
+
651
+ messages.append({"role": "user", "content": str(prompt)})
652
+ return messages
653
+
654
+ def _extract_urls(self, raw) -> List[str]:
655
+ """
656
+ 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
+ """
665
+ urls: List[str] = []
666
+ seen = set()
667
+ if not raw:
668
+ return urls
669
+ if isinstance(raw, list):
670
+ for it in raw:
671
+ u = None
672
+ if isinstance(it, str):
673
+ u = it
674
+ elif isinstance(it, dict):
675
+ u = (it.get("url") or it.get("uri") or
676
+ (it.get("source") or {}).get("url") or (it.get("source") or {}).get("uri"))
677
+ if isinstance(u, str) and (u.startswith("http://") or u.startswith("https://")):
678
+ if u not in seen:
679
+ urls.append(u); seen.add(u)
680
+ return urls
681
+
682
+ def _normalize_usage(self, raw) -> Optional[dict]:
683
+ """
684
+ Normalize usage to a common dict: {'in','out','reasoning','total'}.
685
+ Accepts either:
686
+ - {'input_tokens','output_tokens','total_tokens'}
687
+ - {'prompt_tokens','completion_tokens','total_tokens'}
688
+
689
+ :param raw: Raw usage input
690
+ :return: Normalized usage dict or None
691
+ """
692
+ if not isinstance(raw, dict):
693
+ return None
694
+
695
+ def _as_int(v) -> int:
696
+ try:
697
+ return int(v)
698
+ except Exception:
699
+ try:
700
+ return int(float(v))
701
+ except Exception:
702
+ return 0
703
+
704
+ in_tok = raw.get("input_tokens") if "input_tokens" in raw else raw.get("prompt_tokens")
705
+ out_tok = raw.get("output_tokens") if "output_tokens" in raw else raw.get("completion_tokens")
706
+ tot = raw.get("total_tokens")
707
+
708
+ i = _as_int(in_tok or 0)
709
+ 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}
712
+
713
+ def _looks_like_http_messages(self, messages) -> bool:
714
+ """
715
+ 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
+ """
720
+ if not isinstance(messages, list):
721
+ return False
722
+ for m in messages:
723
+ if not isinstance(m, dict):
724
+ return False
725
+ if "role" not in m or "content" not in m:
726
+ return False
727
+ return True
728
+
729
+ def _build_http_messages(
730
+ self,
731
+ model_id: str,
732
+ system_prompt: Optional[str],
733
+ history: Optional[List[CtxItem]],
734
+ prompt: Optional[str],
735
+ attachments: Optional[Dict[str, AttachmentItem]],
736
+ ) -> list:
737
+ """
738
+ Build OpenAI-compatible messages array (vision-safe):
739
+ - If images present: content is a list of parts, with image parts first, then text part.
740
+ - Only JPEG/PNG allowed; size validated; on violation we raise clear error in logs.
741
+ - 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
+ """
750
+ self.window.core.api.xai.vision.reset()
751
+ messages: List[dict] = []
752
+ if system_prompt:
753
+ messages.append({"role": "system", "content": system_prompt})
754
+
755
+ # history as plain user/assistant turns
756
+ items: List[CtxItem] = []
757
+ if self.window.core.config.get('use_context'):
758
+ used = self.window.core.tokens.from_user(prompt or "", system_prompt or "")
759
+ mdl = self.window.core.models.get(model_id) if self.window.core.models.has(model_id) else self.window.core.models.from_defaults()
760
+ items = self.window.core.ctx.get_history(history, model_id, MODE_CHAT, used, self._fit_ctx(mdl))
761
+ for it in items:
762
+ if it.final_input:
763
+ messages.append({"role": "user", "content": str(it.final_input)})
764
+ if it.final_output:
765
+ messages.append({"role": "assistant", "content": str(it.final_output)})
766
+
767
+ # Inject tool-turns from last ctx item
768
+ self._append_tool_turns_from_ctx(messages, items)
769
+
770
+ # Current user content (images first -> then text), validated
771
+ parts: List[dict] = []
772
+ img_found = False
773
+ if attachments:
774
+ # image constraints
775
+ cfg = self.window.core.config
776
+ max_bytes = int(cfg.get("xai_image_max_bytes") or self.default_image_max_bytes)
777
+
778
+ for _, att in attachments.items():
779
+ try:
780
+ if not (att and att.path):
781
+ continue
782
+ if not self.window.core.api.xai.vision.is_image(att.path):
783
+ continue
784
+ mime = self.window.core.api.xai.vision.guess_mime(att.path)
785
+ # Enforce allowed MIME
786
+ if mime not in self.allowed_mimes:
787
+ self.window.core.debug.error(f"[xai.vision] Unsupported image MIME: {mime}. Use JPEG/PNG.")
788
+ continue
789
+ # Enforce size
790
+ try:
791
+ fsz = os.path.getsize(att.path)
792
+ if fsz > max_bytes:
793
+ self.window.core.debug.error(f"[xai.vision] Image too large ({fsz} bytes). Max: {max_bytes}.")
794
+ continue
795
+ except Exception:
796
+ pass
797
+ with open(att.path, "rb") as f:
798
+ b64 = base64.b64encode(f.read()).decode("utf-8")
799
+ parts.append({
800
+ "type": "image_url",
801
+ "image_url": {"url": f"data:{mime};base64,{b64}", "detail": "high"},
802
+ })
803
+ att.consumed = True
804
+ img_found = True
805
+ self.window.core.api.xai.vision.attachments[att.id] = att.path
806
+ except Exception as e:
807
+ self.window.core.debug.error(f"[xai.vision] Error processing image '{getattr(att,'path',None)}': {e}")
808
+ continue
809
+
810
+ # Append text part last when images exist
811
+ if img_found:
812
+ if prompt:
813
+ parts.append({"type": "text", "text": str(prompt)})
814
+ messages.append({"role": "user", "content": parts})
815
+ else:
816
+ messages.append({"role": "user", "content": str(prompt or "")})
817
+
818
+ return messages
819
+
820
+ def _append_tool_turns_from_ctx(self, messages: List[dict], items: List[CtxItem]):
821
+ """
822
+ 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
+ """
827
+ tool_call_native_enabled = self.window.core.config.get('func_call.native', False)
828
+ if not (items and tool_call_native_enabled):
829
+ return
830
+ last = items[-1]
831
+ if not (last.extra and isinstance(last.extra, dict)):
832
+ return
833
+ tool_calls = last.extra.get("tool_calls")
834
+ tool_output = last.extra.get("tool_output")
835
+ if not (tool_calls and isinstance(tool_calls, list)):
836
+ return
837
+
838
+ # find last assistant message to attach tool_calls
839
+ idx = None
840
+ for i in range(len(messages) - 1, -1, -1):
841
+ if messages[i].get("role") == "assistant":
842
+ idx = i
843
+ break
844
+ if idx is None:
845
+ return
846
+
847
+ messages[idx]["tool_calls"] = []
848
+ messages[idx]["content"] = ""
849
+ for call in tool_calls:
850
+ fn = call.get("function", {})
851
+ messages[idx]["tool_calls"].append({
852
+ "id": call.get("id", ""),
853
+ "type": "function",
854
+ "function": {
855
+ "name": fn.get("name", ""),
856
+ "arguments": json.dumps(fn.get("arguments", {})),
857
+ }
858
+ })
859
+
860
+ # append tool messages
861
+ if tool_output and isinstance(tool_output, list):
862
+ for out in tool_output:
863
+ if isinstance(out, dict) and "result" in out:
864
+ content = str(out["result"])
865
+ tool_call_id = None
866
+ if "cmd" in out:
867
+ for c in tool_calls:
868
+ if c.get("function", {}).get("name") == out.get("cmd"):
869
+ tool_call_id = c.get("id")
870
+ break
871
+ messages.append({
872
+ "role": "tool",
873
+ "tool_call_id": tool_call_id or tool_calls[0].get("id", ""),
874
+ "content": content,
875
+ })
876
+ else:
877
+ messages.append({
878
+ "role": "tool",
879
+ "tool_call_id": tool_calls[0].get("id", ""),
880
+ "content": str(out),
881
+ })
882
+
883
+ def _has_tool_turns(self, history: Optional[List[CtxItem]]) -> bool:
884
+ """
885
+ 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
+ """
890
+ if not history:
891
+ return False
892
+ last = history[-1]
893
+ if not (last.extra and isinstance(last.extra, dict)):
894
+ return False
895
+ calls = last.extra.get("tool_calls")
896
+ outs = last.extra.get("tool_output")
897
+ return bool(calls and isinstance(calls, list) and outs and isinstance(outs, list))
898
+
899
+ def _attachments_have_images(self, attachments: Optional[Dict[str, AttachmentItem]]) -> bool:
900
+ """
901
+ 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
+ """
906
+ if not attachments:
907
+ return False
908
+ for _, att in attachments.items():
909
+ try:
910
+ if att.path and self.window.core.api.xai.vision.is_image(att.path):
911
+ return True
912
+ except Exception:
913
+ continue
914
+ return False
915
+
916
+ def _is_vision_model(self, model: ModelItem) -> bool:
917
+ """
918
+ Heuristic check for vision-capable model IDs.
919
+
920
+ :param model: ModelItem
921
+ :return: True if model ID indicates vision capability
922
+ """
923
+ model_id = (model.id if model and model.id else "").strip()
924
+ if not model or not model_id:
925
+ return False
926
+ if model.is_image_input():
927
+ return True
928
+ mid = model_id.lower()
929
+ return ("vision" in mid) or ("-v" in mid and "grok" in mid)
930
+
931
+ def _make_tools_payload(self, tools: Optional[List[dict]]) -> List[dict]:
932
+ """
933
+ 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
+ """
938
+ out: List[dict] = []
939
+ for t in tools or []:
940
+ if isinstance(t, dict) and t.get("type") == "function" and "function" in t:
941
+ out.append(t)
942
+ else:
943
+ out.append({"type": "function", "function": t})
944
+ return out
945
+
946
+ def _build_base_url(self, cfg_endpoint: Optional[str]) -> str:
947
+ """
948
+ 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
+ """
953
+ base = (cfg_endpoint or "https://api.x.ai/v1").strip()
954
+ if base.endswith("/"):
955
+ base = base[:-1]
956
+ return base
957
+
958
+ def reset_tokens(self):
959
+ """Reset input tokens counter."""
960
+ self.input_tokens = 0
961
+
962
+ def get_used_tokens(self) -> int:
963
+ """
964
+ Return the locally estimated input tokens count.
965
+
966
+ :return: Input tokens int
967
+ """
968
+ return self.input_tokens