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