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.
Files changed (96) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/chat/handler/anthropic_stream.py +164 -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 +570 -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/debug/debug.py +6 -6
  14. pygpt_net/controller/model/editor.py +3 -0
  15. pygpt_net/controller/model/importer.py +9 -2
  16. pygpt_net/controller/plugins/plugins.py +11 -3
  17. pygpt_net/controller/presets/presets.py +2 -2
  18. pygpt_net/core/bridge/context.py +35 -35
  19. pygpt_net/core/bridge/worker.py +40 -16
  20. pygpt_net/core/ctx/bag.py +7 -2
  21. pygpt_net/core/ctx/reply.py +17 -2
  22. pygpt_net/core/db/viewer.py +19 -34
  23. pygpt_net/core/render/plain/pid.py +12 -1
  24. pygpt_net/core/render/web/body.py +30 -39
  25. pygpt_net/core/tabs/tab.py +24 -1
  26. pygpt_net/data/config/config.json +10 -3
  27. pygpt_net/data/config/models.json +3 -3
  28. pygpt_net/data/config/settings.json +105 -0
  29. pygpt_net/data/css/style.dark.css +2 -3
  30. pygpt_net/data/css/style.light.css +2 -3
  31. pygpt_net/data/locale/locale.de.ini +3 -1
  32. pygpt_net/data/locale/locale.en.ini +19 -1
  33. pygpt_net/data/locale/locale.es.ini +3 -1
  34. pygpt_net/data/locale/locale.fr.ini +3 -1
  35. pygpt_net/data/locale/locale.it.ini +3 -1
  36. pygpt_net/data/locale/locale.pl.ini +4 -2
  37. pygpt_net/data/locale/locale.uk.ini +3 -1
  38. pygpt_net/data/locale/locale.zh.ini +3 -1
  39. pygpt_net/item/assistant.py +51 -2
  40. pygpt_net/item/attachment.py +21 -20
  41. pygpt_net/item/calendar_note.py +19 -2
  42. pygpt_net/item/ctx.py +115 -2
  43. pygpt_net/item/index.py +9 -2
  44. pygpt_net/item/mode.py +9 -6
  45. pygpt_net/item/model.py +20 -3
  46. pygpt_net/item/notepad.py +14 -2
  47. pygpt_net/item/preset.py +42 -2
  48. pygpt_net/item/prompt.py +8 -2
  49. pygpt_net/plugin/cmd_files/plugin.py +2 -2
  50. pygpt_net/provider/api/__init__.py +5 -3
  51. pygpt_net/provider/api/anthropic/__init__.py +190 -29
  52. pygpt_net/provider/api/anthropic/audio.py +30 -0
  53. pygpt_net/provider/api/anthropic/chat.py +341 -0
  54. pygpt_net/provider/api/anthropic/image.py +25 -0
  55. pygpt_net/provider/api/anthropic/tools.py +266 -0
  56. pygpt_net/provider/api/anthropic/vision.py +142 -0
  57. pygpt_net/provider/api/google/chat.py +2 -2
  58. pygpt_net/provider/api/google/realtime/client.py +2 -2
  59. pygpt_net/provider/api/google/tools.py +58 -48
  60. pygpt_net/provider/api/google/vision.py +7 -1
  61. pygpt_net/provider/api/openai/chat.py +1 -0
  62. pygpt_net/provider/api/openai/vision.py +6 -0
  63. pygpt_net/provider/api/x_ai/__init__.py +247 -0
  64. pygpt_net/provider/api/x_ai/audio.py +32 -0
  65. pygpt_net/provider/api/x_ai/chat.py +968 -0
  66. pygpt_net/provider/api/x_ai/image.py +208 -0
  67. pygpt_net/provider/api/x_ai/remote.py +262 -0
  68. pygpt_net/provider/api/x_ai/tools.py +120 -0
  69. pygpt_net/provider/api/x_ai/vision.py +119 -0
  70. pygpt_net/provider/core/attachment/json_file.py +2 -2
  71. pygpt_net/provider/core/config/patch.py +28 -0
  72. pygpt_net/provider/llms/anthropic.py +4 -2
  73. pygpt_net/tools/text_editor/tool.py +4 -1
  74. pygpt_net/tools/text_editor/ui/dialogs.py +1 -1
  75. pygpt_net/ui/base/config_dialog.py +5 -11
  76. pygpt_net/ui/dialog/db.py +177 -59
  77. pygpt_net/ui/dialog/dictionary.py +57 -59
  78. pygpt_net/ui/dialog/editor.py +3 -2
  79. pygpt_net/ui/dialog/image.py +1 -1
  80. pygpt_net/ui/dialog/logger.py +3 -2
  81. pygpt_net/ui/dialog/models.py +16 -16
  82. pygpt_net/ui/dialog/plugins.py +63 -60
  83. pygpt_net/ui/layout/ctx/ctx_list.py +3 -4
  84. pygpt_net/ui/layout/toolbox/__init__.py +2 -2
  85. pygpt_net/ui/layout/toolbox/assistants.py +8 -9
  86. pygpt_net/ui/layout/toolbox/presets.py +2 -2
  87. pygpt_net/ui/main.py +9 -4
  88. pygpt_net/ui/widget/element/labels.py +20 -4
  89. pygpt_net/ui/widget/textarea/editor.py +0 -4
  90. pygpt_net/ui/widget/textarea/web.py +1 -1
  91. {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.38.dist-info}/METADATA +18 -6
  92. {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.38.dist-info}/RECORD +95 -76
  93. pygpt_net/controller/chat/handler/stream_worker.py +0 -1136
  94. {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.38.dist-info}/LICENSE +0 -0
  95. {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.38.dist-info}/WHEEL +0 -0
  96. {pygpt_net-2.6.36.dist-info → pygpt_net-2.6.38.dist-info}/entry_points.txt +0 -0
@@ -1,1136 +0,0 @@
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.04 00:00:00 #
10
- # ================================================== #
11
-
12
- import base64
13
- import io
14
- import json
15
- from dataclasses import dataclass, field
16
- from typing import Optional, Literal, Any
17
- from enum import Enum
18
-
19
- from PySide6.QtCore import QObject, Signal, Slot, QRunnable
20
-
21
- from pygpt_net.core.events import RenderEvent
22
- from pygpt_net.core.text.utils import has_unclosed_code_tag
23
- from pygpt_net.item.ctx import CtxItem
24
-
25
- # OpenAI Responses Events
26
- EventType = Literal[
27
- "response.completed",
28
- "response.output_text.delta",
29
- "response.output_item.added",
30
- "response.function_call_arguments.delta",
31
- "response.function_call_arguments.done",
32
- "response.output_text.annotation.added",
33
- "response.reasoning_summary_text.delta",
34
- "response.output_item.done",
35
- "response.code_interpreter_call_code.delta",
36
- "response.code_interpreter_call_code.done",
37
- "response.image_generation_call.partial_image",
38
- "response.created",
39
- "response.done",
40
- "response.failed",
41
- "error",
42
- ]
43
-
44
-
45
- class ChunkType(str, Enum):
46
- """
47
- Enum for chunk type classification.
48
- """
49
- API_CHAT = "api_chat"
50
- API_CHAT_RESPONSES = "api_chat_responses"
51
- API_COMPLETION = "api_completion"
52
- LANGCHAIN_CHAT = "langchain_chat"
53
- LLAMA_CHAT = "llama_chat"
54
- GOOGLE = "google"
55
- RAW = "raw"
56
-
57
-
58
- class WorkerSignals(QObject):
59
- """
60
- Defines the signals available from a running worker thread.
61
- - `finished`: No data
62
- - `errorOccurred`: Exception
63
- - `eventReady`: RenderEvent
64
- """
65
- end = Signal(object)
66
- errorOccurred = Signal(Exception)
67
- eventReady = Signal(object)
68
-
69
-
70
- @dataclass(slots=True)
71
- class WorkerState:
72
- """Holds mutable state for the streaming loop."""
73
- out: Optional[io.StringIO] = None
74
- output_tokens: int = 0
75
- begin: bool = True
76
- error: Optional[Exception] = None
77
- fn_args_buffers: dict[str, io.StringIO] = field(default_factory=dict)
78
- citations: Optional[list] = field(default_factory=list)
79
- image_paths: list[str] = field(default_factory=list)
80
- files: list[dict] = field(default_factory=list)
81
- img_path: Optional[str] = None
82
- is_image: bool = False
83
- has_google_inline_image: bool = False
84
- is_code: bool = False
85
- force_func_call: bool = False
86
- stopped: bool = False
87
- chunk_type: ChunkType = ChunkType.RAW
88
- generator: Any = None
89
- usage_vendor: Optional[str] = None
90
- usage_payload: dict = field(default_factory=dict)
91
- google_stream_ref: Any = None
92
- tool_calls: list[dict] = field(default_factory=list)
93
-
94
-
95
- class StreamWorker(QRunnable):
96
- __slots__ = ("signals", "ctx", "window", "stream")
97
-
98
- def __init__(self, ctx: CtxItem, window, parent=None):
99
- super().__init__()
100
- self.signals = WorkerSignals()
101
- self.ctx = ctx
102
- self.window = window
103
- self.stream = None
104
-
105
- @Slot()
106
- def run(self):
107
- ctx = self.ctx
108
- win = self.window
109
- core = win.core
110
- ctrl = win.controller
111
-
112
- emit_event = self.signals.eventReady.emit
113
- emit_error = self.signals.errorOccurred.emit
114
- emit_end = self.signals.end.emit
115
-
116
- state = WorkerState()
117
- state.generator = self.stream
118
- state.img_path = core.image.gen_unique_path(ctx)
119
-
120
- base_data = {"meta": ctx.meta, "ctx": ctx}
121
- emit_event(RenderEvent(RenderEvent.STREAM_BEGIN, base_data))
122
-
123
- try:
124
- if state.generator is not None:
125
- for chunk in state.generator:
126
- # cooperative stop
127
- if self._should_stop(ctrl, state, ctx):
128
- break
129
-
130
- # if error flagged, stop early
131
- if state.error is not None:
132
- ctx.msg_id = None
133
- state.stopped = True
134
- break
135
-
136
- etype: Optional[EventType] = None
137
-
138
- # detect chunk type
139
- if ctx.use_responses_api:
140
- if hasattr(chunk, 'type'):
141
- etype = chunk.type # type: ignore[assignment]
142
- state.chunk_type = ChunkType.API_CHAT_RESPONSES
143
- else:
144
- continue
145
- else:
146
- state.chunk_type = self._detect_chunk_type(chunk)
147
-
148
- # process chunk according to type
149
- response = self._process_chunk(ctx, core, state, chunk, etype)
150
-
151
- # emit response delta if present
152
- if response is not None and response != "" and not state.stopped:
153
- self._append_response(ctx, state, response, emit_event)
154
-
155
- # free per-iteration ref
156
- chunk = None
157
-
158
- # after loop: handle tool-calls and images assembly
159
- self._handle_after_loop(ctx, core, state)
160
-
161
- except Exception as e:
162
- state.error = e
163
-
164
- finally:
165
- self._finalize(ctx, core, state, emit_end, emit_error)
166
-
167
- # ------------ Orchestration helpers ------------
168
-
169
- def _should_stop(
170
- self,
171
- ctrl,
172
- state: WorkerState,
173
- ctx: CtxItem
174
- ) -> bool:
175
- """
176
- Checks external stop signal and attempts to stop the generator gracefully.
177
-
178
- :param ctrl: Controller with stop signal
179
- :param state: WorkerState
180
- :param ctx: CtxItem
181
- :return: True if stopped, False otherwise
182
- """
183
- if not ctrl.kernel.stopped():
184
- return False
185
-
186
- gen = state.generator
187
- if gen is not None:
188
- # Try common stop methods without raising
189
- for meth in ("close", "cancel", "stop"):
190
- if hasattr(gen, meth):
191
- try:
192
- getattr(gen, meth)()
193
- except Exception:
194
- pass
195
-
196
- ctx.msg_id = None
197
- state.stopped = True
198
- return True
199
-
200
- def _detect_chunk_type(self, chunk) -> ChunkType:
201
- """
202
- Detects chunk type for various providers/SDKs.
203
-
204
- :param chunk: The chunk object from the stream
205
- :return: Detected ChunkType
206
- """
207
- choices = getattr(chunk, 'choices', None)
208
- if choices:
209
- choice0 = choices[0] if len(choices) > 0 else None
210
- if choice0 is not None and hasattr(choice0, 'delta') and choice0.delta is not None:
211
- return ChunkType.API_CHAT
212
- if choice0 is not None and hasattr(choice0, 'text') and choice0.text is not None:
213
- return ChunkType.API_COMPLETION
214
-
215
- if hasattr(chunk, 'content') and getattr(chunk, 'content') is not None:
216
- return ChunkType.LANGCHAIN_CHAT
217
- if hasattr(chunk, 'delta') and getattr(chunk, 'delta') is not None:
218
- return ChunkType.LLAMA_CHAT
219
- if hasattr(chunk, "candidates"):
220
- return ChunkType.GOOGLE
221
- return ChunkType.RAW
222
-
223
- def _append_response(
224
- self,
225
- ctx: CtxItem,
226
- state: WorkerState,
227
- response: str,
228
- emit_event
229
- ):
230
- """
231
- Appends response delta and emits STREAM_APPEND event.
232
-
233
- Skips empty initial chunks if state.begin is True.
234
-
235
- :param ctx: CtxItem
236
- :param state: WorkerState
237
- :param response: Response delta string
238
- :param emit_event: Function to emit RenderEvent
239
- """
240
- if state.begin and response == "":
241
- return
242
- # Use a single expandable buffer to avoid per-chunk list allocations
243
- if state.out is None:
244
- state.out = io.StringIO()
245
- state.out.write(response)
246
- state.output_tokens += 1
247
- emit_event(
248
- RenderEvent(
249
- RenderEvent.STREAM_APPEND,
250
- {
251
- "meta": ctx.meta,
252
- "ctx": ctx,
253
- "chunk": response,
254
- "begin": state.begin,
255
- },
256
- )
257
- )
258
- state.begin = False
259
-
260
- def _handle_after_loop(
261
- self,
262
- ctx: CtxItem,
263
- core,
264
- state: WorkerState
265
- ):
266
- """
267
- Post-loop handling for tool calls and images assembly.
268
-
269
- :param ctx: CtxItem
270
- :param core: Core instance
271
- :param state: WorkerState
272
- """
273
- if state.tool_calls:
274
- ctx.force_call = state.force_func_call
275
- core.debug.info("[chat] Tool calls found, unpacking...")
276
- # Ensure function.arguments is JSON string
277
- for tc in state.tool_calls:
278
- fn = tc.get("function") or {}
279
- if isinstance(fn.get("arguments"), dict):
280
- fn["arguments"] = json.dumps(fn["arguments"], ensure_ascii=False)
281
- core.command.unpack_tool_calls_chunks(ctx, state.tool_calls)
282
-
283
- # OpenAI partial image assembly
284
- if state.is_image and state.img_path:
285
- core.debug.info("[chat] OpenAI partial image assembled")
286
- ctx.images = [state.img_path]
287
-
288
- # Google inline images
289
- if state.image_paths:
290
- core.debug.info("[chat] Google inline images found")
291
- if not isinstance(ctx.images, list) or not ctx.images:
292
- ctx.images = list(state.image_paths)
293
- else:
294
- seen = set(ctx.images)
295
- for p in state.image_paths:
296
- if p not in seen:
297
- ctx.images.append(p)
298
- seen.add(p)
299
-
300
- def _finalize(
301
- self,
302
- ctx: CtxItem,
303
- core,
304
- state: WorkerState,
305
- emit_end,
306
- emit_error
307
- ):
308
- """
309
- Finalize stream: build output, usage, tokens, files, errors, cleanup.
310
-
311
- :param ctx: CtxItem
312
- :param core: Core instance
313
- :param state: WorkerState
314
- :param emit_end: Function to emit end signal
315
- """
316
- # Build final output from the incremental buffer
317
- output = state.out.getvalue() if state.out is not None else ""
318
- if state.out is not None:
319
- try:
320
- state.out.close()
321
- except Exception:
322
- pass
323
- state.out = None
324
-
325
- if has_unclosed_code_tag(output):
326
- output += "\n```"
327
-
328
- # Attempt to resolve Google usage from the stream object if missing
329
- if ((state.usage_vendor is None or state.usage_vendor == "google")
330
- and not state.usage_payload and state.generator is not None):
331
- try:
332
- if hasattr(state.generator, "resolve"):
333
- state.generator.resolve()
334
- um = getattr(state.generator, "usage_metadata", None)
335
- if um:
336
- self._capture_google_usage(state, um)
337
- except Exception:
338
- pass
339
-
340
- # Close generator if possible
341
- gen = state.generator
342
- if gen and hasattr(gen, 'close'):
343
- try:
344
- gen.close()
345
- except Exception:
346
- pass
347
-
348
- self.stream = None
349
- ctx.output = output
350
- output = None # free ref
351
-
352
- # Tokens usage
353
- if state.usage_payload:
354
- in_tok_final = state.usage_payload.get("in")
355
- out_tok_final = state.usage_payload.get("out")
356
-
357
- if in_tok_final is None:
358
- in_tok_final = ctx.input_tokens if ctx.input_tokens is not None else 0
359
- if out_tok_final is None:
360
- out_tok_final = state.output_tokens
361
-
362
- ctx.set_tokens(in_tok_final, out_tok_final)
363
-
364
- # Attach usage details in ctx.extra for debugging
365
- try:
366
- if not isinstance(ctx.extra, dict):
367
- ctx.extra = {}
368
- ctx.extra["usage"] = {
369
- "vendor": state.usage_vendor,
370
- "input_tokens": in_tok_final,
371
- "output_tokens": out_tok_final,
372
- "reasoning_tokens": state.usage_payload.get("reasoning", 0),
373
- "total_reported": state.usage_payload.get("total"),
374
- }
375
- except Exception:
376
- pass
377
- else:
378
- # Fallback when usage is not available
379
- ctx.set_tokens(ctx.input_tokens if ctx.input_tokens is not None else 0, state.output_tokens)
380
-
381
- core.ctx.update_item(ctx)
382
-
383
- # OpenAI only: download container files if present
384
- if state.files and not state.stopped:
385
- core.debug.info("[chat] Container files found, downloading...")
386
- try:
387
- core.api.openai.container.download_files(ctx, state.files)
388
- except Exception as e:
389
- core.debug.error(f"[chat] Error downloading container files: {e}")
390
-
391
- # Emit error and end
392
- if state.error:
393
- emit_error(state.error)
394
- emit_end(ctx)
395
-
396
- # Cleanup local buffers
397
- for _buf in state.fn_args_buffers.values():
398
- try:
399
- _buf.close()
400
- except Exception:
401
- pass
402
- state.fn_args_buffers.clear()
403
- state.files.clear()
404
- state.tool_calls.clear()
405
- if state.citations is not None and state.citations is not ctx.urls:
406
- state.citations.clear()
407
- state.citations = None
408
-
409
- # Worker cleanup (signals etc.)
410
- self.cleanup()
411
-
412
- # ------------ Chunk processors ------------
413
-
414
- def _process_chunk(
415
- self,
416
- ctx: CtxItem,
417
- core,
418
- state: WorkerState,
419
- chunk,
420
- etype: Optional[EventType]
421
- ) -> Optional[str]:
422
- """
423
- Dispatches processing to concrete provider-specific processing.
424
-
425
- :param ctx: CtxItem
426
- :param core: Core instance
427
- :param state: WorkerState
428
- :param chunk: The chunk object from the stream
429
- :param etype: Optional event type for Responses API
430
- :return: Response delta string or None
431
- """
432
- t = state.chunk_type
433
- if t == ChunkType.API_CHAT:
434
- return self._process_api_chat(ctx, state, chunk)
435
- if t == ChunkType.API_CHAT_RESPONSES:
436
- return self._process_api_chat_responses(ctx, core, state, chunk, etype)
437
- if t == ChunkType.API_COMPLETION:
438
- return self._process_api_completion(chunk)
439
- if t == ChunkType.LANGCHAIN_CHAT:
440
- return self._process_langchain_chat(chunk)
441
- if t == ChunkType.LLAMA_CHAT:
442
- return self._process_llama_chat(state, chunk)
443
- if t == ChunkType.GOOGLE:
444
- return self._process_google_chunk(ctx, core, state, chunk)
445
- # raw fallback
446
- return self._process_raw(chunk)
447
-
448
- def _process_api_chat(
449
- self,
450
- ctx: CtxItem,
451
- state: WorkerState,
452
- chunk
453
- ) -> Optional[str]:
454
- """
455
- OpenAI Chat Completions stream delta.
456
-
457
- Handles text deltas, citations, and streamed tool_calls.
458
-
459
- :param ctx: CtxItem
460
- :param state: WorkerState
461
- :param chunk: The chunk object from the stream
462
- :return: Response delta string or None
463
- """
464
- response = None
465
- state.citations = None # as in original, reset to None for this type
466
-
467
- delta = chunk.choices[0].delta if getattr(chunk, "choices", None) else None
468
- if delta and getattr(delta, "content", None) is not None:
469
- if state.citations is None and hasattr(chunk, 'citations') and chunk.citations is not None:
470
- state.citations = chunk.citations
471
- ctx.urls = state.citations
472
- response = delta.content
473
-
474
- # Accumulate streamed tool_calls
475
- if delta and getattr(delta, "tool_calls", None):
476
- for tool_chunk in delta.tool_calls:
477
- if tool_chunk.index is None:
478
- tool_chunk.index = 0
479
- if len(state.tool_calls) <= tool_chunk.index:
480
- state.tool_calls.append(
481
- {
482
- "id": "",
483
- "type": "function",
484
- "function": {"name": "", "arguments": ""}
485
- }
486
- )
487
- tool_call = state.tool_calls[tool_chunk.index]
488
- if getattr(tool_chunk, "id", None):
489
- tool_call["id"] += tool_chunk.id
490
- if getattr(getattr(tool_chunk, "function", None), "name", None):
491
- tool_call["function"]["name"] += tool_chunk.function.name
492
- if getattr(getattr(tool_chunk, "function", None), "arguments", None):
493
- tool_call["function"]["arguments"] += tool_chunk.function.arguments
494
-
495
- # Capture usage (if available on final chunk with include_usage=True)
496
- try:
497
- u = getattr(chunk, "usage", None)
498
- if u:
499
- self._capture_openai_usage(state, u)
500
- except Exception:
501
- pass
502
-
503
- return response
504
-
505
- def _process_api_chat_responses(
506
- self,
507
- ctx: CtxItem,
508
- core,
509
- state: WorkerState,
510
- chunk,
511
- etype: Optional[EventType]
512
- ) -> Optional[str]:
513
- """
514
- OpenAI Responses API stream events
515
-
516
- Handles various event types including text deltas, tool calls, citations, images, and usage.
517
-
518
- :param ctx: CtxItem
519
- :param core: Core instance
520
- :param state: WorkerState
521
- :param chunk: The chunk object from the stream
522
- :param etype: EventType string
523
- :return: Response delta string or None
524
- """
525
- response = None
526
-
527
- if etype == "response.completed":
528
- # usage on final response
529
- try:
530
- u = getattr(chunk.response, "usage", None)
531
- if u:
532
- self._capture_openai_usage(state, u)
533
- except Exception:
534
- pass
535
-
536
- for item in chunk.response.output:
537
- if item.type == "mcp_list_tools":
538
- core.api.openai.responses.mcp_tools = item.tools
539
- elif item.type == "mcp_call":
540
- call = {
541
- "id": item.id,
542
- "type": "mcp_call",
543
- "approval_request_id": item.approval_request_id,
544
- "arguments": item.arguments,
545
- "error": item.error,
546
- "name": item.name,
547
- "output": item.output,
548
- "server_label": item.server_label,
549
- }
550
- state.tool_calls.append({
551
- "id": item.id,
552
- "call_id": "",
553
- "type": "function",
554
- "function": {"name": item.name, "arguments": item.arguments}
555
- })
556
- ctx.extra["mcp_call"] = call
557
- core.ctx.update_item(ctx)
558
- elif item.type == "mcp_approval_request":
559
- call = {
560
- "id": item.id,
561
- "type": "mcp_call",
562
- "arguments": item.arguments,
563
- "name": item.name,
564
- "server_label": item.server_label,
565
- }
566
- ctx.extra["mcp_approval_request"] = call
567
- core.ctx.update_item(ctx)
568
-
569
- elif etype == "response.output_text.delta":
570
- response = chunk.delta
571
-
572
- elif etype == "response.output_item.added" and chunk.item.type == "function_call":
573
- state.tool_calls.append({
574
- "id": chunk.item.id,
575
- "call_id": chunk.item.call_id,
576
- "type": "function",
577
- "function": {"name": chunk.item.name, "arguments": ""}
578
- })
579
- state.fn_args_buffers[chunk.item.id] = io.StringIO()
580
-
581
- elif etype == "response.function_call_arguments.delta":
582
- buf = state.fn_args_buffers.get(chunk.item_id)
583
- if buf is not None:
584
- buf.write(chunk.delta)
585
-
586
- elif etype == "response.function_call_arguments.done":
587
- buf = state.fn_args_buffers.pop(chunk.item_id, None)
588
- if buf is not None:
589
- try:
590
- args_val = buf.getvalue()
591
- finally:
592
- buf.close()
593
- for tc in state.tool_calls:
594
- if tc["id"] == chunk.item_id:
595
- tc["function"]["arguments"] = args_val
596
- break
597
-
598
- elif etype == "response.output_text.annotation.added":
599
- ann = chunk.annotation
600
- if ann['type'] == "url_citation":
601
- if state.citations is None:
602
- state.citations = []
603
- url_citation = ann['url']
604
- state.citations.append(url_citation)
605
- ctx.urls = state.citations
606
- elif ann['type'] == "container_file_citation":
607
- state.files.append({
608
- "container_id": ann['container_id'],
609
- "file_id": ann['file_id'],
610
- })
611
-
612
- elif etype == "response.reasoning_summary_text.delta":
613
- response = chunk.delta
614
-
615
- elif etype == "response.output_item.done":
616
- # Delegate to computer handler which may add tool calls
617
- tool_calls, has_calls = core.api.openai.computer.handle_stream_chunk(ctx, chunk, state.tool_calls)
618
- state.tool_calls = tool_calls
619
- if has_calls:
620
- state.force_func_call = True
621
-
622
- elif etype == "response.code_interpreter_call_code.delta":
623
- if not state.is_code:
624
- response = "\n\n**Code interpreter**\n```python\n" + chunk.delta
625
- state.is_code = True
626
- else:
627
- response = chunk.delta
628
-
629
- elif etype == "response.code_interpreter_call_code.done":
630
- response = "\n\n```\n-----------\n"
631
-
632
- elif etype == "response.image_generation_call.partial_image":
633
- image_base64 = chunk.partial_image_b64
634
- image_bytes = base64.b64decode(image_base64)
635
- if state.img_path:
636
- with open(state.img_path, "wb") as f:
637
- f.write(image_bytes)
638
- del image_bytes
639
- state.is_image = True
640
-
641
- elif etype == "response.created":
642
- ctx.msg_id = str(chunk.response.id)
643
- core.ctx.update_item(ctx)
644
-
645
- elif etype in {"response.done", "response.failed", "error"}:
646
- pass
647
-
648
- return response
649
-
650
- def _process_api_completion(self, chunk) -> Optional[str]:
651
- """
652
- OpenAI Completions stream text delta.
653
-
654
- :param chunk: The chunk object from the stream
655
- :return: Response delta string or None
656
- """
657
- if getattr(chunk, "choices", None):
658
- choice0 = chunk.choices[0]
659
- if getattr(choice0, "text", None) is not None:
660
- return choice0.text
661
- return None
662
-
663
- def _process_langchain_chat(self, chunk) -> Optional[str]:
664
- """
665
- LangChain chat streaming delta.
666
-
667
- :param chunk: The chunk object from the stream
668
- :return: Response delta string or None
669
- """
670
- if getattr(chunk, "content", None) is not None:
671
- return str(chunk.content)
672
- return None
673
-
674
- def _process_llama_chat(
675
- self,
676
- state: WorkerState,
677
- chunk
678
- ) -> Optional[str]:
679
- """
680
- Llama chat streaming delta with optional tool call extraction.
681
-
682
- :param state: WorkerState
683
- :param chunk: The chunk object from the stream
684
- :return: Response delta string or None
685
- """
686
- response = None
687
- if getattr(chunk, "delta", None) is not None:
688
- response = str(chunk.delta)
689
-
690
- tool_chunks = getattr(getattr(chunk, "message", None), "additional_kwargs", {}).get("tool_calls", [])
691
- if tool_chunks:
692
- for tool_chunk in tool_chunks:
693
- id_val = getattr(tool_chunk, "call_id", None) or getattr(tool_chunk, "id", None)
694
- name = getattr(tool_chunk, "name", None) or getattr(getattr(tool_chunk, "function", None), "name", None)
695
- args = getattr(tool_chunk, "arguments", None)
696
- if args is None:
697
- f = getattr(tool_chunk, "function", None)
698
- args = getattr(f, "arguments", None) if f else None
699
- if id_val:
700
- if not args:
701
- args = "{}"
702
- tool_call = {
703
- "id": id_val,
704
- "type": "function",
705
- "function": {"name": name, "arguments": args}
706
- }
707
- state.tool_calls.clear()
708
- state.tool_calls.append(tool_call)
709
-
710
- return response
711
-
712
- def _process_google_chunk(
713
- self,
714
- ctx: CtxItem,
715
- core,
716
- state: WorkerState,
717
- chunk
718
- ) -> Optional[str]:
719
- """
720
- Google python-genai streaming chunk.
721
-
722
- Handles text, tool calls, inline images, code execution parts, citations, and usage.
723
-
724
- :param ctx: CtxItem
725
- :param core: Core instance
726
- :param state: WorkerState
727
- :param chunk: The chunk object from the stream
728
- :return: Response delta string or None
729
- """
730
- response_parts: list[str] = []
731
-
732
- # Keep a reference to stream object for resolve() later if needed
733
- if state.google_stream_ref is None:
734
- state.google_stream_ref = state.generator
735
-
736
- # Try to capture usage from this chunk (usage_metadata)
737
- try:
738
- um = getattr(chunk, "usage_metadata", None)
739
- if um:
740
- self._capture_google_usage(state, um)
741
- except Exception:
742
- pass
743
-
744
- # 1) Plain text delta (if present)
745
- t = None
746
- try:
747
- t = getattr(chunk, "text", None)
748
- if t:
749
- response_parts.append(t)
750
- except Exception:
751
- pass
752
-
753
- # 2) Tool calls (function_calls property preferred)
754
- fc_list = []
755
- try:
756
- fc_list = getattr(chunk, "function_calls", None) or []
757
- except Exception:
758
- fc_list = []
759
-
760
- new_calls = []
761
-
762
- def _to_plain_dict(obj):
763
- """
764
- Best-effort conversion of SDK objects to plain dict/list.
765
- """
766
- try:
767
- if hasattr(obj, "to_json_dict"):
768
- return obj.to_json_dict()
769
- if hasattr(obj, "model_dump"):
770
- return obj.model_dump()
771
- if hasattr(obj, "to_dict"):
772
- return obj.to_dict()
773
- except Exception:
774
- pass
775
- if isinstance(obj, dict):
776
- return {k: _to_plain_dict(v) for k, v in obj.items()}
777
- if isinstance(obj, (list, tuple)):
778
- return [_to_plain_dict(x) for x in obj]
779
- return obj
780
-
781
- if fc_list:
782
- for fc in fc_list:
783
- name = getattr(fc, "name", "") or ""
784
- args_obj = getattr(fc, "args", {}) or {}
785
- args_dict = _to_plain_dict(args_obj) or {}
786
- new_calls.append({
787
- "id": getattr(fc, "id", "") or "",
788
- "type": "function",
789
- "function": {
790
- "name": name,
791
- "arguments": json.dumps(args_dict, ensure_ascii=False),
792
- }
793
- })
794
- else:
795
- # Fallback: read from candidates -> parts[].function_call
796
- try:
797
- cands = getattr(chunk, "candidates", None) or []
798
- for cand in cands:
799
- content = getattr(cand, "content", None)
800
- parts = getattr(content, "parts", None) or []
801
- for p in parts:
802
- fn = getattr(p, "function_call", None)
803
- if not fn:
804
- continue
805
- name = getattr(fn, "name", "") or ""
806
- args_obj = getattr(fn, "args", {}) or {}
807
- args_dict = _to_plain_dict(args_obj) or {}
808
- new_calls.append({
809
- "id": getattr(fn, "id", "") or "",
810
- "type": "function",
811
- "function": {
812
- "name": name,
813
- "arguments": json.dumps(args_dict, ensure_ascii=False),
814
- }
815
- })
816
- except Exception:
817
- pass
818
-
819
- # De-duplicate tool calls and mark force flag if any found
820
- if new_calls:
821
- seen = {(tc["function"]["name"], tc["function"]["arguments"]) for tc in state.tool_calls}
822
- for tc in new_calls:
823
- key = (tc["function"]["name"], tc["function"]["arguments"])
824
- if key not in seen:
825
- state.tool_calls.append(tc)
826
- seen.add(key)
827
-
828
- # 3) Inspect candidates for code execution parts, inline images, and citations
829
- try:
830
- cands = getattr(chunk, "candidates", None) or []
831
- for cand in cands:
832
- content = getattr(cand, "content", None)
833
- parts = getattr(content, "parts", None) or []
834
-
835
- for p in parts:
836
- # Code execution: executable code part -> open or append within fenced block
837
- ex = getattr(p, "executable_code", None)
838
- if ex:
839
- lang = (getattr(ex, "language", None) or "python").strip() or "python"
840
- code_txt = (
841
- getattr(ex, "code", None) or
842
- getattr(ex, "program", None) or
843
- getattr(ex, "source", None) or
844
- ""
845
- )
846
- if code_txt is None:
847
- code_txt = ""
848
- if not state.is_code:
849
- response_parts.append(f"\n\n**Code interpreter**\n```{lang.lower()}\n{code_txt}")
850
- state.is_code = True
851
- else:
852
- response_parts.append(str(code_txt))
853
-
854
- # Code execution result -> close fenced block (output will be streamed as normal text if provided)
855
- cer = getattr(p, "code_execution_result", None)
856
- if cer:
857
- if state.is_code:
858
- response_parts.append("\n\n```\n-----------\n")
859
- state.is_code = False
860
- # Note: We do not append execution outputs here to avoid duplicating chunk.text.
861
-
862
- # Inline image blobs
863
- blob = getattr(p, "inline_data", None)
864
- if blob:
865
- mime = (getattr(blob, "mime_type", "") or "").lower()
866
- if mime.startswith("image/"):
867
- data = getattr(blob, "data", None)
868
- if data:
869
- # inline_data.data may be bytes or base64-encoded string
870
- if isinstance(data, (bytes, bytearray)):
871
- img_bytes = bytes(data)
872
- else:
873
- img_bytes = base64.b64decode(data)
874
- save_path = core.image.gen_unique_path(ctx)
875
- with open(save_path, "wb") as f:
876
- f.write(img_bytes)
877
- if not isinstance(ctx.images, list):
878
- ctx.images = []
879
- ctx.images.append(save_path)
880
- state.image_paths.append(save_path)
881
- state.has_google_inline_image = True
882
-
883
- # File data that points to externally hosted image (http/https)
884
- fdata = getattr(p, "file_data", None)
885
- if fdata:
886
- uri = getattr(fdata, "file_uri", None) or getattr(fdata, "uri", None)
887
- mime = (getattr(fdata, "mime_type", "") or "").lower()
888
- if uri and mime.startswith("image/") and (uri.startswith("http://") or uri.startswith("https://")):
889
- if ctx.urls is None:
890
- ctx.urls = []
891
- ctx.urls.append(uri)
892
-
893
- # Collect citations (web search URLs) if present in candidates metadata
894
- self._collect_google_citations(ctx, state, chunk)
895
-
896
- except Exception:
897
- # Never break stream on extraction failures
898
- pass
899
-
900
- # Combine all response parts
901
- return "".join(response_parts) if response_parts else None
902
-
903
- def _process_raw(self, chunk) -> Optional[str]:
904
- """
905
- Raw chunk fallback.
906
-
907
- :param chunk: The chunk object from the stream
908
- :return: String representation of chunk or None
909
- """
910
- if chunk is not None:
911
- return chunk if isinstance(chunk, str) else str(chunk)
912
- return None
913
-
914
- # ------------ Usage helpers ------------
915
-
916
- def _safe_get(self, obj, path: str) -> Any:
917
- """
918
- Dot-path getter for dicts and objects.
919
-
920
- :param obj: dict or object
921
- :param path: Dot-separated path string
922
- :return: Value or None
923
- """
924
- cur = obj
925
- for seg in path.split("."):
926
- if cur is None:
927
- return None
928
- if isinstance(cur, dict):
929
- cur = cur.get(seg)
930
- else:
931
- # Support numeric indices for lists like candidates.0...
932
- if seg.isdigit() and isinstance(cur, (list, tuple)):
933
- idx = int(seg)
934
- if 0 <= idx < len(cur):
935
- cur = cur[idx]
936
- else:
937
- return None
938
- else:
939
- cur = getattr(cur, seg, None)
940
- return cur
941
-
942
- def _as_int(self, val) -> Optional[int]:
943
- """
944
- Coerce to int if possible, else None.
945
-
946
- :param val: Any value
947
- :return: int or None
948
- """
949
- if val is None:
950
- return None
951
- try:
952
- return int(val)
953
- except Exception:
954
- try:
955
- return int(float(val))
956
- except Exception:
957
- return None
958
-
959
- def _capture_openai_usage(self, state: WorkerState, u_obj):
960
- """
961
- Extract usage for OpenAI; include reasoning tokens in output if available.
962
-
963
- :param state: WorkerState
964
- :param u_obj: Usage object from OpenAI response
965
- """
966
- if not u_obj:
967
- return
968
- state.usage_vendor = "openai"
969
- in_tok = self._as_int(self._safe_get(u_obj, "input_tokens")) or self._as_int(self._safe_get(u_obj, "prompt_tokens"))
970
- out_tok = self._as_int(self._safe_get(u_obj, "output_tokens")) or self._as_int(self._safe_get(u_obj, "completion_tokens"))
971
- total = self._as_int(self._safe_get(u_obj, "total_tokens"))
972
- reasoning = (
973
- self._as_int(self._safe_get(u_obj, "output_tokens_details.reasoning_tokens")) or
974
- self._as_int(self._safe_get(u_obj, "completion_tokens_details.reasoning_tokens")) or
975
- self._as_int(self._safe_get(u_obj, "reasoning_tokens")) or
976
- 0
977
- )
978
- out_with_reason = (out_tok or 0) + (reasoning or 0)
979
- state.usage_payload = {"in": in_tok, "out": out_with_reason, "reasoning": reasoning or 0, "total": total}
980
-
981
- def _capture_google_usage(self, state: WorkerState, um_obj):
982
- """
983
- Extract usage for Google python-genai; prefer total - prompt to include reasoning.
984
-
985
- :param state: WorkerState
986
- :param um_obj: Usage metadata object from Google chunk
987
- """
988
- if not um_obj:
989
- return
990
- state.usage_vendor = "google"
991
- prompt = (
992
- self._as_int(self._safe_get(um_obj, "prompt_token_count")) or
993
- self._as_int(self._safe_get(um_obj, "prompt_tokens")) or
994
- self._as_int(self._safe_get(um_obj, "input_tokens"))
995
- )
996
- total = (
997
- self._as_int(self._safe_get(um_obj, "total_token_count")) or
998
- self._as_int(self._safe_get(um_obj, "total_tokens"))
999
- )
1000
- candidates = (
1001
- self._as_int(self._safe_get(um_obj, "candidates_token_count")) or
1002
- self._as_int(self._safe_get(um_obj, "output_tokens"))
1003
- )
1004
- reasoning = (
1005
- self._as_int(self._safe_get(um_obj, "candidates_reasoning_token_count")) or
1006
- self._as_int(self._safe_get(um_obj, "reasoning_tokens")) or 0
1007
- )
1008
- if total is not None and prompt is not None:
1009
- out_total = max(0, total - prompt)
1010
- else:
1011
- out_total = candidates
1012
- state.usage_payload = {"in": prompt, "out": out_total, "reasoning": reasoning or 0, "total": total}
1013
-
1014
- def _collect_google_citations(
1015
- self,
1016
- ctx: CtxItem,
1017
- state: WorkerState,
1018
- chunk: Any
1019
- ):
1020
- """
1021
- Collect web citations (URLs) from Google GenAI stream.
1022
-
1023
- Tries multiple known locations (grounding metadata and citation metadata)
1024
- in a defensive manner to remain compatible with SDK changes.
1025
-
1026
- :param ctx: CtxItem
1027
- :param state: WorkerState
1028
- :param chunk: The chunk object from the stream
1029
- """
1030
- try:
1031
- cands = getattr(chunk, "candidates", None) or []
1032
- except Exception:
1033
- cands = []
1034
-
1035
- if not isinstance(state.citations, list):
1036
- state.citations = []
1037
-
1038
- # Helper to add URLs with de-duplication
1039
- def _add_url(url: Optional[str]):
1040
- if not url or not isinstance(url, str):
1041
- return
1042
- url = url.strip()
1043
- if not (url.startswith("http://") or url.startswith("https://")):
1044
- return
1045
- # Initialize ctx.urls if needed
1046
- if ctx.urls is None:
1047
- ctx.urls = []
1048
- if url not in state.citations:
1049
- state.citations.append(url)
1050
- if url not in ctx.urls:
1051
- ctx.urls.append(url)
1052
-
1053
- # Candidate-level metadata extraction
1054
- for cand in cands:
1055
- # Grounding metadata (web search attributions)
1056
- gm = self._safe_get(cand, "grounding_metadata") or self._safe_get(cand, "groundingMetadata")
1057
- if gm:
1058
- atts = self._safe_get(gm, "grounding_attributions") or self._safe_get(gm, "groundingAttributions") or []
1059
- try:
1060
- for att in atts or []:
1061
- # Try several common paths for URI
1062
- for path in (
1063
- "web.uri",
1064
- "web.url",
1065
- "source.web.uri",
1066
- "source.web.url",
1067
- "source.uri",
1068
- "source.url",
1069
- "uri",
1070
- "url",
1071
- ):
1072
- _add_url(self._safe_get(att, path))
1073
- except Exception:
1074
- pass
1075
- # Also check search entry point
1076
- for path in (
1077
- "search_entry_point.uri",
1078
- "search_entry_point.url",
1079
- "searchEntryPoint.uri",
1080
- "searchEntryPoint.url",
1081
- "search_entry_point.rendered_content_uri",
1082
- "searchEntryPoint.rendered_content_uri",
1083
- ):
1084
- _add_url(self._safe_get(gm, path))
1085
-
1086
- # Citation metadata (legacy and alt paths)
1087
- cm = self._safe_get(cand, "citation_metadata") or self._safe_get(cand, "citationMetadata")
1088
- if cm:
1089
- cit_arrays = (
1090
- self._safe_get(cm, "citation_sources") or
1091
- self._safe_get(cm, "citationSources") or
1092
- self._safe_get(cm, "citations") or []
1093
- )
1094
- try:
1095
- for cit in cit_arrays or []:
1096
- for path in ("uri", "url", "source.uri", "source.url", "web.uri", "web.url"):
1097
- _add_url(self._safe_get(cit, path))
1098
- except Exception:
1099
- pass
1100
-
1101
- # Part-level citation metadata
1102
- try:
1103
- parts = self._safe_get(cand, "content.parts") or []
1104
- for p in parts:
1105
- # Per-part citation metadata
1106
- pcm = self._safe_get(p, "citation_metadata") or self._safe_get(p, "citationMetadata")
1107
- if pcm:
1108
- arr = (
1109
- self._safe_get(pcm, "citation_sources") or
1110
- self._safe_get(pcm, "citationSources") or
1111
- self._safe_get(pcm, "citations") or []
1112
- )
1113
- for cit in arr or []:
1114
- for path in ("uri", "url", "source.uri", "source.url", "web.uri", "web.url"):
1115
- _add_url(self._safe_get(cit, path))
1116
- # Per-part grounding attributions (rare)
1117
- gpa = self._safe_get(p, "grounding_attributions") or self._safe_get(p, "groundingAttributions") or []
1118
- for att in gpa or []:
1119
- for path in ("web.uri", "web.url", "source.web.uri", "source.web.url", "uri", "url"):
1120
- _add_url(self._safe_get(att, path))
1121
- except Exception:
1122
- pass
1123
-
1124
- # Bind to ctx on first discovery
1125
- if state.citations and (ctx.urls is None or not ctx.urls):
1126
- ctx.urls = list(state.citations)
1127
-
1128
- def cleanup(self):
1129
- """Cleanup resources after worker execution."""
1130
- sig = self.signals
1131
- self.signals = None
1132
- if sig is not None:
1133
- try:
1134
- sig.deleteLater()
1135
- except RuntimeError:
1136
- pass