pygpt-net 2.6.35__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 (65) hide show
  1. pygpt_net/CHANGELOG.txt +9 -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 +39 -15
  17. pygpt_net/core/render/web/renderer.py +4 -4
  18. pygpt_net/data/config/config.json +10 -3
  19. pygpt_net/data/config/models.json +3 -3
  20. pygpt_net/data/config/settings.json +105 -0
  21. pygpt_net/data/css/style.dark.css +2 -3
  22. pygpt_net/data/css/style.light.css +2 -3
  23. pygpt_net/data/css/web-blocks.css +1 -1
  24. pygpt_net/data/css/web-chatgpt.css +1 -1
  25. pygpt_net/data/css/web-chatgpt_wide.css +1 -1
  26. pygpt_net/data/locale/locale.de.ini +3 -1
  27. pygpt_net/data/locale/locale.en.ini +19 -1
  28. pygpt_net/data/locale/locale.es.ini +3 -1
  29. pygpt_net/data/locale/locale.fr.ini +3 -1
  30. pygpt_net/data/locale/locale.it.ini +3 -1
  31. pygpt_net/data/locale/locale.pl.ini +4 -2
  32. pygpt_net/data/locale/locale.uk.ini +3 -1
  33. pygpt_net/data/locale/locale.zh.ini +3 -1
  34. pygpt_net/provider/api/__init__.py +5 -3
  35. pygpt_net/provider/api/anthropic/__init__.py +190 -29
  36. pygpt_net/provider/api/anthropic/audio.py +30 -0
  37. pygpt_net/provider/api/anthropic/chat.py +341 -0
  38. pygpt_net/provider/api/anthropic/image.py +25 -0
  39. pygpt_net/provider/api/anthropic/tools.py +266 -0
  40. pygpt_net/provider/api/anthropic/vision.py +142 -0
  41. pygpt_net/provider/api/google/chat.py +2 -2
  42. pygpt_net/provider/api/google/tools.py +58 -48
  43. pygpt_net/provider/api/google/vision.py +7 -1
  44. pygpt_net/provider/api/openai/chat.py +1 -0
  45. pygpt_net/provider/api/openai/vision.py +6 -0
  46. pygpt_net/provider/api/x_ai/__init__.py +247 -0
  47. pygpt_net/provider/api/x_ai/audio.py +32 -0
  48. pygpt_net/provider/api/x_ai/chat.py +968 -0
  49. pygpt_net/provider/api/x_ai/image.py +208 -0
  50. pygpt_net/provider/api/x_ai/remote.py +262 -0
  51. pygpt_net/provider/api/x_ai/tools.py +120 -0
  52. pygpt_net/provider/api/x_ai/vision.py +119 -0
  53. pygpt_net/provider/core/config/patch.py +37 -0
  54. pygpt_net/provider/llms/anthropic.py +4 -2
  55. pygpt_net/ui/base/config_dialog.py +5 -11
  56. pygpt_net/ui/dialog/models.py +2 -4
  57. pygpt_net/ui/dialog/plugins.py +40 -43
  58. pygpt_net/ui/widget/element/labels.py +19 -3
  59. pygpt_net/ui/widget/textarea/web.py +1 -1
  60. {pygpt_net-2.6.35.dist-info → pygpt_net-2.6.37.dist-info}/METADATA +15 -6
  61. {pygpt_net-2.6.35.dist-info → pygpt_net-2.6.37.dist-info}/RECORD +64 -45
  62. pygpt_net/controller/chat/handler/stream_worker.py +0 -1136
  63. {pygpt_net-2.6.35.dist-info → pygpt_net-2.6.37.dist-info}/LICENSE +0 -0
  64. {pygpt_net-2.6.35.dist-info → pygpt_net-2.6.37.dist-info}/WHEEL +0 -0
  65. {pygpt_net-2.6.35.dist-info → pygpt_net-2.6.37.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,208 @@
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
+ import base64
13
+ import datetime
14
+ import os
15
+ from typing import Optional, Dict, Any, List
16
+
17
+ import requests
18
+ from PySide6.QtCore import QObject, Signal, QRunnable, Slot
19
+
20
+ from pygpt_net.core.events import KernelEvent
21
+ from pygpt_net.core.bridge.context import BridgeContext
22
+ from pygpt_net.item.ctx import CtxItem
23
+ from pygpt_net.utils import trans
24
+
25
+
26
+ class Image:
27
+ def __init__(self, window=None):
28
+ self.window = window
29
+ self.worker = None
30
+
31
+ def generate(
32
+ self,
33
+ context: BridgeContext,
34
+ extra: Optional[Dict[str, Any]] = None,
35
+ sync: bool = True
36
+ ) -> bool:
37
+ """
38
+ Generate image(s) via xAI REST API /v1/images/generations (OpenAI-compatible).
39
+ Model: grok-2-image (or -1212 variants).
40
+
41
+ :param context: BridgeContext with prompt, model, ctx
42
+ :param extra: Extra parameters (num: int, inline: bool, etc.)
43
+ :param sync: Run synchronously (blocking) if True
44
+ :return: True if started
45
+ """
46
+ extra = extra or {}
47
+ ctx = context.ctx or CtxItem()
48
+ model = context.model
49
+ prompt = context.prompt
50
+ num = int(extra.get("num", 1))
51
+ inline = bool(extra.get("inline", False))
52
+
53
+ # Optional prompt enhancement model (same as in your Google path)
54
+ prompt_model = self.window.core.models.from_defaults()
55
+ tmp = self.window.core.config.get('img_prompt_model')
56
+ if self.window.core.models.has(tmp):
57
+ prompt_model = self.window.core.models.get(tmp)
58
+
59
+ worker = ImageWorker()
60
+ worker.window = self.window
61
+ worker.ctx = ctx
62
+ worker.model = model.id or "grok-2-image"
63
+ worker.input_prompt = prompt
64
+ worker.model_prompt = prompt_model
65
+ worker.system_prompt = self.window.core.prompt.get('img')
66
+ worker.raw = self.window.core.config.get('img_raw')
67
+ worker.num = num
68
+ worker.inline = inline
69
+
70
+ self.worker = worker
71
+ self.worker.signals.finished.connect(self.window.core.image.handle_finished)
72
+ self.worker.signals.finished_inline.connect(self.window.core.image.handle_finished_inline)
73
+ self.worker.signals.status.connect(self.window.core.image.handle_status)
74
+ self.worker.signals.error.connect(self.window.core.image.handle_error)
75
+
76
+ if sync or not self.window.controller.kernel.async_allowed(ctx):
77
+ self.worker.run()
78
+ return True
79
+
80
+ self.window.dispatch(KernelEvent(KernelEvent.STATE_BUSY, {"id": "img"}))
81
+ self.window.threadpool.start(self.worker)
82
+ return True
83
+
84
+
85
+ class ImageSignals(QObject):
86
+ finished = Signal(object, list, str) # ctx, paths, prompt
87
+ finished_inline = Signal(object, list, str) # ctx, paths, prompt
88
+ status = Signal(object) # message
89
+ error = Signal(object) # exception
90
+
91
+
92
+ class ImageWorker(QRunnable):
93
+ def __init__(self, *args, **kwargs):
94
+ super().__init__()
95
+ self.signals = ImageSignals()
96
+ self.window = None
97
+ self.ctx: Optional[CtxItem] = None
98
+
99
+ # params
100
+ self.model = "grok-2-image"
101
+ self.model_prompt = None
102
+ self.input_prompt = ""
103
+ self.system_prompt = ""
104
+ self.inline = False
105
+ self.raw = False
106
+ self.num = 1
107
+
108
+ # API
109
+ self.api_url = "https://api.x.ai/v1/images/generations" # OpenAI-compatible endpoint
110
+
111
+ @Slot()
112
+ def run(self):
113
+ try:
114
+ # optional prompt enhancement
115
+ if not self.raw and not self.inline and self.input_prompt:
116
+ try:
117
+ self.signals.status.emit(trans('img.status.prompt.wait'))
118
+ bridge_context = BridgeContext(
119
+ prompt=self.input_prompt,
120
+ system_prompt=self.system_prompt,
121
+ model=self.model_prompt,
122
+ max_tokens=200,
123
+ temperature=1.0,
124
+ )
125
+ ev = KernelEvent(KernelEvent.CALL, {'context': bridge_context, 'extra': {}})
126
+ self.window.dispatch(ev)
127
+ resp = ev.data.get('response')
128
+ if resp:
129
+ self.input_prompt = resp
130
+ except Exception as e:
131
+ self.signals.error.emit(e)
132
+ self.signals.status.emit(trans('img.status.prompt.error') + ": " + str(e))
133
+
134
+ self.signals.status.emit(trans('img.status.generating') + f": {self.input_prompt}...")
135
+
136
+ cfg = self.window.core.config
137
+ api_key = cfg.get("api_key_xai") or os.environ.get("XAI_API_KEY") or ""
138
+ self.api_url = cfg.get("api_endpoint_xai") + "/images/generations"
139
+ if not api_key:
140
+ raise RuntimeError("Missing xAI API key. Set `api_key_xai` in config or XAI_API_KEY in env.")
141
+
142
+ headers = {
143
+ "Authorization": f"Bearer {api_key}",
144
+ "Content-Type": "application/json",
145
+ }
146
+ payload = {
147
+ "model": self.model or "grok-2-image",
148
+ "prompt": self.input_prompt or "",
149
+ "n": max(1, min(int(self.num), 10)),
150
+ "response_format": "b64_json", # get base64 so we can save locally
151
+ }
152
+
153
+ r = requests.post(self.api_url, headers=headers, json=payload, timeout=180)
154
+ r.raise_for_status()
155
+ data = r.json()
156
+
157
+ images = []
158
+ for idx, img in enumerate((data.get("data") or [])[: self.num]):
159
+ b64 = img.get("b64_json")
160
+ if not b64:
161
+ # fallback: url download
162
+ url = img.get("url")
163
+ if url:
164
+ try:
165
+ rr = requests.get(url, timeout=60)
166
+ if rr.status_code == 200:
167
+ images.append(rr.content)
168
+ except Exception:
169
+ pass
170
+ continue
171
+ try:
172
+ images.append(base64.b64decode(b64))
173
+ except Exception:
174
+ continue
175
+
176
+ paths: List[str] = []
177
+ for i, content in enumerate(images):
178
+ name = (
179
+ datetime.date.today().strftime("%Y-%m-%d") + "_" +
180
+ datetime.datetime.now().strftime("%H-%M-%S") + "-" +
181
+ self.window.core.image.make_safe_filename(self.input_prompt) + "-" +
182
+ str(i + 1) + ".jpg"
183
+ )
184
+ path = os.path.join(self.window.core.config.get_user_dir("img"), name)
185
+ self.signals.status.emit(trans('img.status.downloading') + f" ({i + 1} / {self.num}) -> {path}")
186
+
187
+ if self.window.core.image.save_image(path, content):
188
+ paths.append(path)
189
+
190
+ if self.inline:
191
+ self.signals.finished_inline.emit(self.ctx, paths, self.input_prompt)
192
+ else:
193
+ self.signals.finished.emit(self.ctx, paths, self.input_prompt)
194
+
195
+ except Exception as e:
196
+ self.signals.error.emit(e)
197
+ finally:
198
+ self._cleanup()
199
+
200
+ def _cleanup(self):
201
+ """Cleanup signals to avoid multiple calls."""
202
+ sig = self.signals
203
+ self.signals = None
204
+ if sig is not None:
205
+ try:
206
+ sig.deleteLater()
207
+ except RuntimeError:
208
+ pass
@@ -0,0 +1,262 @@
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
+ from typing import Optional, Dict, Any, List
14
+
15
+ from pygpt_net.item.model import ModelItem
16
+
17
+
18
+ class Remote:
19
+ def __init__(self, window=None):
20
+ """
21
+ Live Search builder for xAI:
22
+ - Returns a dict with 'sdk' (X-only toggle) and 'http' (full search_parameters).
23
+ - SDK path: native streaming (xai_sdk.chat.stream()) works only for basic X search (no advanced filters).
24
+ - HTTP path: full Live Search (web/news/rss/X with filters), streaming via SSE.
25
+
26
+ :param window: Window instance
27
+ """
28
+ self.window = window
29
+
30
+ def build_remote_tools(self, model: ModelItem = None) -> Dict[str, Any]:
31
+ """
32
+ Return live-search config for xAI:
33
+ {
34
+ "mode": ...,
35
+ "sdk": {"enabled": bool},
36
+ "http": Optional[dict],
37
+ "reason": Optional[str],
38
+ }
39
+
40
+ :param model: ModelItem (optional, not used now)
41
+ :return: Dict with 'sdk' and 'http' keys
42
+ """
43
+ return self.build(model)
44
+
45
+ def build(self, model=None) -> Dict[str, Any]:
46
+ """
47
+ Build both SDK-capable toggle and HTTP search_parameters.
48
+ Returns:
49
+ {
50
+ "mode": "auto"|"on"|"off",
51
+ "sdk": {"enabled": bool}, # True if we can use native SDK stream (X-only no filters)
52
+ "http": Optional[dict], # search_parameters for Chat Completions (or None)
53
+ "reason": Optional[str], # diagnostic: why http is needed
54
+ }
55
+
56
+ :param model: ModelItem (not used now)
57
+ :return: Dict with 'sdk' and 'http' keys
58
+ """
59
+ cfg = self.window.core.config
60
+
61
+ mode = str(cfg.get("remote_tools.xai.mode") or "auto").lower()
62
+ if mode not in ("auto", "on", "off"):
63
+ mode = "auto"
64
+
65
+ # sources toggles
66
+ s_web = bool(cfg.get("remote_tools.xai.sources.web", True))
67
+ s_x = bool(cfg.get("remote_tools.xai.sources.x", True))
68
+ s_news = bool(cfg.get("remote_tools.xai.sources.news", False))
69
+ s_rss = bool(cfg.get("remote_tools.xai.sources.rss", False))
70
+
71
+ # advanced flags
72
+ adv_web_allowed = self._has_list(cfg.get("remote_tools.xai.web.allowed_websites"))
73
+ adv_web_excluded = self._has_list(cfg.get("remote_tools.xai.web.excluded_websites"))
74
+ adv_web_country = self._has_str(cfg.get("remote_tools.xai.web.country"))
75
+ adv_web_safe = cfg.get("remote_tools.xai.web.safe_search", None)
76
+
77
+ adv_news_excl = self._has_list(cfg.get("remote_tools.xai.news.excluded_websites"))
78
+ adv_news_country = self._has_str(cfg.get("remote_tools.xai.news.country"))
79
+ adv_news_safe = cfg.get("remote_tools.xai.news.safe_search", None)
80
+
81
+ adv_x_incl = self._has_list(cfg.get("remote_tools.xai.x.included_handles"))
82
+ adv_x_excl = self._has_list(cfg.get("remote_tools.xai.x.excluded_handles"))
83
+ adv_x_favs = self._has_int(cfg.get("remote_tools.xai.x.min_favs"))
84
+ adv_x_views = self._has_int(cfg.get("remote_tools.xai.x.min_views"))
85
+
86
+ adv_rss_link = self._has_str(cfg.get("remote_tools.xai.rss.link"))
87
+
88
+ adv_from = self._has_str(cfg.get("remote_tools.xai.from_date"))
89
+ adv_to = self._has_str(cfg.get("remote_tools.xai.to_date"))
90
+
91
+ adv_max_results = self._has_int(cfg.get("remote_tools.xai.max_results"))
92
+ adv_return_cits = cfg.get("remote_tools.xai.return_citations", True) is not True # different than default?
93
+
94
+ # SDK-capable if: mode!=off and ONLY X is enabled and no X filters/date/max_results customizations
95
+ x_only = s_x and not s_web and not s_news and not s_rss
96
+ x_filters = any([adv_x_incl, adv_x_excl, adv_x_favs, adv_x_views])
97
+ sdk_enabled = (mode != "off") and x_only and not any([
98
+ x_filters, adv_from, adv_to, adv_max_results, adv_return_cits
99
+ ])
100
+
101
+ # Build HTTP search_parameters only when needed beyond X-only basic,
102
+ # or when mode explicitly on/auto but SDK cannot represent flags.
103
+ http_params: Optional[dict] = None
104
+ http_reason: Optional[str] = None
105
+
106
+ need_http = (mode != "off") and (
107
+ not sdk_enabled or # advanced X filters or other sources/date/results/citations
108
+ s_web or s_news or s_rss
109
+ )
110
+ if need_http:
111
+ http_params = self._build_http_params(cfg, mode, s_web, s_x, s_news, s_rss)
112
+ http_reason = "advanced_sources_or_filters"
113
+
114
+ return {
115
+ "mode": mode,
116
+ "sdk": {"enabled": sdk_enabled},
117
+ "http": http_params,
118
+ "reason": http_reason,
119
+ }
120
+
121
+ # ---------- helpers ----------
122
+
123
+ def _build_http_params(
124
+ self,
125
+ cfg,
126
+ mode: str,
127
+ s_web: bool,
128
+ s_x: bool,
129
+ s_news: bool,
130
+ s_rss: bool
131
+ ) -> dict:
132
+ """
133
+ Build search_parameters for Chat Completions (HTTP).
134
+
135
+ :param cfg: Config dict
136
+ :param mode: "auto"|"on"|"off"
137
+ :param s_web: Include web search
138
+ :param s_x: Include X search
139
+ :param s_news: Include news search
140
+ :param s_rss: Include RSS search
141
+ :return: search_parameters dict
142
+ """
143
+ params: Dict[str, Any] = {"mode": mode}
144
+
145
+ rc = cfg.get("remote_tools.xai.return_citations")
146
+ if rc is None:
147
+ rc = True
148
+ params["return_citations"] = bool(rc)
149
+
150
+ msr = cfg.get("remote_tools.xai.max_results")
151
+ if isinstance(msr, int) and msr > 0:
152
+ params["max_search_results"] = int(msr)
153
+
154
+ for k_cfg, k_out in (("remote_tools.xai.from_date", "from_date"),
155
+ ("remote_tools.xai.to_date", "to_date")):
156
+ val = cfg.get(k_cfg)
157
+ if isinstance(val, str) and val.strip():
158
+ params[k_out] = val.strip()
159
+
160
+ sources: List[Dict[str, Any]] = []
161
+
162
+ if s_web:
163
+ web: Dict[str, Any] = {"type": "web"}
164
+ country = cfg.get("remote_tools.xai.web.country")
165
+ if isinstance(country, str) and len(country.strip()) == 2:
166
+ web["country"] = country.strip().upper()
167
+ allowed = self._as_list(cfg.get("remote_tools.xai.web.allowed_websites"), 5)
168
+ excluded = self._as_list(cfg.get("remote_tools.xai.web.excluded_websites"), 5)
169
+ if allowed:
170
+ web["allowed_websites"] = allowed
171
+ elif excluded:
172
+ web["excluded_websites"] = excluded
173
+ safe = cfg.get("remote_tools.xai.web.safe_search")
174
+ if safe is not None:
175
+ web["safe_search"] = bool(safe)
176
+ sources.append(web)
177
+
178
+ if s_x:
179
+ xsrc: Dict[str, Any] = {"type": "x"}
180
+ inc = self._as_list(cfg.get("remote_tools.xai.x.included_handles"), 10)
181
+ exc = self._as_list(cfg.get("remote_tools.xai.x.excluded_handles"), 10)
182
+ if inc and not exc:
183
+ xsrc["included_x_handles"] = inc
184
+ elif exc and not inc:
185
+ xsrc["excluded_x_handles"] = exc
186
+ favs = cfg.get("remote_tools.xai.x.min_favs")
187
+ views = cfg.get("remote_tools.xai.x.min_views")
188
+ if isinstance(favs, int) and favs > 0:
189
+ xsrc["post_favorite_count"] = int(favs)
190
+ if isinstance(views, int) and views > 0:
191
+ xsrc["post_view_count"] = int(views)
192
+ sources.append(xsrc)
193
+
194
+ if s_news:
195
+ news: Dict[str, Any] = {"type": "news"}
196
+ country = cfg.get("remote_tools.xai.news.country")
197
+ if isinstance(country, str) and len(country.strip()) == 2:
198
+ news["country"] = country.strip().upper()
199
+ excluded = self._as_list(cfg.get("remote_tools.xai.news.excluded_websites"), 5)
200
+ if excluded:
201
+ news["excluded_websites"] = excluded
202
+ safe = cfg.get("remote_tools.xai.news.safe_search")
203
+ if safe is not None:
204
+ news["safe_search"] = bool(safe)
205
+ sources.append(news)
206
+
207
+ if s_rss:
208
+ link = cfg.get("remote_tools.xai.rss.link")
209
+ if isinstance(link, str) and link.strip():
210
+ sources.append({"type": "rss", "links": [link.strip()]})
211
+
212
+ if sources:
213
+ params["sources"] = sources
214
+
215
+ return params
216
+
217
+ def _has_list(self, v) -> bool:
218
+ """
219
+ Return True if v is a non-empty list/tuple or a non-empty comma-separated string.
220
+
221
+ :param v: Any
222
+ :return: bool
223
+ """
224
+ if v is None:
225
+ return False
226
+ if isinstance(v, (list, tuple)):
227
+ return len([x for x in v if str(x).strip()]) > 0
228
+ return len([x for x in str(v).split(",") if x.strip()]) > 0
229
+
230
+ def _has_str(self, v) -> bool:
231
+ """
232
+ Return True if v is a non-empty string.
233
+
234
+ :param v: Any
235
+ :return: bool
236
+ """
237
+ return isinstance(v, str) and bool(v.strip())
238
+
239
+ def _has_int(self, v) -> bool:
240
+ """
241
+ Return True if v is a positive integer.
242
+
243
+ :param v: Any
244
+ :return: true if v is a positive integer
245
+ """
246
+ return isinstance(v, int) and v > 0
247
+
248
+ def _as_list(self, v, limit: int) -> List[str]:
249
+ """
250
+ Convert v to a list of strings, limited to 'limit' items.
251
+
252
+ :param v: Any
253
+ :param limit: limit number of items
254
+ :return: List of strings
255
+ """
256
+ if v is None:
257
+ return []
258
+ if isinstance(v, (list, tuple)):
259
+ arr = [str(x).strip() for x in v if str(x).strip()]
260
+ else:
261
+ arr = [s.strip() for s in str(v).split(",") if s.strip()]
262
+ return arr[:limit]
@@ -0,0 +1,120 @@
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
+ import json
13
+ from typing import List, Any, Optional
14
+
15
+
16
+ class Tools:
17
+ def __init__(self, window=None):
18
+ """
19
+ Tools mapper for xAI Chat Completions-compatible schema.
20
+
21
+ Input: app 'functions' list with keys: name, desc, params (JSON Schema string).
22
+ Output: list of dicts with keys: name, description, parameters.
23
+
24
+ :param window: Window instance
25
+ """
26
+ self.window = window
27
+
28
+ def _sanitize_schema(self, schema: Any) -> Any:
29
+ """
30
+ Sanitize JSON Schema for tool parameters:
31
+ - Remove unsupported or risky keywords.
32
+ - Normalize 'type'.
33
+ - Ensure properties/items recursively valid.
34
+
35
+ :param schema: JSON Schema (dict or list)
36
+ :return: Sanitized JSON Schema (dict)
37
+ """
38
+ if isinstance(schema, list):
39
+ return self._sanitize_schema(schema[0]) if schema else {}
40
+
41
+ if not isinstance(schema, dict):
42
+ return schema
43
+
44
+ banned = {
45
+ "$defs", "$ref", "$schema", "$id",
46
+ "oneOf", "anyOf", "allOf",
47
+ "patternProperties", "dependentSchemas", "dependentRequired",
48
+ "unevaluatedProperties", "nullable", "readOnly", "writeOnly",
49
+ "examples", "additional_properties", "additionalProperties",
50
+ }
51
+ for k in list(schema.keys()):
52
+ if k in banned:
53
+ schema.pop(k, None)
54
+
55
+ t = schema.get("type")
56
+ if isinstance(t, list):
57
+ t_no_null = [x for x in t if x != "null"]
58
+ schema["type"] = t_no_null[0] if t_no_null else "object"
59
+
60
+ # Recurse
61
+ if (schema.get("type") or "").lower() == "object":
62
+ props = schema.get("properties")
63
+ if not isinstance(props, dict):
64
+ props = {}
65
+ clean = {}
66
+ for k, v in props.items():
67
+ clean[k] = self._sanitize_schema(v)
68
+ schema["properties"] = clean
69
+ req = schema.get("required")
70
+ if not isinstance(req, list) or not all(isinstance(x, str) for x in req):
71
+ schema.pop("required", None)
72
+ elif len(req) == 0:
73
+ schema.pop("required", None)
74
+
75
+ if (schema.get("type") or "").lower() == "array":
76
+ items = schema.get("items")
77
+ if isinstance(items, list) and items:
78
+ items = items[0]
79
+ if not isinstance(items, dict):
80
+ items = {"type": "string"}
81
+ schema["items"] = self._sanitize_schema(items)
82
+
83
+ return schema
84
+
85
+ def prepare(self, functions: list) -> List[dict]:
86
+ """
87
+ Prepare xAI tools list (OpenAI-compatible schema) from app functions list.
88
+
89
+ Returns [] if no functions provided.
90
+
91
+ :param functions: List of functions with keys: name (str), desc (str), params (JSON Schema str)
92
+ :return: List of tools with keys: name (str), description (str), parameters (dict)
93
+ """
94
+ if not functions or not isinstance(functions, list):
95
+ return []
96
+
97
+ tools: List[dict] = []
98
+ for fn in functions:
99
+ name = str(fn.get("name") or "").strip()
100
+ if not name:
101
+ continue
102
+ desc = fn.get("desc") or ""
103
+ params: Optional[dict] = {}
104
+ if fn.get("params"):
105
+ try:
106
+ params = json.loads(fn["params"])
107
+ except Exception:
108
+ params = {}
109
+ params = self._sanitize_schema(params or {})
110
+ if not params.get("type"):
111
+ params["type"] = "object"
112
+ else:
113
+ params = {"type": "object"}
114
+
115
+ tools.append({
116
+ "name": name,
117
+ "description": desc,
118
+ "parameters": params,
119
+ })
120
+ return tools