pygpt-net 2.6.52__py3-none-any.whl → 2.6.54__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 +11 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +4 -0
  4. pygpt_net/controller/audio/audio.py +22 -1
  5. pygpt_net/controller/chat/chat.py +5 -1
  6. pygpt_net/controller/chat/remote_tools.py +116 -0
  7. pygpt_net/controller/lang/mapping.py +2 -1
  8. pygpt_net/controller/mode/mode.py +5 -2
  9. pygpt_net/controller/plugins/plugins.py +29 -3
  10. pygpt_net/controller/realtime/realtime.py +8 -3
  11. pygpt_net/controller/ui/mode.py +17 -5
  12. pygpt_net/core/agents/provider.py +16 -9
  13. pygpt_net/core/models/models.py +25 -1
  14. pygpt_net/core/render/web/renderer.py +3 -1
  15. pygpt_net/data/config/config.json +5 -4
  16. pygpt_net/data/config/models.json +3 -3
  17. pygpt_net/data/icons/web_off.svg +1 -0
  18. pygpt_net/data/icons/web_on.svg +1 -0
  19. pygpt_net/data/js/app.js +19 -0
  20. pygpt_net/data/locale/locale.de.ini +1 -0
  21. pygpt_net/data/locale/locale.en.ini +3 -2
  22. pygpt_net/data/locale/locale.es.ini +1 -0
  23. pygpt_net/data/locale/locale.fr.ini +1 -0
  24. pygpt_net/data/locale/locale.it.ini +1 -0
  25. pygpt_net/data/locale/locale.pl.ini +1 -4
  26. pygpt_net/data/locale/locale.uk.ini +1 -0
  27. pygpt_net/data/locale/locale.zh.ini +1 -0
  28. pygpt_net/data/locale/plugin.mcp.en.ini +4 -4
  29. pygpt_net/data/locale/plugin.osm.en.ini +35 -0
  30. pygpt_net/data/locale/plugin.wolfram.en.ini +24 -0
  31. pygpt_net/icons.qrc +2 -0
  32. pygpt_net/icons_rc.py +232 -147
  33. pygpt_net/js_rc.py +10490 -10432
  34. pygpt_net/plugin/base/worker.py +7 -1
  35. pygpt_net/plugin/osm/__init__.py +12 -0
  36. pygpt_net/plugin/osm/config.py +267 -0
  37. pygpt_net/plugin/osm/plugin.py +87 -0
  38. pygpt_net/plugin/osm/worker.py +719 -0
  39. pygpt_net/plugin/wolfram/__init__.py +12 -0
  40. pygpt_net/plugin/wolfram/config.py +214 -0
  41. pygpt_net/plugin/wolfram/plugin.py +115 -0
  42. pygpt_net/plugin/wolfram/worker.py +551 -0
  43. pygpt_net/provider/api/anthropic/tools.py +4 -2
  44. pygpt_net/provider/api/google/__init__.py +3 -2
  45. pygpt_net/provider/api/google/video.py +0 -0
  46. pygpt_net/provider/api/openai/agents/experts.py +1 -1
  47. pygpt_net/provider/api/openai/agents/remote_tools.py +14 -4
  48. pygpt_net/provider/api/openai/chat.py +7 -2
  49. pygpt_net/provider/api/openai/remote_tools.py +5 -2
  50. pygpt_net/provider/api/x_ai/remote.py +6 -1
  51. pygpt_net/provider/core/config/patch.py +8 -1
  52. pygpt_net/provider/llms/anthropic.py +29 -1
  53. pygpt_net/provider/llms/google.py +30 -1
  54. pygpt_net/provider/llms/open_router.py +3 -1
  55. pygpt_net/provider/llms/x_ai.py +21 -1
  56. pygpt_net/ui/layout/chat/output.py +7 -2
  57. {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/METADATA +37 -2
  58. {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/RECORD +60 -47
  59. {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/LICENSE +0 -0
  60. {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/WHEEL +0 -0
  61. {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,551 @@
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.18 00:25:36 #
10
+ # ================================================== #
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import time
18
+ from typing import Any, Dict, List, Optional
19
+ from urllib.parse import quote # kept for parity, not strictly needed everywhere
20
+
21
+ import requests
22
+ from PySide6.QtCore import Slot
23
+
24
+ from pygpt_net.plugin.base.worker import BaseWorker, BaseSignals
25
+
26
+
27
+ class WorkerSignals(BaseSignals):
28
+ pass
29
+
30
+
31
+ class Worker(BaseWorker):
32
+ """
33
+ Wolfram Alpha plugin worker: Short answers, Full JSON query, Math compute,
34
+ Solve equations, Derivatives, Integrals, Unit conversions, Matrix ops, Plots.
35
+ """
36
+
37
+ def __init__(self, *args, **kwargs):
38
+ super(Worker, self).__init__()
39
+ self.signals = WorkerSignals()
40
+ self.args = args
41
+ self.kwargs = kwargs
42
+ self.plugin = None
43
+ self.cmds = None
44
+ self.ctx = None
45
+ self.msg = None
46
+
47
+ # ---------------------- Core runner ----------------------
48
+
49
+ @Slot()
50
+ def run(self):
51
+ try:
52
+ responses = []
53
+ for item in self.cmds:
54
+ if self.is_stopped():
55
+ break
56
+ try:
57
+ response = None
58
+ if item["cmd"] in self.plugin.allowed_cmds and self.plugin.has_cmd(item["cmd"]):
59
+
60
+ # Generic query endpoints
61
+ if item["cmd"] == "wa_short":
62
+ response = self.cmd_wa_short(item)
63
+ elif item["cmd"] == "wa_spoken":
64
+ response = self.cmd_wa_spoken(item)
65
+ elif item["cmd"] == "wa_simple":
66
+ response = self.cmd_wa_simple(item)
67
+ elif item["cmd"] == "wa_query":
68
+ response = self.cmd_wa_query(item)
69
+
70
+ # Convenience math commands
71
+ elif item["cmd"] == "wa_calculate":
72
+ response = self.cmd_wa_calculate(item)
73
+ elif item["cmd"] == "wa_solve":
74
+ response = self.cmd_wa_solve(item)
75
+ elif item["cmd"] == "wa_derivative":
76
+ response = self.cmd_wa_derivative(item)
77
+ elif item["cmd"] == "wa_integral":
78
+ response = self.cmd_wa_integral(item)
79
+ elif item["cmd"] == "wa_units_convert":
80
+ response = self.cmd_wa_units_convert(item)
81
+ elif item["cmd"] == "wa_matrix":
82
+ response = self.cmd_wa_matrix(item)
83
+ elif item["cmd"] == "wa_plot":
84
+ response = self.cmd_wa_plot(item)
85
+
86
+ if response is not None:
87
+ responses.append(response)
88
+
89
+ except Exception as e:
90
+ responses.append(self.make_response(item, self.throw_error(e)))
91
+
92
+ if responses:
93
+ self.reply_more(responses)
94
+ if self.msg is not None:
95
+ self.status(self.msg)
96
+ except Exception as e:
97
+ self.error(e)
98
+ finally:
99
+ self.cleanup()
100
+
101
+ # ---------------------- HTTP / Helpers ----------------------
102
+
103
+ def _api_base(self) -> str:
104
+ return (self.plugin.get_option_value("api_base") or "https://api.wolframalpha.com").rstrip("/")
105
+
106
+ def _timeout(self) -> int:
107
+ try:
108
+ return int(self.plugin.get_option_value("http_timeout") or 30)
109
+ except Exception:
110
+ return 30
111
+
112
+ def _appid(self) -> str:
113
+ appid = (self.plugin.get_option_value("wa_appid") or "").strip()
114
+ if not appid:
115
+ raise RuntimeError("Missing Wolfram Alpha AppID (set 'wa_appid' in plugin options).")
116
+ return appid
117
+
118
+ def _units(self) -> Optional[str]:
119
+ units = (self.plugin.get_option_value("units") or "").strip().lower()
120
+ return units if units in ("metric", "nonmetric") else None
121
+
122
+ def _headers(self) -> Dict[str, str]:
123
+ return {
124
+ "User-Agent": "pygpt-net-wolframalpha-plugin/1.0",
125
+ "Accept": "*/*",
126
+ }
127
+
128
+ def _get_raw(self, path: str, params: dict | None = None) -> requests.Response:
129
+ url = f"{self._api_base()}{path}"
130
+ r = requests.get(url, headers=self._headers(), params=params or {}, timeout=self._timeout())
131
+ return r
132
+
133
+ def _handle_json(self, r: requests.Response) -> dict:
134
+ # For /v2/query?output=json
135
+ try:
136
+ payload = r.json() if r.content else None
137
+ except Exception:
138
+ payload = r.text
139
+
140
+ if not (200 <= r.status_code < 300):
141
+ message = payload if isinstance(payload, str) else None
142
+ raise RuntimeError(json.dumps({
143
+ "status": r.status_code,
144
+ "error": message or "HTTP error",
145
+ }, ensure_ascii=False))
146
+
147
+ # Normalize
148
+ if isinstance(payload, dict):
149
+ ret = payload
150
+ else:
151
+ ret = {"data": payload}
152
+
153
+ ret["_meta"] = {
154
+ "status": r.status_code,
155
+ "content_type": r.headers.get("Content-Type"),
156
+ }
157
+ return ret
158
+
159
+ def _handle_text(self, r: requests.Response) -> dict:
160
+ # For short/spoken result endpoints
161
+ txt = r.text if r.content else ""
162
+ if not (200 <= r.status_code < 300):
163
+ # WA short endpoints may return 501 if no concise answer
164
+ return {
165
+ "ok": False,
166
+ "status": r.status_code,
167
+ "text": txt,
168
+ "_meta": {"content_type": r.headers.get("Content-Type")},
169
+ }
170
+ return {
171
+ "ok": True,
172
+ "status": r.status_code,
173
+ "text": txt,
174
+ "_meta": {"content_type": r.headers.get("Content-Type")},
175
+ }
176
+
177
+ def _save_bytes(self, data: bytes, out_path: str) -> str:
178
+ local = self.prepare_path(out_path)
179
+ os.makedirs(os.path.dirname(local), exist_ok=True)
180
+ with open(local, "wb") as fh:
181
+ fh.write(data)
182
+ return local
183
+
184
+ def _slug(self, s: str, maxlen: int = 80) -> str:
185
+ s = re.sub(r"\s+", "_", s.strip())
186
+ s = re.sub(r"[^a-zA-Z0-9_\-\.]", "", s)
187
+ return (s[:maxlen] or "plot").strip("._-") or "plot"
188
+
189
+ def _now(self) -> int:
190
+ return int(time.time())
191
+
192
+ def prepare_path(self, path: str) -> str:
193
+ if path in [".", "./"]:
194
+ return self.plugin.window.core.config.get_user_dir("data")
195
+ if self.is_absolute_path(path):
196
+ return path
197
+ return os.path.join(self.plugin.window.core.config.get_user_dir("data"), path)
198
+
199
+ def is_absolute_path(self, path: str) -> bool:
200
+ return os.path.isabs(path)
201
+
202
+ # ---------------------- Parsing helpers ----------------------
203
+
204
+ def _extract_primary_plaintext(self, query_json: dict) -> Optional[str]:
205
+ try:
206
+ qr = query_json.get("queryresult") or {}
207
+ pods = qr.get("pods") or []
208
+ # Prefer primary pod
209
+ for p in pods:
210
+ if p.get("primary"):
211
+ texts = [sp.get("plaintext") for sp in (p.get("subpods") or []) if sp.get("plaintext")]
212
+ if texts:
213
+ return "\n".join(texts)
214
+ # Fallback to common pod titles
215
+ prefer_ids = {"Result", "Results", "ExactResult", "Exact result", "Decimal approximation", "Solution", "Solutions"}
216
+ for p in pods:
217
+ if (p.get("id") in prefer_ids) or (p.get("title") in prefer_ids):
218
+ texts = [sp.get("plaintext") for sp in (p.get("subpods") or []) if sp.get("plaintext")]
219
+ if texts:
220
+ return "\n".join(texts)
221
+ # Last resort: first plaintext
222
+ for p in pods:
223
+ for sp in (p.get("subpods") or []):
224
+ if sp.get("plaintext"):
225
+ return sp["plaintext"]
226
+ except Exception:
227
+ pass
228
+ return None
229
+
230
+ def _matrix_literal(self, matrix: List[List[Any]]) -> str:
231
+ rows = []
232
+ for row in matrix:
233
+ row_str = ",".join(str(x) for x in row)
234
+ rows.append("{" + row_str + "}")
235
+ return "{" + ",".join(rows) + "}"
236
+
237
+ # ---------------------- Internal cross-call helper ----------------------
238
+
239
+ def _call_cmd(self, cmd: str, params: dict) -> dict:
240
+ """Call another command inside this worker with a proper envelope so make_response() works."""
241
+ fn = getattr(self, f"cmd_{cmd}", None)
242
+ if not callable(fn):
243
+ raise RuntimeError(f"Unknown command: {cmd}")
244
+ return fn({"cmd": cmd, "params": params})
245
+
246
+ def _add_image(self, path: str):
247
+ """Register saved image path in context for downstream UI/use."""
248
+ try:
249
+ if self.ctx is None:
250
+ return
251
+ if path:
252
+ path = self.plugin.window.core.filesystem.to_workdir(path)
253
+ # Ensure list exists
254
+ if not hasattr(self.ctx, "images_before") or self.ctx.images_before is None:
255
+ self.ctx.images_before = []
256
+ # Avoid duplicates
257
+ if path and path not in self.ctx.images_before:
258
+ self.ctx.images_before.append(path)
259
+ except Exception:
260
+ # Keep silent: context may not be attached in some runs/tests
261
+ pass
262
+
263
+ # ---------------------- Endpoint commands ----------------------
264
+
265
+ def cmd_wa_short(self, item: dict) -> dict:
266
+ p = item.get("params", {})
267
+ query = p.get("query") or p.get("i")
268
+ if not query:
269
+ return self.make_response(item, "Param 'query' required")
270
+ params = {"appid": self._appid(), "i": query}
271
+ units = self._units()
272
+ if units:
273
+ params["units"] = units
274
+ r = self._get_raw("/v2/result", params=params)
275
+ res = self._handle_text(r)
276
+ return self.make_response(item, res)
277
+
278
+ def cmd_wa_spoken(self, item: dict) -> dict:
279
+ p = item.get("params", {})
280
+ query = p.get("query") or p.get("i")
281
+ if not query:
282
+ return self.make_response(item, "Param 'query' required")
283
+ params = {"appid": self._appid(), "i": query}
284
+ units = self._units()
285
+ if units:
286
+ params["units"] = units
287
+ r = self._get_raw("/v1/spoken", params=params)
288
+ res = self._handle_text(r)
289
+ return self.make_response(item, res)
290
+
291
+ def cmd_wa_simple(self, item: dict) -> dict:
292
+ p = item.get("params", {})
293
+ query = p.get("query") or p.get("i")
294
+ out = p.get("out") # suggested file path relative to data dir
295
+ if not query:
296
+ return self.make_response(item, "Param 'query' required")
297
+ params = {"appid": self._appid(), "i": query}
298
+ units = self._units()
299
+ if units:
300
+ params["units"] = units
301
+ # Optional presentation params
302
+ bg = (p.get("background") or self.plugin.get_option_value("simple_background") or "white").lower()
303
+ if bg in ("white", "transparent"):
304
+ params["background"] = bg
305
+ layout = (p.get("layout") or self.plugin.get_option_value("simple_layout") or "labelbar").lower()
306
+ params["layout"] = layout
307
+ width = p.get("width") or self.plugin.get_option_value("simple_width")
308
+ if width:
309
+ params["width"] = int(width)
310
+
311
+ r = self._get_raw("/v2/simple", params=params)
312
+ if not (200 <= r.status_code < 300):
313
+ return self.make_response(item, {
314
+ "ok": False,
315
+ "status": r.status_code,
316
+ "error": r.text,
317
+ "_meta": {"content_type": r.headers.get("Content-Type")},
318
+ })
319
+ # Save image
320
+ ext = "gif"
321
+ ctype = r.headers.get("Content-Type", "")
322
+ if "png" in ctype:
323
+ ext = "png"
324
+ elif "jpeg" in ctype or "jpg" in ctype:
325
+ ext = "jpg"
326
+ if not out:
327
+ fname = f"wa_simple_{self._slug(query, 60)}_{self._now()}.{ext}"
328
+ out = os.path.join("wolframalpha", fname)
329
+ local = self._save_bytes(r.content, out)
330
+ # register image in context
331
+ self._add_image(local)
332
+ return self.make_response(item, {
333
+ "ok": True,
334
+ "file": local,
335
+ "_meta": {"status": r.status_code, "content_type": ctype},
336
+ })
337
+
338
+ def cmd_wa_query(self, item: dict) -> dict:
339
+ p = item.get("params", {})
340
+ query = p.get("query") or p.get("input")
341
+ if not query:
342
+ return self.make_response(item, "Param 'query' required")
343
+ params: Dict[str, Any] = {
344
+ "appid": self._appid(),
345
+ "input": query,
346
+ "output": "json",
347
+ "format": p.get("format") or "plaintext,image",
348
+ }
349
+ units = self._units()
350
+ if units:
351
+ params["units"] = units
352
+ if p.get("podstate"):
353
+ params["podstate"] = p["podstate"]
354
+ # Timeouts
355
+ if p.get("scantimeout") is not None:
356
+ params["scantimeout"] = int(p["scantimeout"])
357
+ if p.get("podtimeout") is not None:
358
+ params["podtimeout"] = int(p["podtimeout"])
359
+ # Size
360
+ if p.get("maxwidth") is not None:
361
+ params["maxwidth"] = int(p["maxwidth"])
362
+
363
+ assumptions = p.get("assumptions") or []
364
+ base_url = f"{self._api_base()}/v2/query"
365
+ req_params = []
366
+ for k, v in params.items():
367
+ req_params.append((k, v))
368
+ if isinstance(assumptions, list):
369
+ for a in assumptions:
370
+ req_params.append(("assumption", a))
371
+
372
+ r = requests.get(base_url, headers=self._headers(), params=req_params, timeout=self._timeout())
373
+ data = self._handle_json(r)
374
+
375
+ # Optional image download from pods
376
+ if p.get("download_images"):
377
+ max_images = int(p.get("max_images") or 10)
378
+ saved: List[Dict[str, Any]] = []
379
+ try:
380
+ pods = (data.get("queryresult") or {}).get("pods") or []
381
+ cnt = 0
382
+ for pod in pods:
383
+ for sp in (pod.get("subpods") or []):
384
+ img = sp.get("img")
385
+ if img and img.get("src"):
386
+ if cnt >= max_images:
387
+ break
388
+ img_url = img["src"]
389
+ ir = requests.get(img_url, headers=self._headers(), timeout=self._timeout())
390
+ if 200 <= ir.status_code < 300:
391
+ ctype = ir.headers.get("Content-Type") or ""
392
+ ext = "png" if "png" in ctype else ("jpg" if ("jpeg" in ctype or "jpg" in ctype) else "gif")
393
+ fname = f"wa_pod_{self._slug(pod.get('id') or pod.get('title') or 'pod')}_{cnt}_{self._now()}.{ext}"
394
+ local = self._save_bytes(ir.content, os.path.join("wolframalpha", fname))
395
+ saved.append({"pod": pod.get("id") or pod.get("title"), "file": local})
396
+ # register image in context
397
+ self._add_image(local)
398
+ cnt += 1
399
+ if cnt >= max_images:
400
+ break
401
+ except Exception:
402
+ pass
403
+ data["_downloaded_images"] = saved
404
+
405
+ # Convenience primary plaintext
406
+ primary = self._extract_primary_plaintext(data)
407
+ if primary is not None:
408
+ data["_primary_plaintext"] = primary
409
+
410
+ return self.make_response(item, data)
411
+
412
+ # ---------------------- Convenience math commands ----------------------
413
+
414
+ def cmd_wa_calculate(self, item: dict) -> dict:
415
+ p = item.get("params", {})
416
+ expr = p.get("expr") or p.get("expression") or p.get("query")
417
+ if not expr:
418
+ return self.make_response(item, "Param 'expr' required")
419
+ # Try short answer first
420
+ short = self._call_cmd("wa_short", {"query": expr})
421
+ sdata = short.get("data") or short
422
+ if isinstance(sdata, dict) and sdata.get("ok"):
423
+ return self.make_response(item, {"expr": expr, "result": sdata.get("text"), "_via": "short"})
424
+ # Fallback to full JSON
425
+ full = self._call_cmd("wa_query", {"query": expr})
426
+ fdata = full.get("data") or full
427
+ if isinstance(fdata, dict):
428
+ primary = fdata.get("_primary_plaintext")
429
+ if primary:
430
+ return self.make_response(item, {"expr": expr, "result": primary, "_via": "query"})
431
+ return self.make_response(item, {"expr": expr, "error": "No result"})
432
+
433
+ def cmd_wa_solve(self, item: dict) -> dict:
434
+ p = item.get("params", {})
435
+ eq = p.get("equation") or p.get("eq")
436
+ eqs = p.get("equations") or []
437
+ var = p.get("var") or p.get("variable")
438
+ vars_ = p.get("vars") or p.get("variables") or []
439
+ domain = p.get("domain") # reals|integers|complexes
440
+ if not (eq or eqs):
441
+ return self.make_response(item, "Param 'equation' or 'equations' required")
442
+ if isinstance(eqs, list) and eq:
443
+ eqs = [eq] + eqs
444
+ elif not isinstance(eqs, list) and eq:
445
+ eqs = [eq]
446
+ if vars_ and not isinstance(vars_, list):
447
+ vars_ = [vars_]
448
+ if var and var not in vars_:
449
+ vars_.append(var)
450
+ vars_part = f" for {{{', '.join(vars_)}}}" if vars_ else ""
451
+ dom_part = f" over the {domain}" if domain else ""
452
+ query = f"solve {{{'; '.join(eqs)}}}{vars_part}{dom_part}"
453
+ res = self._call_cmd("wa_query", {"query": query})
454
+ data = res.get("data") or res
455
+ if isinstance(data, dict) and data.get("_primary_plaintext"):
456
+ return self.make_response(item, {"query": query, "solution": data["_primary_plaintext"], "_raw": data})
457
+ return self.make_response(item, {"query": query, "error": "No solution", "_raw": data})
458
+
459
+ def cmd_wa_derivative(self, item: dict) -> dict:
460
+ p = item.get("params", {})
461
+ expr = p.get("expr") or p.get("expression")
462
+ var = p.get("var") or "x"
463
+ order = int(p.get("order") or 1)
464
+ at = p.get("at") # e.g., "x=0"
465
+ if not expr:
466
+ return self.make_response(item, "Param 'expr' required")
467
+ q = f"derivative order {order} of ({expr}) with respect to {var}"
468
+ if at:
469
+ q += f" at {at}"
470
+ res = self._call_cmd("wa_query", {"query": q})
471
+ data = res.get("data") or res
472
+ if isinstance(data, dict) and data.get("_primary_plaintext"):
473
+ return self.make_response(item, {"query": q, "derivative": data["_primary_plaintext"], "_raw": data})
474
+ return self.make_response(item, {"query": q, "error": "No result", "_raw": data})
475
+
476
+ def cmd_wa_integral(self, item: dict) -> dict:
477
+ p = item.get("params", {})
478
+ expr = p.get("expr") or p.get("expression")
479
+ var = p.get("var") or "x"
480
+ a = p.get("a")
481
+ b = p.get("b")
482
+ if not expr:
483
+ return self.make_response(item, "Param 'expr' required")
484
+ if a is not None and b is not None:
485
+ q = f"integrate ({expr}) with respect to {var} from {a} to {b}"
486
+ else:
487
+ q = f"integrate ({expr}) with respect to {var}"
488
+ res = self._call_cmd("wa_query", {"query": q})
489
+ data = res.get("data") or res
490
+ if isinstance(data, dict) and data.get("_primary_plaintext"):
491
+ return self.make_response(item, {"query": q, "integral": data["_primary_plaintext"], "_raw": data})
492
+ return self.make_response(item, {"query": q, "error": "No result", "_raw": data})
493
+
494
+ def cmd_wa_units_convert(self, item: dict) -> dict:
495
+ p = item.get("params", {})
496
+ value = p.get("value")
497
+ from_unit = p.get("from")
498
+ to_unit = p.get("to")
499
+ if value is None or not from_unit or not to_unit:
500
+ return self.make_response(item, "Params 'value','from','to' required")
501
+ q = f"convert {value} {from_unit} to {to_unit}"
502
+ res = self._call_cmd("wa_query", {"query": q})
503
+ data = res.get("data") or res
504
+ if isinstance(data, dict) and data.get("_primary_plaintext"):
505
+ return self.make_response(item, {"query": q, "conversion": data["_primary_plaintext"], "_raw": data})
506
+ # Try short if no plaintext
507
+ short = self._call_cmd("wa_short", {"query": q})
508
+ sdata = short.get("data") or short
509
+ if isinstance(sdata, dict) and sdata.get("ok"):
510
+ return self.make_response(item, {"query": q, "conversion": sdata.get("text"), "_via": "short"})
511
+ return self.make_response(item, {"query": q, "error": "No result", "_raw": data})
512
+
513
+ def cmd_wa_matrix(self, item: dict) -> dict:
514
+ p = item.get("params", {})
515
+ op = (p.get("op") or "determinant").lower() # determinant|inverse|eigenvalues|rank
516
+ matrix = p.get("matrix")
517
+ if not (matrix and isinstance(matrix, list) and all(isinstance(r, list) for r in matrix)):
518
+ return self.make_response(item, "Param 'matrix' must be list of lists")
519
+ literal = self._matrix_literal(matrix)
520
+ if op == "determinant":
521
+ q = f"determinant {literal}"
522
+ elif op == "inverse":
523
+ q = f"inverse {literal}"
524
+ elif op == "eigenvalues":
525
+ q = f"eigenvalues {literal}"
526
+ elif op == "rank":
527
+ q = f"rank {literal}"
528
+ else:
529
+ q = f"{op} {literal}"
530
+ res = self._call_cmd("wa_query", {"query": q})
531
+ data = res.get("data") or res
532
+ if isinstance(data, dict) and data.get("_primary_plaintext"):
533
+ return self.make_response(item, {"query": q, "result": data["_primary_plaintext"], "_raw": data})
534
+ return self.make_response(item, {"query": q, "error": "No result", "_raw": data})
535
+
536
+ def cmd_wa_plot(self, item: dict) -> dict:
537
+ p = item.get("params", {})
538
+ func = p.get("func") or p.get("f") or p.get("function")
539
+ var = p.get("var") or "x"
540
+ a = p.get("a")
541
+ b = p.get("b")
542
+ out = p.get("out")
543
+ if not func:
544
+ return self.make_response(item, "Param 'func' required")
545
+ if a is not None and b is not None:
546
+ q = f"plot {func} for {var} from {a} to {b}"
547
+ else:
548
+ q = f"plot {func}"
549
+ # Use Simple API to get a ready image
550
+ simple_res = self._call_cmd("wa_simple", {"query": q, "out": out})
551
+ return self.make_response(item, simple_res.get("data") or simple_res)
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.05 01:00:00 #
9
+ # Updated Date: 2025.09.17 05:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
@@ -177,8 +177,10 @@ class Tools:
177
177
  if model and model.id and model.id.startswith("claude-3-5"):
178
178
  return tools
179
179
 
180
+ is_web = self.window.controller.chat.remote_tools.enabled(model, "web_search") # get global config
181
+
180
182
  # Web Search tool
181
- if cfg.get("remote_tools.anthropic.web_search"):
183
+ if is_web:
182
184
  ttype = cfg.get("remote_tools.anthropic.web_search.type", "web_search_20250305") # stable as of docs
183
185
  tname = "web_search"
184
186
 
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.15 01:00:00 #
9
+ # Updated Date: 2025.09.17 05:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
@@ -260,9 +260,10 @@ class ApiGoogle:
260
260
  tools: list = []
261
261
  cfg = self.window.core.config
262
262
  model_id = (model.id if model and getattr(model, "id", None) else "").lower()
263
+ is_web = self.window.controller.chat.remote_tools.enabled(model, "web_search") # get global config
263
264
 
264
265
  # Google Search tool
265
- if cfg.get("remote_tools.google.web_search") and "image" not in model.id:
266
+ if is_web and "image" not in model.id:
266
267
  try:
267
268
  if not model_id.startswith("gemini-1.5") and not model_id.startswith("models/gemini-1.5"):
268
269
  # Gemini 2.x uses GoogleSearch
File without changes
@@ -13,7 +13,7 @@ from agents import (
13
13
  from pygpt_net.item.model import ModelItem
14
14
  from pygpt_net.item.preset import PresetItem
15
15
 
16
- from pygpt_net.provider.api.openai.agents.remote_tools import append_tools
16
+ from .remote_tools import append_tools
17
17
 
18
18
 
19
19
  def get_experts(
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.01 03:00:00 #
9
+ # Updated Date: 2025.09.17 05:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
@@ -135,9 +135,11 @@ def get_remote_tools(
135
135
  "computer_use": False,
136
136
  }
137
137
 
138
+ enabled_global = window.controller.chat.remote_tools.enabled # get global config
139
+
138
140
  # from global config if not expert call
139
141
  if not is_expert_call:
140
- enabled["web_search"] = window.core.config.get("remote_tools.web_search", False)
142
+ enabled["web_search"] = enabled_global(model, "web_search") # <-- from global config
141
143
  enabled["image"] = window.core.config.get("remote_tools.image", False)
142
144
  enabled["code_interpreter"] = window.core.config.get("remote_tools.code_interpreter", False)
143
145
  enabled["mcp"] = window.core.config.get("remote_tools.mcp", False)
@@ -147,8 +149,16 @@ def get_remote_tools(
147
149
  # for expert call, get from preset config
148
150
  if preset:
149
151
  if preset.remote_tools:
150
- tools_list = [preset_remote_tool.strip() for preset_remote_tool in preset.remote_tools.split(",") if
151
- preset_remote_tool.strip()]
152
+ if isinstance(preset.remote_tools, str):
153
+ tools_list = [preset_remote_tool.strip() for preset_remote_tool in preset.remote_tools.split(",") if
154
+ preset_remote_tool.strip()]
155
+ elif isinstance(preset.remote_tools, list):
156
+ tools_list = [str(preset_remote_tool).strip() for preset_remote_tool in preset.remote_tools if
157
+ str(preset_remote_tool).strip()]
158
+ else:
159
+ tools_list = []
160
+ if "web_search" not in tools_list:
161
+ enabled["web_search"] = enabled_global(model, "web_search") # <-- from global config
152
162
  for item in tools_list:
153
163
  if item in enabled:
154
164
  enabled[item] = True
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.28 09:00:00 #
9
+ # Updated Date: 2025.09.17 19:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
@@ -145,9 +145,14 @@ class Chat:
145
145
  if stream:
146
146
  response_kwargs['stream_options'] = {"include_usage": True}
147
147
 
148
+ # OpenRouter: add web search remote tool (if enabled)
149
+ model_id = model.id
150
+ if model.provider == "open_router":
151
+ model_id = self.window.core.models.get_openrouter_model(model)
152
+
148
153
  response = client.chat.completions.create(
149
154
  messages=messages,
150
- model=model.id,
155
+ model=model_id,
151
156
  stream=stream,
152
157
  **response_kwargs,
153
158
  )