pygpt-net 2.6.36__py3-none-any.whl → 2.6.38__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pygpt_net/CHANGELOG.txt +12 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/chat/handler/anthropic_stream.py +164 -0
- pygpt_net/controller/chat/handler/google_stream.py +181 -0
- pygpt_net/controller/chat/handler/langchain_stream.py +24 -0
- pygpt_net/controller/chat/handler/llamaindex_stream.py +47 -0
- pygpt_net/controller/chat/handler/openai_stream.py +260 -0
- pygpt_net/controller/chat/handler/utils.py +210 -0
- pygpt_net/controller/chat/handler/worker.py +570 -0
- pygpt_net/controller/chat/handler/xai_stream.py +135 -0
- pygpt_net/controller/chat/stream.py +1 -1
- pygpt_net/controller/ctx/ctx.py +1 -1
- pygpt_net/controller/debug/debug.py +6 -6
- pygpt_net/controller/model/editor.py +3 -0
- pygpt_net/controller/model/importer.py +9 -2
- pygpt_net/controller/plugins/plugins.py +11 -3
- pygpt_net/controller/presets/presets.py +2 -2
- pygpt_net/core/bridge/context.py +35 -35
- pygpt_net/core/bridge/worker.py +40 -16
- pygpt_net/core/ctx/bag.py +7 -2
- pygpt_net/core/ctx/reply.py +17 -2
- pygpt_net/core/db/viewer.py +19 -34
- pygpt_net/core/render/plain/pid.py +12 -1
- pygpt_net/core/render/web/body.py +30 -39
- pygpt_net/core/tabs/tab.py +24 -1
- pygpt_net/data/config/config.json +10 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/settings.json +105 -0
- pygpt_net/data/css/style.dark.css +2 -3
- pygpt_net/data/css/style.light.css +2 -3
- pygpt_net/data/locale/locale.de.ini +3 -1
- pygpt_net/data/locale/locale.en.ini +19 -1
- pygpt_net/data/locale/locale.es.ini +3 -1
- pygpt_net/data/locale/locale.fr.ini +3 -1
- pygpt_net/data/locale/locale.it.ini +3 -1
- pygpt_net/data/locale/locale.pl.ini +4 -2
- pygpt_net/data/locale/locale.uk.ini +3 -1
- pygpt_net/data/locale/locale.zh.ini +3 -1
- pygpt_net/item/assistant.py +51 -2
- pygpt_net/item/attachment.py +21 -20
- pygpt_net/item/calendar_note.py +19 -2
- pygpt_net/item/ctx.py +115 -2
- pygpt_net/item/index.py +9 -2
- pygpt_net/item/mode.py +9 -6
- pygpt_net/item/model.py +20 -3
- pygpt_net/item/notepad.py +14 -2
- pygpt_net/item/preset.py +42 -2
- pygpt_net/item/prompt.py +8 -2
- pygpt_net/plugin/cmd_files/plugin.py +2 -2
- pygpt_net/provider/api/__init__.py +5 -3
- pygpt_net/provider/api/anthropic/__init__.py +190 -29
- pygpt_net/provider/api/anthropic/audio.py +30 -0
- pygpt_net/provider/api/anthropic/chat.py +341 -0
- pygpt_net/provider/api/anthropic/image.py +25 -0
- pygpt_net/provider/api/anthropic/tools.py +266 -0
- pygpt_net/provider/api/anthropic/vision.py +142 -0
- pygpt_net/provider/api/google/chat.py +2 -2
- pygpt_net/provider/api/google/realtime/client.py +2 -2
- pygpt_net/provider/api/google/tools.py +58 -48
- pygpt_net/provider/api/google/vision.py +7 -1
- pygpt_net/provider/api/openai/chat.py +1 -0
- pygpt_net/provider/api/openai/vision.py +6 -0
- pygpt_net/provider/api/x_ai/__init__.py +247 -0
- pygpt_net/provider/api/x_ai/audio.py +32 -0
- pygpt_net/provider/api/x_ai/chat.py +968 -0
- pygpt_net/provider/api/x_ai/image.py +208 -0
- pygpt_net/provider/api/x_ai/remote.py +262 -0
- pygpt_net/provider/api/x_ai/tools.py +120 -0
- pygpt_net/provider/api/x_ai/vision.py +119 -0
- pygpt_net/provider/core/attachment/json_file.py +2 -2
- pygpt_net/provider/core/config/patch.py +28 -0
- pygpt_net/provider/llms/anthropic.py +4 -2
- pygpt_net/tools/text_editor/tool.py +4 -1
- pygpt_net/tools/text_editor/ui/dialogs.py +1 -1
- pygpt_net/ui/base/config_dialog.py +5 -11
- pygpt_net/ui/dialog/db.py +177 -59
- pygpt_net/ui/dialog/dictionary.py +57 -59
- pygpt_net/ui/dialog/editor.py +3 -2
- pygpt_net/ui/dialog/image.py +1 -1
- pygpt_net/ui/dialog/logger.py +3 -2
- pygpt_net/ui/dialog/models.py +16 -16
- pygpt_net/ui/dialog/plugins.py +63 -60
- pygpt_net/ui/layout/ctx/ctx_list.py +3 -4
- pygpt_net/ui/layout/toolbox/__init__.py +2 -2
- pygpt_net/ui/layout/toolbox/assistants.py +8 -9
- pygpt_net/ui/layout/toolbox/presets.py +2 -2
- pygpt_net/ui/main.py +9 -4
- pygpt_net/ui/widget/element/labels.py +20 -4
- pygpt_net/ui/widget/textarea/editor.py +0 -4
- pygpt_net/ui/widget/textarea/web.py +1 -1
- {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.38.dist-info}/METADATA +18 -6
- {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.38.dist-info}/RECORD +95 -76
- pygpt_net/controller/chat/handler/stream_worker.py +0 -1136
- {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.38.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.38.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.38.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
|