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.
- pygpt_net/CHANGELOG.txt +13 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/{container.py → app_core.py} +5 -6
- pygpt_net/controller/access/control.py +1 -9
- pygpt_net/controller/assistant/assistant.py +4 -4
- pygpt_net/controller/assistant/batch.py +7 -7
- pygpt_net/controller/assistant/files.py +4 -4
- pygpt_net/controller/assistant/threads.py +3 -3
- pygpt_net/controller/attachment/attachment.py +4 -7
- pygpt_net/controller/chat/common.py +1 -1
- pygpt_net/controller/chat/stream.py +961 -294
- pygpt_net/controller/chat/vision.py +11 -19
- pygpt_net/controller/config/placeholder.py +1 -1
- pygpt_net/controller/ctx/ctx.py +1 -1
- pygpt_net/controller/ctx/summarizer.py +1 -1
- pygpt_net/controller/mode/mode.py +21 -12
- pygpt_net/controller/plugins/settings.py +3 -2
- pygpt_net/controller/presets/editor.py +112 -99
- pygpt_net/controller/theme/common.py +2 -0
- pygpt_net/controller/theme/theme.py +6 -2
- pygpt_net/controller/ui/vision.py +4 -4
- pygpt_net/core/agents/legacy.py +2 -2
- pygpt_net/core/agents/runners/openai_workflow.py +2 -2
- pygpt_net/core/assistants/files.py +5 -5
- pygpt_net/core/assistants/store.py +4 -4
- pygpt_net/core/bridge/bridge.py +3 -3
- pygpt_net/core/bridge/worker.py +28 -9
- pygpt_net/core/debug/console/console.py +2 -2
- pygpt_net/core/debug/presets.py +2 -2
- pygpt_net/core/experts/experts.py +2 -2
- pygpt_net/core/idx/llm.py +21 -3
- pygpt_net/core/modes/modes.py +2 -2
- pygpt_net/core/presets/presets.py +3 -3
- pygpt_net/core/tokens/tokens.py +4 -4
- pygpt_net/core/types/mode.py +5 -2
- pygpt_net/core/vision/analyzer.py +1 -1
- pygpt_net/data/config/config.json +6 -3
- pygpt_net/data/config/models.json +75 -3
- pygpt_net/data/config/modes.json +3 -9
- pygpt_net/data/config/settings.json +112 -55
- pygpt_net/data/config/settings_section.json +2 -2
- pygpt_net/data/locale/locale.de.ini +2 -2
- pygpt_net/data/locale/locale.en.ini +9 -2
- pygpt_net/data/locale/locale.es.ini +2 -2
- pygpt_net/data/locale/locale.fr.ini +2 -2
- pygpt_net/data/locale/locale.it.ini +2 -2
- pygpt_net/data/locale/locale.pl.ini +3 -3
- pygpt_net/data/locale/locale.uk.ini +2 -2
- pygpt_net/data/locale/locale.zh.ini +2 -2
- pygpt_net/item/model.py +23 -3
- pygpt_net/plugin/openai_dalle/plugin.py +4 -4
- pygpt_net/plugin/openai_vision/plugin.py +12 -13
- pygpt_net/provider/agents/openai/agent.py +5 -5
- pygpt_net/provider/agents/openai/agent_b2b.py +5 -5
- pygpt_net/provider/agents/openai/agent_planner.py +5 -6
- pygpt_net/provider/agents/openai/agent_with_experts.py +5 -5
- pygpt_net/provider/agents/openai/agent_with_experts_feedback.py +4 -4
- pygpt_net/provider/agents/openai/agent_with_feedback.py +4 -4
- pygpt_net/provider/agents/openai/bot_researcher.py +2 -2
- pygpt_net/provider/agents/openai/bots/research_bot/agents/planner_agent.py +1 -1
- pygpt_net/provider/agents/openai/bots/research_bot/agents/search_agent.py +1 -1
- pygpt_net/provider/agents/openai/bots/research_bot/agents/writer_agent.py +1 -1
- pygpt_net/provider/agents/openai/evolve.py +5 -5
- pygpt_net/provider/agents/openai/supervisor.py +4 -4
- pygpt_net/provider/api/__init__.py +27 -0
- pygpt_net/provider/api/anthropic/__init__.py +68 -0
- pygpt_net/provider/api/google/__init__.py +262 -0
- pygpt_net/provider/api/google/audio.py +114 -0
- pygpt_net/provider/api/google/chat.py +552 -0
- pygpt_net/provider/api/google/image.py +287 -0
- pygpt_net/provider/api/google/tools.py +222 -0
- pygpt_net/provider/api/google/vision.py +129 -0
- pygpt_net/provider/{gpt → api/openai}/__init__.py +2 -2
- pygpt_net/provider/{gpt → api/openai}/agents/computer.py +1 -1
- pygpt_net/provider/{gpt → api/openai}/agents/experts.py +1 -1
- pygpt_net/provider/{gpt → api/openai}/agents/response.py +1 -1
- pygpt_net/provider/{gpt → api/openai}/assistants.py +1 -1
- pygpt_net/provider/{gpt → api/openai}/chat.py +15 -8
- pygpt_net/provider/{gpt → api/openai}/completion.py +1 -1
- pygpt_net/provider/{gpt → api/openai}/image.py +1 -1
- pygpt_net/provider/{gpt → api/openai}/remote_tools.py +1 -1
- pygpt_net/provider/{gpt → api/openai}/responses.py +34 -20
- pygpt_net/provider/{gpt → api/openai}/store.py +2 -2
- pygpt_net/provider/{gpt → api/openai}/vision.py +1 -1
- pygpt_net/provider/{gpt → api/openai}/worker/assistants.py +4 -4
- pygpt_net/provider/{gpt → api/openai}/worker/importer.py +10 -10
- pygpt_net/provider/audio_input/openai_whisper.py +1 -1
- pygpt_net/provider/audio_output/google_tts.py +12 -0
- pygpt_net/provider/audio_output/openai_tts.py +1 -1
- pygpt_net/provider/core/config/patch.py +11 -0
- pygpt_net/provider/core/model/patch.py +9 -0
- pygpt_net/provider/core/preset/json_file.py +2 -4
- pygpt_net/provider/llms/anthropic.py +2 -5
- pygpt_net/provider/llms/base.py +4 -3
- pygpt_net/provider/llms/openai.py +1 -1
- pygpt_net/provider/loaders/hub/image_vision/base.py +1 -1
- pygpt_net/ui/dialog/preset.py +71 -55
- pygpt_net/ui/main.py +6 -4
- pygpt_net/utils.py +9 -0
- {pygpt_net-2.6.28.dist-info → pygpt_net-2.6.30.dist-info}/METADATA +42 -48
- {pygpt_net-2.6.28.dist-info → pygpt_net-2.6.30.dist-info}/RECORD +115 -107
- /pygpt_net/provider/{gpt → api/openai}/agents/__init__.py +0 -0
- /pygpt_net/provider/{gpt → api/openai}/agents/client.py +0 -0
- /pygpt_net/provider/{gpt → api/openai}/agents/remote_tools.py +0 -0
- /pygpt_net/provider/{gpt → api/openai}/agents/utils.py +0 -0
- /pygpt_net/provider/{gpt → api/openai}/audio.py +0 -0
- /pygpt_net/provider/{gpt → api/openai}/computer.py +0 -0
- /pygpt_net/provider/{gpt → api/openai}/container.py +0 -0
- /pygpt_net/provider/{gpt → api/openai}/summarizer.py +0 -0
- /pygpt_net/provider/{gpt → api/openai}/tools.py +0 -0
- /pygpt_net/provider/{gpt → api/openai}/utils.py +0 -0
- /pygpt_net/provider/{gpt → api/openai}/worker/__init__.py +0 -0
- {pygpt_net-2.6.28.dist-info → pygpt_net-2.6.30.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.28.dist-info → pygpt_net-2.6.30.dist-info}/WHEEL +0 -0
- {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.
|
|
9
|
+
# Updated Date: 2025.08.28 20:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
import base64
|
|
13
13
|
import io
|
|
14
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
365
|
-
output_parts.clear()
|
|
366
|
-
del output_parts
|
|
159
|
+
self._finalize(ctx, core, state, emit_end, emit_error)
|
|
367
160
|
|
|
368
|
-
|
|
369
|
-
output += "\n```"
|
|
161
|
+
# ------------ Orchestration helpers ------------
|
|
370
162
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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."""
|