pygpt-net 2.6.65__py3-none-any.whl → 2.6.67__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 (50) hide show
  1. pygpt_net/CHANGELOG.txt +17 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +2 -0
  4. pygpt_net/controller/chat/chat.py +0 -0
  5. pygpt_net/controller/chat/handler/openai_stream.py +137 -7
  6. pygpt_net/controller/chat/render.py +0 -0
  7. pygpt_net/controller/config/field/checkbox_list.py +34 -1
  8. pygpt_net/controller/config/field/textarea.py +2 -2
  9. pygpt_net/controller/dialogs/info.py +2 -2
  10. pygpt_net/controller/media/media.py +48 -1
  11. pygpt_net/controller/model/editor.py +74 -9
  12. pygpt_net/controller/presets/presets.py +4 -1
  13. pygpt_net/controller/settings/editor.py +25 -1
  14. pygpt_net/controller/ui/mode.py +14 -10
  15. pygpt_net/controller/ui/ui.py +18 -1
  16. pygpt_net/core/image/image.py +34 -1
  17. pygpt_net/core/tabs/tabs.py +0 -0
  18. pygpt_net/core/types/image.py +70 -3
  19. pygpt_net/core/video/video.py +43 -3
  20. pygpt_net/data/config/config.json +4 -3
  21. pygpt_net/data/config/models.json +637 -38
  22. pygpt_net/data/locale/locale.de.ini +5 -0
  23. pygpt_net/data/locale/locale.en.ini +5 -0
  24. pygpt_net/data/locale/locale.es.ini +5 -0
  25. pygpt_net/data/locale/locale.fr.ini +5 -0
  26. pygpt_net/data/locale/locale.it.ini +5 -0
  27. pygpt_net/data/locale/locale.pl.ini +5 -0
  28. pygpt_net/data/locale/locale.uk.ini +5 -0
  29. pygpt_net/data/locale/locale.zh.ini +5 -0
  30. pygpt_net/item/model.py +15 -19
  31. pygpt_net/provider/agents/openai/agent.py +0 -0
  32. pygpt_net/provider/api/google/__init__.py +20 -9
  33. pygpt_net/provider/api/google/image.py +161 -28
  34. pygpt_net/provider/api/google/video.py +73 -36
  35. pygpt_net/provider/api/openai/__init__.py +21 -11
  36. pygpt_net/provider/api/openai/agents/client.py +0 -0
  37. pygpt_net/provider/api/openai/video.py +562 -0
  38. pygpt_net/provider/core/config/patch.py +7 -0
  39. pygpt_net/provider/core/model/patch.py +54 -3
  40. pygpt_net/provider/vector_stores/qdrant.py +117 -0
  41. pygpt_net/ui/dialog/models.py +10 -1
  42. pygpt_net/ui/layout/toolbox/raw.py +7 -1
  43. pygpt_net/ui/layout/toolbox/video.py +14 -6
  44. pygpt_net/ui/widget/option/checkbox_list.py +14 -2
  45. pygpt_net/ui/widget/option/input.py +3 -1
  46. {pygpt_net-2.6.65.dist-info → pygpt_net-2.6.67.dist-info}/METADATA +72 -25
  47. {pygpt_net-2.6.65.dist-info → pygpt_net-2.6.67.dist-info}/RECORD +45 -43
  48. {pygpt_net-2.6.65.dist-info → pygpt_net-2.6.67.dist-info}/LICENSE +0 -0
  49. {pygpt_net-2.6.65.dist-info → pygpt_net-2.6.67.dist-info}/WHEEL +0 -0
  50. {pygpt_net-2.6.65.dist-info → pygpt_net-2.6.67.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,562 @@
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.12.26 12:00:00 #
10
+ # ================================================== #
11
+
12
+ import datetime
13
+ import io
14
+ import json
15
+ import mimetypes
16
+ import os
17
+ import time
18
+ from typing import Optional, Dict, Any, List, Tuple
19
+
20
+ from openai import OpenAI
21
+
22
+ from PySide6.QtCore import QObject, Signal, QRunnable, Slot
23
+
24
+ from pygpt_net.core.events import KernelEvent
25
+ from pygpt_net.core.bridge.context import BridgeContext
26
+ from pygpt_net.item.ctx import CtxItem
27
+ from pygpt_net.utils import trans
28
+
29
+
30
+ class Video:
31
+ """
32
+ OpenAI Sora 2 video generation wrapper.
33
+ """
34
+
35
+ MODE_GENERATE = "generate"
36
+ MODE_IMAGE_TO_VIDEO = "image2video"
37
+
38
+ def __init__(self, window=None):
39
+ self.window = window
40
+ self.worker: Optional[VideoWorker] = None
41
+
42
+ def generate(
43
+ self,
44
+ context: BridgeContext,
45
+ extra: Optional[Dict[str, Any]] = None,
46
+ sync: bool = True,
47
+ ) -> bool:
48
+ """
49
+ Generate video(s) using OpenAI Sora 2 API.
50
+
51
+ :param context: BridgeContext with prompt, model, attachments
52
+ :param extra: extra parameters (num, inline, aspect_ratio, duration, resolution)
53
+ :param sync: run synchronously (blocking) if True
54
+ :return: True if started
55
+ """
56
+ extra = extra or {}
57
+ ctx = context.ctx or CtxItem()
58
+ model = context.model
59
+ prompt = context.prompt
60
+ num = int(extra.get("num", 1))
61
+ inline = bool(extra.get("inline", False))
62
+
63
+ # decide sub-mode based on attachments (image-to-video when image is attached)
64
+ sub_mode = self.MODE_GENERATE
65
+ attachments = context.attachments or {}
66
+ if self._has_image_attachment(attachments):
67
+ sub_mode = self.MODE_IMAGE_TO_VIDEO
68
+
69
+ # model used to improve the prompt (not video model)
70
+ prompt_model = self.window.core.models.from_defaults()
71
+ tmp = self.window.core.config.get('video.prompt_model')
72
+ if self.window.core.models.has(tmp):
73
+ prompt_model = self.window.core.models.get(tmp)
74
+
75
+ worker = VideoWorker()
76
+ worker.window = self.window
77
+ worker.client = self.window.core.api.openai.get_client() # configured client
78
+ worker.ctx = ctx
79
+ worker.mode = sub_mode
80
+ worker.attachments = attachments
81
+ worker.model = (model.id if model and getattr(model, "id", None) else "sora-2")
82
+ worker.input_prompt = prompt or ""
83
+ worker.model_prompt = prompt_model # LLM for prompt rewriting
84
+ worker.system_prompt = self.window.core.prompt.get('video')
85
+ worker.raw = self.window.core.config.get('img_raw')
86
+ worker.num = num
87
+ worker.inline = inline
88
+
89
+ # optional params (app-level options)
90
+ worker.aspect_ratio = str(extra.get("aspect_ratio") or self.window.core.config.get('video.aspect_ratio') or "16:9")
91
+ worker.duration_seconds = int(extra.get("duration") or self.window.core.config.get('video.duration') or 8)
92
+ worker.resolution = (extra.get("resolution") or self.window.core.config.get('video.resolution') or "720p")
93
+
94
+ # Sora limits; one output per job
95
+ worker.max_per_job = 1
96
+
97
+ self.worker = worker
98
+ self.worker.signals.finished.connect(self.window.core.video.handle_finished)
99
+ self.worker.signals.finished_inline.connect(self.window.core.video.handle_finished_inline)
100
+ self.worker.signals.status.connect(self.window.core.video.handle_status)
101
+ self.worker.signals.error.connect(self.window.core.video.handle_error)
102
+
103
+ if sync or not self.window.controller.kernel.async_allowed(ctx):
104
+ self.worker.run()
105
+ return True
106
+
107
+ self.window.dispatch(KernelEvent(KernelEvent.STATE_BUSY, {"id": "video"}))
108
+ self.window.threadpool.start(self.worker)
109
+ return True
110
+
111
+ def _has_image_attachment(self, attachments: Dict[str, Any]) -> bool:
112
+ """Check if at least one image attachment is present."""
113
+ for _, att in (attachments or {}).items():
114
+ try:
115
+ p = getattr(att, "path", None)
116
+ if p and os.path.exists(p):
117
+ mt, _ = mimetypes.guess_type(p)
118
+ if mt and mt.startswith("image/"):
119
+ return True
120
+ except Exception:
121
+ continue
122
+ return False
123
+
124
+
125
+ class VideoSignals(QObject):
126
+ finished = Signal(object, list, str) # ctx, paths, prompt
127
+ finished_inline = Signal(object, list, str) # ctx, paths, prompt
128
+ status = Signal(object) # message
129
+ error = Signal(object) # exception
130
+
131
+
132
+ class VideoWorker(QRunnable):
133
+ """
134
+ Worker that encapsulates Sora 2 async job lifecycle:
135
+ - POST /videos to create job
136
+ - Poll GET /videos/{id} until status=completed or failed
137
+ - GET /videos/{id}/content to download MP4 bytes
138
+ """
139
+
140
+ # Allowed MIME types for input_reference per API
141
+ ALLOWED_MIME = {"image/jpeg", "image/png", "image/webp"}
142
+
143
+ def __init__(self, *args, **kwargs):
144
+ super().__init__()
145
+ self.signals = VideoSignals()
146
+ self.window = None
147
+ self.client: Optional[OpenAI] = None
148
+ self.ctx: Optional[CtxItem] = None
149
+
150
+ # params
151
+ self.mode = Video.MODE_GENERATE
152
+ self.attachments: Dict[str, Any] = {}
153
+ self.model = "sora-2"
154
+ self.model_prompt = None
155
+ self.input_prompt = ""
156
+ self.system_prompt = ""
157
+ self.inline = False
158
+ self.raw = False
159
+ self.num = 1
160
+
161
+ # video generation params (mapped to Sora API)
162
+ self.aspect_ratio = "16:9"
163
+ self.duration_seconds = 8
164
+ self.resolution: str = "720p"
165
+
166
+ # Sora limits
167
+ self.max_per_job = 1
168
+
169
+ @Slot()
170
+ def run(self):
171
+ try:
172
+ # Optional prompt enhancement via app default LLM
173
+ if not self.raw and not self.inline and self.input_prompt:
174
+ try:
175
+ self.signals.status.emit(trans('vid.status.prompt.wait'))
176
+ bridge_context = BridgeContext(
177
+ prompt=self.input_prompt,
178
+ system_prompt=self.system_prompt,
179
+ model=self.model_prompt,
180
+ max_tokens=200,
181
+ temperature=1.0,
182
+ )
183
+ ev = KernelEvent(KernelEvent.CALL, {'context': bridge_context, 'extra': {}})
184
+ self.window.dispatch(ev)
185
+ resp = ev.data.get('response')
186
+ if resp:
187
+ self.input_prompt = resp
188
+ except Exception as e:
189
+ self.signals.error.emit(e)
190
+ self.signals.status.emit(trans('vid.status.prompt.error') + ": " + str(e))
191
+
192
+ # Sora API accepts a single video per create call; honor app's num but cap to 1 per job
193
+ _ = max(1, min(self.num, self.max_per_job))
194
+
195
+ # Build request parameters
196
+ seconds = self._clamp_seconds(self.duration_seconds)
197
+ size = self._resolve_size(self.aspect_ratio, self.resolution, self.model)
198
+
199
+ # Image-to-video: first image attachment as input_reference, must match "size"
200
+ image_path = self._first_image_attachment(self.attachments) if self.mode == Video.MODE_IMAGE_TO_VIDEO else None
201
+
202
+ self.signals.status.emit(trans('vid.status.generating') + f": {self.input_prompt}...")
203
+
204
+ # Create job
205
+ create_kwargs: Dict[str, Any] = {
206
+ "model": self.model or "sora-2",
207
+ "prompt": self.input_prompt or "",
208
+ "seconds": str(seconds),
209
+ "size": size,
210
+ }
211
+
212
+ # Attach image as input_reference; auto-resize to match requested size if needed
213
+ file_handle = None
214
+ if image_path:
215
+ prepared = self._prepare_input_reference(image_path, size)
216
+ if prepared is not None:
217
+ # tuple (filename, bytes, mime) supported by OpenAI Python SDK
218
+ create_kwargs["input_reference"] = prepared
219
+ else:
220
+ # Fallback: use original file (may fail if size/mime mismatch)
221
+ try:
222
+ file_handle = open(image_path, "rb")
223
+ create_kwargs["input_reference"] = file_handle
224
+ except Exception as e:
225
+ self.signals.error.emit(e)
226
+
227
+ job = self.client.videos.create(**create_kwargs)
228
+ video_id = self._safe_get(job, "id")
229
+ if not video_id:
230
+ # include raw payload for debugging
231
+ raise RuntimeError("Video job ID missing in create response.")
232
+
233
+ # Poll until completed (or failed/canceled)
234
+ last_progress = None
235
+ last_status = None
236
+ while True:
237
+ time.sleep(5)
238
+ job = self.client.videos.retrieve(video_id)
239
+ status = self._safe_get(job, "status") or ""
240
+ progress = self._safe_get(job, "progress")
241
+
242
+ if status != last_status or (progress is not None and progress != last_progress):
243
+ try:
244
+ pr_txt = f" [{int(progress)}%]" if isinstance(progress, (int, float)) else ""
245
+ self.signals.status.emit(f"{trans('vid.status.generating')} {status}{pr_txt}")
246
+ except Exception:
247
+ pass
248
+ last_progress = progress
249
+ last_status = status
250
+
251
+ if status in ("completed", "failed", "canceled"):
252
+ break
253
+
254
+ if file_handle is not None:
255
+ try:
256
+ file_handle.close()
257
+ except Exception:
258
+ pass
259
+
260
+ if status != "completed":
261
+ # Extract detailed reason, surface policy hints if present
262
+ reason = self._format_job_error(job)
263
+ if reason:
264
+ self.signals.status.emit(reason)
265
+ raise RuntimeError(f"Video generation did not complete: {status}. {reason or ''}".strip())
266
+
267
+ # Download content
268
+ response = self.client.videos.download_content(video_id=video_id)
269
+ data = response.read() # bytes stream per OpenAI Python SDK
270
+
271
+ # Save file to user video dir
272
+ paths: List[str] = []
273
+ p = self._save(0, data)
274
+ if p:
275
+ paths.append(p)
276
+
277
+ if self.inline:
278
+ self.signals.finished_inline.emit(self.ctx, paths, self.input_prompt)
279
+ else:
280
+ self.signals.finished.emit(self.ctx, paths, self.input_prompt)
281
+
282
+ except Exception as e:
283
+ self.signals.error.emit(e)
284
+ finally:
285
+ self._cleanup()
286
+
287
+ # ---------- helpers ----------
288
+
289
+ @staticmethod
290
+ def _clamp_seconds(requested: int) -> int:
291
+ """
292
+ Clamp duration to Sora allowed values (4, 8, 12).
293
+ """
294
+ allowed = [4, 8, 12]
295
+ try:
296
+ r = int(requested or 8)
297
+ except Exception:
298
+ r = 8
299
+ return min(allowed, key=lambda x: abs(x - r))
300
+
301
+ @staticmethod
302
+ def _parse_size(size: str) -> Tuple[int, int]:
303
+ """
304
+ Parse "WxH" string into integers; returns (W, H). Raises ValueError on invalid format.
305
+ """
306
+ parts = str(size or "").lower().split("x")
307
+ if len(parts) != 2:
308
+ raise ValueError(f"Invalid size format: {size}")
309
+ w = int(parts[0].strip())
310
+ h = int(parts[1].strip())
311
+ if w <= 0 or h <= 0:
312
+ raise ValueError(f"Invalid size values: {size}")
313
+ return w, h
314
+
315
+ @staticmethod
316
+ def _resolve_size(aspect_ratio: str, resolution: str, model_id: str) -> str:
317
+ """
318
+ Map app-level aspect ratio and resolution to Sora size enumerations.
319
+ Supported sizes:
320
+ - sora-2: 1280x720, 720x1280
321
+ - sora-2-pro: 1280x720, 720x1280, 1024x1792, 1792x1024
322
+
323
+ Fallback to 1280x720 or 720x1280 depending on orientation.
324
+ """
325
+ ar = (aspect_ratio or "16:9").strip().lower()
326
+ res = (resolution or "720p").lower().strip()
327
+ model = (model_id or "").lower()
328
+ portrait = ar in ("9:16", "9x16", "portrait")
329
+
330
+ if "sora-2-pro" in model:
331
+ if "1024" in res or "1080" in res or "1792" in res or "hd" in res:
332
+ return "1024x1792" if portrait else "1792x1024"
333
+
334
+ return "720x1280" if portrait else "1280x720"
335
+
336
+ def _first_image_attachment(self, attachments: Dict[str, Any]) -> Optional[str]:
337
+ """Return path of the first image attachment, if any."""
338
+ for _, att in (attachments or {}).items():
339
+ try:
340
+ p = getattr(att, "path", None)
341
+ if p and os.path.exists(p):
342
+ mt, _ = mimetypes.guess_type(p)
343
+ if mt and mt.startswith("image/"):
344
+ return p
345
+ except Exception:
346
+ continue
347
+ return None
348
+
349
+ def _prepare_input_reference(self, image_path: str, size: str) -> Optional[Tuple[str, bytes, str]]:
350
+ """
351
+ Ensure the input image matches required WxH and mime. Returns a (filename, bytes, mime) tuple.
352
+ - Converts on the fly using Pillow if dimensions do not match.
353
+ - Strips EXIF metadata and normalizes orientation.
354
+ - Fit mode configurable via `video.image_to_video.fit`: crop | pad | stretch (default crop).
355
+ """
356
+ try:
357
+ w, h = self._parse_size(size)
358
+ except Exception:
359
+ self.signals.status.emit(f"Invalid target size: {size}, using default 1280x720")
360
+ w, h = 1280, 720
361
+
362
+ # Try Pillow import lazily
363
+ try:
364
+ from PIL import Image, ImageOps
365
+ except Exception:
366
+ # Fallback: pass original file; may still fail if size mismatches.
367
+ try:
368
+ with open(image_path, "rb") as f:
369
+ data = f.read()
370
+ mime = (mimetypes.guess_type(image_path)[0] or "image/jpeg")
371
+ if mime not in self.ALLOWED_MIME:
372
+ mime = "image/jpeg"
373
+ filename = os.path.basename(image_path)
374
+ return filename, data, mime
375
+ except Exception:
376
+ return None
377
+
378
+ try:
379
+ img = Image.open(image_path)
380
+ img = ImageOps.exif_transpose(img) # honor EXIF orientation
381
+
382
+ # If already exact size and mime allowed -> pass-through with re-encode to strip EXIF
383
+ if img.width == w and img.height == h:
384
+ buf = io.BytesIO()
385
+ # choose output mime based on original extension but restrict to allowed set
386
+ target_mime = (mimetypes.guess_type(image_path)[0] or "image/jpeg")
387
+ if target_mime not in self.ALLOWED_MIME:
388
+ target_mime = "image/jpeg"
389
+
390
+ fmt = "WEBP" if target_mime == "image/webp" else "PNG" if target_mime == "image/png" else "JPEG"
391
+ if fmt == "JPEG" and img.mode not in ("RGB", "L"):
392
+ img = img.convert("RGB")
393
+ save_kwargs = {"optimize": True}
394
+ if fmt == "JPEG":
395
+ save_kwargs.update({"quality": 95})
396
+ img.save(buf, format=fmt, **save_kwargs)
397
+ data = buf.getvalue()
398
+ buf.close()
399
+ filename = os.path.basename(image_path) if fmt != "JPEG" else "input.jpg"
400
+ return filename, data, target_mime
401
+
402
+ fit_mode = str(self.window.core.config.get('video.image_to_video.fit') or "crop").lower()
403
+ if fit_mode not in ("crop", "pad", "stretch"):
404
+ fit_mode = "crop"
405
+
406
+ # Convert to RGB for JPEG output by default
407
+ if img.mode not in ("RGB", "L"):
408
+ img = img.convert("RGB")
409
+
410
+ if fit_mode == "stretch":
411
+ resized = img.resize((w, h), Image.Resampling.LANCZOS)
412
+ elif fit_mode == "pad":
413
+ scale = min(w / img.width, h / img.height)
414
+ new_w = max(1, int(img.width * scale))
415
+ new_h = max(1, int(img.height * scale))
416
+ tmp = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
417
+ canvas = Image.new("RGB", (w, h), color=(0, 0, 0))
418
+ left = (w - new_w) // 2
419
+ top = (h - new_h) // 2
420
+ canvas.paste(tmp, (left, top))
421
+ resized = canvas
422
+ else: # crop (fill then center-crop)
423
+ scale = max(w / img.width, h / img.height)
424
+ new_w = max(1, int(img.width * scale))
425
+ new_h = max(1, int(img.height * scale))
426
+ tmp = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
427
+ left = max(0, (new_w - w) // 2)
428
+ top = max(0, (new_h - h) // 2)
429
+ resized = tmp.crop((left, top, left + w, top + h))
430
+
431
+ # Encode as JPEG (safe default per API)
432
+ buf = io.BytesIO()
433
+ resized.save(buf, format="JPEG", quality=95, optimize=True)
434
+ data = buf.getvalue()
435
+ buf.close()
436
+
437
+ self.signals.status.emit(f"Auto-resized input image to {w}x{h} (mode={fit_mode}).")
438
+ return "input.jpg", data, "image/jpeg"
439
+
440
+ except Exception:
441
+ # As a last resort, send original image
442
+ try:
443
+ with open(image_path, "rb") as f:
444
+ data = f.read()
445
+ mime = (mimetypes.guess_type(image_path)[0] or "image/jpeg")
446
+ if mime not in self.ALLOWED_MIME:
447
+ mime = "image/jpeg"
448
+ filename = os.path.basename(image_path)
449
+ self.signals.status.emit("Image preprocessing failed; sending original file (may fail if size/policy mismatch).")
450
+ return filename, data, mime
451
+ except Exception:
452
+ return None
453
+
454
+ def _save(self, idx: int, data: Optional[bytes]) -> Optional[str]:
455
+ """Save video bytes to file and return path."""
456
+ if not data:
457
+ return None
458
+ name = (
459
+ datetime.date.today().strftime("%Y-%m-%d") + "_" +
460
+ datetime.datetime.now().strftime("%H-%M-%S") + "-" +
461
+ self.window.core.video.make_safe_filename(self.input_prompt) + "-" +
462
+ str(idx + 1) + ".mp4"
463
+ )
464
+ path = os.path.join(self.window.core.config.get_user_dir("video"), name)
465
+ self.signals.status.emit(trans('vid.status.downloading') + f" ({idx + 1} / 1) -> {path}")
466
+
467
+ if self.window.core.video.save_video(path, data):
468
+ return str(path)
469
+
470
+ try:
471
+ os.makedirs(os.path.dirname(path), exist_ok=True)
472
+ with open(path, "wb") as f:
473
+ f.write(data)
474
+ return str(path)
475
+ except Exception:
476
+ return None
477
+
478
+ def _cleanup(self):
479
+ """Cleanup resources."""
480
+ sig = self.signals
481
+ self.signals = None
482
+ if sig is not None:
483
+ try:
484
+ sig.deleteLater()
485
+ except RuntimeError:
486
+ pass
487
+
488
+ # ---------- error utilities ----------
489
+
490
+ @staticmethod
491
+ def _safe_get(obj: Any, key: str, default=None):
492
+ """
493
+ Access attribute or dict key safely.
494
+ """
495
+ try:
496
+ if isinstance(obj, dict):
497
+ return obj.get(key, default)
498
+ if hasattr(obj, key):
499
+ return getattr(obj, key)
500
+ except Exception:
501
+ return default
502
+ return default
503
+
504
+ def _format_job_error(self, job: Any) -> str:
505
+ """
506
+ Build a human-readable error message from the video job.
507
+ Includes error.message/type/code and common policy hints.
508
+ """
509
+ # Extract error payload
510
+ err = self._safe_get(job, "error")
511
+ details: Dict[str, Any] = {}
512
+ message = ""
513
+ code = ""
514
+ etype = ""
515
+
516
+ # Normalize to dict
517
+ if err is not None:
518
+ try:
519
+ if isinstance(err, dict):
520
+ details = err
521
+ elif hasattr(err, "model_dump"):
522
+ details = err.model_dump() # pydantic v2
523
+ elif hasattr(err, "to_dict"):
524
+ details = err.to_dict()
525
+ else:
526
+ # last resort string
527
+ message = str(err)
528
+ except Exception:
529
+ message = str(err)
530
+
531
+ message = details.get("message", message) if isinstance(details, dict) else message
532
+ code = details.get("code", code) if isinstance(details, dict) else code
533
+ etype = details.get("type", etype) if isinstance(details, dict) else etype
534
+
535
+ # Policy-related hints
536
+ hint = ""
537
+ lower = (message or "").lower()
538
+ if any(x in lower for x in ("public figure", "likeness", "real person", "people", "face", "biometric")):
539
+ hint = "Policy: input images with real people or public figures are blocked. Use a non-real/persona or generated image."
540
+ elif any(x in lower for x in ("copyright", "trademark", "brand", "ip infringement")):
541
+ hint = "Policy: potential IP restriction detected. Avoid brand logos or copyrighted assets."
542
+ elif "inpaint" in lower and "width and height" in lower:
543
+ hint = "The reference image must exactly match the requested size (size=WxH). The app auto-resizes now; verify your aspect and chosen model allow this size."
544
+
545
+ parts = []
546
+ if message:
547
+ parts.append(f"reason: {message}")
548
+ if etype:
549
+ parts.append(f"type: {etype}")
550
+ if code:
551
+ parts.append(f"code: {code}")
552
+ if hint:
553
+ parts.append(hint)
554
+
555
+ # Include minimal structured fallback if nothing above was present
556
+ if not parts and isinstance(details, dict) and details:
557
+ try:
558
+ parts.append(json.dumps(details, ensure_ascii=False))
559
+ except Exception:
560
+ pass
561
+
562
+ return " ".join(parts).strip()
@@ -176,6 +176,13 @@ class Patch:
176
176
  patch_css('style.dark.css', True)
177
177
  updated = True
178
178
 
179
+ # < 2.6.66
180
+ if old < parse_version("2.6.66"):
181
+ print("Migrating config from < 2.6.66...")
182
+ if "img_mode" not in data:
183
+ data["img_mode"] = "image"
184
+ updated = True
185
+
179
186
  # update file
180
187
  migrated = False
181
188
  if updated:
@@ -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.12 00:00:00 #
9
+ # Updated Date: 2025.12.26 11:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from packaging.version import parse as parse_version, Version
@@ -52,11 +52,62 @@ class Patch:
52
52
  data, updated = patcher.execute(version)
53
53
  # --------------------------------------------
54
54
 
55
- # > 2.6.42 below:
56
- # pass
55
+ # < 2.6.66 <--- add models
56
+ if old < parse_version("2.6.66"):
57
+ print("Migrating models from < 2.6.66...")
58
+ models_to_add = [
59
+ "claude-opus-4-5",
60
+ "claude-sonnet-4-5",
61
+ "gemini-3-flash-preview",
62
+ "gemini-3-pro-image-preview",
63
+ "gemini-3-pro-preview",
64
+ "gpt-5.2-low",
65
+ "gpt-5.2-medium",
66
+ "gpt-5.2-high",
67
+ "gpt-image-1.5",
68
+ "nano-banana-pro-preview",
69
+ "sora-2",
70
+ "veo-3.1-fast-generate-preview",
71
+ "veo-3.1-generate-preview"
72
+ ]
73
+ for model in models_to_add:
74
+ if model not in data:
75
+ base_model = from_base(model)
76
+ if base_model:
77
+ data[model] = base_model
78
+ updated = True
79
+
80
+ # < 2.6.67 <--- add missing image input
81
+ if old < parse_version("2.6.67"):
82
+ print("Migrating models from < 2.6.67...")
83
+ models_to_update = [
84
+ "claude-opus-4-5",
85
+ "claude-sonnet-4-5",
86
+ "gemini-3-flash-preview",
87
+ "gemini-3-pro-image-preview",
88
+ "gemini-3-pro-preview",
89
+ "gpt-5.2-low",
90
+ "gpt-5.2-medium",
91
+ "gpt-5.2-high",
92
+ "gpt-image-1.5",
93
+ "nano-banana-pro-preview",
94
+ "sora-2",
95
+ "veo-3.1-fast-generate-preview",
96
+ "veo-3.1-generate-preview"
97
+ ]
98
+ for model in models_to_update:
99
+ if model in data:
100
+ m = data[model]
101
+ if not m.is_image_input():
102
+ m.input.append("image")
103
+ updated = True
57
104
 
58
105
  # update file
59
106
  if updated:
107
+ # fix empty/broken data
108
+ for key in list(data.keys()):
109
+ if not data[key]:
110
+ del data[key]
60
111
  data = dict(sorted(data.items()))
61
112
  self.window.core.models.items = data
62
113
  self.window.core.models.save()