base-agentkit 0.1.0__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.
- agentkit/__init__.py +35 -0
- agentkit/agent/__init__.py +7 -0
- agentkit/agent/agent.py +368 -0
- agentkit/agent/budgets.py +48 -0
- agentkit/agent/report.py +166 -0
- agentkit/agent/tool_runtime.py +77 -0
- agentkit/cli/__init__.py +5 -0
- agentkit/cli/main.py +108 -0
- agentkit/config/__init__.py +23 -0
- agentkit/config/loader.py +108 -0
- agentkit/config/provider_defaults.py +96 -0
- agentkit/config/schema.py +148 -0
- agentkit/constants.py +21 -0
- agentkit/errors.py +58 -0
- agentkit/llm/__init__.py +53 -0
- agentkit/llm/base.py +36 -0
- agentkit/llm/factory.py +27 -0
- agentkit/llm/providers/__init__.py +15 -0
- agentkit/llm/providers/anthropic_provider.py +371 -0
- agentkit/llm/providers/gemini_provider.py +396 -0
- agentkit/llm/providers/openai_provider.py +881 -0
- agentkit/llm/providers/qwen_provider.py +34 -0
- agentkit/llm/providers/vllm_provider.py +47 -0
- agentkit/llm/types.py +215 -0
- agentkit/llm/usage.py +72 -0
- agentkit/py.typed +0 -0
- agentkit/runlog/__init__.py +15 -0
- agentkit/runlog/events.py +67 -0
- agentkit/runlog/jsonl.py +90 -0
- agentkit/runlog/recorder.py +94 -0
- agentkit/runlog/sinks.py +15 -0
- agentkit/tools/__init__.py +16 -0
- agentkit/tools/base.py +139 -0
- agentkit/tools/library/__init__.py +8 -0
- agentkit/tools/library/_fs_common.py +330 -0
- agentkit/tools/library/create_file.py +168 -0
- agentkit/tools/library/fs_tools.py +21 -0
- agentkit/tools/library/str_replace.py +241 -0
- agentkit/tools/library/view.py +372 -0
- agentkit/tools/library/word_count.py +138 -0
- agentkit/tools/loader.py +81 -0
- agentkit/tools/registry.py +284 -0
- agentkit/tools/types.py +98 -0
- agentkit/workspace/__init__.py +6 -0
- agentkit/workspace/fs.py +288 -0
- agentkit/workspace/layout.py +33 -0
- base_agentkit-0.1.0.dist-info/METADATA +142 -0
- base_agentkit-0.1.0.dist-info/RECORD +51 -0
- base_agentkit-0.1.0.dist-info/WHEEL +4 -0
- base_agentkit-0.1.0.dist-info/entry_points.txt +3 -0
- base_agentkit-0.1.0.dist-info/licenses/LICENSE +183 -0
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
"""OpenAI provider supporting Responses and Chat Completions APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from openai import OpenAI
|
|
9
|
+
|
|
10
|
+
from agentkit.config.schema import ProviderConfig
|
|
11
|
+
from agentkit.errors import ProviderError, ProviderIssue
|
|
12
|
+
from agentkit.llm.base import BaseLLMProvider
|
|
13
|
+
from agentkit.llm.types import (
|
|
14
|
+
CompletionReason,
|
|
15
|
+
ConversationItem,
|
|
16
|
+
MessageItem,
|
|
17
|
+
ReasoningItem,
|
|
18
|
+
StatePatch,
|
|
19
|
+
ToolCallItem,
|
|
20
|
+
ToolResultItem,
|
|
21
|
+
TurnStatus,
|
|
22
|
+
UnifiedLLMRequest,
|
|
23
|
+
UnifiedLLMResponse,
|
|
24
|
+
Usage,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OpenAIProvider(BaseLLMProvider):
|
|
29
|
+
"""OpenAI provider adapter for the unified request/response model."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, config: ProviderConfig) -> None:
|
|
32
|
+
self.config = config
|
|
33
|
+
self.model = config.model
|
|
34
|
+
self._api_variant = config.openai_api_variant
|
|
35
|
+
|
|
36
|
+
client_kwargs: dict[str, Any] = {
|
|
37
|
+
"timeout": config.timeout_s,
|
|
38
|
+
"max_retries": config.retries,
|
|
39
|
+
}
|
|
40
|
+
if config.api_key:
|
|
41
|
+
client_kwargs["api_key"] = config.api_key
|
|
42
|
+
if config.base_url:
|
|
43
|
+
client_kwargs["base_url"] = config.base_url
|
|
44
|
+
|
|
45
|
+
self._client = OpenAI(**client_kwargs)
|
|
46
|
+
|
|
47
|
+
def generate(self, req: UnifiedLLMRequest) -> UnifiedLLMResponse:
|
|
48
|
+
if self._api_variant == "chat_completions":
|
|
49
|
+
return self._generate_chat_completions(req)
|
|
50
|
+
return self._generate_responses(req)
|
|
51
|
+
|
|
52
|
+
def render_output_text(
|
|
53
|
+
self,
|
|
54
|
+
output_items: list[ConversationItem],
|
|
55
|
+
raw_response: dict[str, object] | None,
|
|
56
|
+
) -> str:
|
|
57
|
+
del raw_response
|
|
58
|
+
assistant_texts = [
|
|
59
|
+
item.text
|
|
60
|
+
for item in output_items
|
|
61
|
+
if isinstance(item, MessageItem) and item.role == "assistant"
|
|
62
|
+
]
|
|
63
|
+
return "\n".join(text for text in assistant_texts if text).strip()
|
|
64
|
+
|
|
65
|
+
def _generate_responses(self, req: UnifiedLLMRequest) -> UnifiedLLMResponse:
|
|
66
|
+
if req.state.mode == "server" and self._api_variant != "responses":
|
|
67
|
+
raise ProviderError(
|
|
68
|
+
"conversation_mode='server' is only supported with OpenAI Responses.",
|
|
69
|
+
issue=ProviderIssue(category="invalid_request", retryable=False),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
use_server_cursor = req.state.mode == "server" or (
|
|
73
|
+
req.state.mode == "auto" and bool(req.state.provider_cursor)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if use_server_cursor and req.state.provider_cursor:
|
|
77
|
+
response_input = self._compile_responses_items(req.inputs)
|
|
78
|
+
else:
|
|
79
|
+
response_input = self._compile_responses_items(
|
|
80
|
+
req.state.history + req.inputs
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
kwargs: dict[str, Any] = {
|
|
84
|
+
"model": req.model,
|
|
85
|
+
"input": response_input,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
instructions_text = self._build_instruction_text(req)
|
|
89
|
+
if instructions_text:
|
|
90
|
+
kwargs["instructions"] = instructions_text
|
|
91
|
+
|
|
92
|
+
if use_server_cursor and req.state.provider_cursor:
|
|
93
|
+
kwargs["previous_response_id"] = req.state.provider_cursor
|
|
94
|
+
|
|
95
|
+
if req.tools:
|
|
96
|
+
kwargs["tools"] = [
|
|
97
|
+
{
|
|
98
|
+
"type": "function",
|
|
99
|
+
"name": tool.name,
|
|
100
|
+
"description": tool.description,
|
|
101
|
+
"parameters": tool.parameters,
|
|
102
|
+
}
|
|
103
|
+
for tool in req.tools
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
temperature = self._resolve_option(
|
|
107
|
+
req.options.temperature, self.config.temperature
|
|
108
|
+
)
|
|
109
|
+
if temperature is not None:
|
|
110
|
+
kwargs["temperature"] = temperature
|
|
111
|
+
|
|
112
|
+
if req.options.max_output_tokens is not None:
|
|
113
|
+
kwargs["max_output_tokens"] = req.options.max_output_tokens
|
|
114
|
+
|
|
115
|
+
if req.options.stop_sequences:
|
|
116
|
+
kwargs["stop"] = list(req.options.stop_sequences)
|
|
117
|
+
|
|
118
|
+
reasoning_effort = self._resolve_option(
|
|
119
|
+
req.options.reasoning_effort,
|
|
120
|
+
self.config.reasoning_effort,
|
|
121
|
+
)
|
|
122
|
+
if reasoning_effort and self._allow_reasoning_effort():
|
|
123
|
+
kwargs["reasoning"] = {"effort": reasoning_effort}
|
|
124
|
+
|
|
125
|
+
kwargs.update(self._extra_responses_kwargs(req))
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
response = self._client.responses.create(**kwargs)
|
|
129
|
+
except Exception as exc: # pragma: no cover - provider/network specific
|
|
130
|
+
raise ProviderError(
|
|
131
|
+
f"OpenAI request failed: {exc}",
|
|
132
|
+
issue=self._issue_from_exception(exc),
|
|
133
|
+
) from exc
|
|
134
|
+
return self._parse_responses_response(response)
|
|
135
|
+
|
|
136
|
+
def _generate_chat_completions(self, req: UnifiedLLMRequest) -> UnifiedLLMResponse:
|
|
137
|
+
if req.state.mode == "server":
|
|
138
|
+
raise ProviderError(
|
|
139
|
+
"conversation_mode='server' is only supported with OpenAI Responses.",
|
|
140
|
+
issue=ProviderIssue(category="invalid_request", retryable=False),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
messages = self._compile_chat_messages(req.state.history + req.inputs, req)
|
|
144
|
+
|
|
145
|
+
kwargs: dict[str, Any] = {
|
|
146
|
+
"model": req.model,
|
|
147
|
+
"messages": messages,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if req.tools:
|
|
151
|
+
kwargs["tools"] = [
|
|
152
|
+
{
|
|
153
|
+
"type": "function",
|
|
154
|
+
"function": {
|
|
155
|
+
"name": tool.name,
|
|
156
|
+
"description": tool.description,
|
|
157
|
+
"parameters": tool.parameters,
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
for tool in req.tools
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
temperature = self._resolve_option(
|
|
164
|
+
req.options.temperature, self.config.temperature
|
|
165
|
+
)
|
|
166
|
+
if temperature is not None:
|
|
167
|
+
kwargs["temperature"] = temperature
|
|
168
|
+
|
|
169
|
+
if req.options.max_output_tokens is not None:
|
|
170
|
+
kwargs["max_completion_tokens"] = req.options.max_output_tokens
|
|
171
|
+
|
|
172
|
+
if req.options.stop_sequences:
|
|
173
|
+
kwargs["stop"] = list(req.options.stop_sequences)
|
|
174
|
+
|
|
175
|
+
reasoning_effort = self._resolve_option(
|
|
176
|
+
req.options.reasoning_effort,
|
|
177
|
+
self.config.reasoning_effort,
|
|
178
|
+
)
|
|
179
|
+
if reasoning_effort and self._allow_reasoning_effort():
|
|
180
|
+
kwargs["reasoning_effort"] = reasoning_effort
|
|
181
|
+
|
|
182
|
+
kwargs.update(self._extra_chat_kwargs(req))
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
response = self._client.chat.completions.create(**kwargs)
|
|
186
|
+
except Exception as exc: # pragma: no cover - provider/network specific
|
|
187
|
+
raise ProviderError(
|
|
188
|
+
f"OpenAI request failed: {exc}",
|
|
189
|
+
issue=self._issue_from_exception(exc),
|
|
190
|
+
) from exc
|
|
191
|
+
return self._parse_chat_response(response)
|
|
192
|
+
|
|
193
|
+
def _build_instruction_text(self, req: UnifiedLLMRequest) -> str:
|
|
194
|
+
return req.instructions.strip()
|
|
195
|
+
|
|
196
|
+
def _compile_responses_items(
|
|
197
|
+
self, items: list[ConversationItem]
|
|
198
|
+
) -> list[dict[str, Any]]:
|
|
199
|
+
compiled: list[dict[str, Any]] = []
|
|
200
|
+
for item in items:
|
|
201
|
+
payload = self._to_responses_item(item)
|
|
202
|
+
if payload is not None:
|
|
203
|
+
compiled.append(payload)
|
|
204
|
+
return compiled
|
|
205
|
+
|
|
206
|
+
def _compile_chat_messages(
|
|
207
|
+
self,
|
|
208
|
+
items: list[ConversationItem],
|
|
209
|
+
req: UnifiedLLMRequest,
|
|
210
|
+
) -> list[dict[str, Any]]:
|
|
211
|
+
messages: list[dict[str, Any]] = []
|
|
212
|
+
|
|
213
|
+
instructions_text = req.instructions.strip()
|
|
214
|
+
if instructions_text:
|
|
215
|
+
messages.append({"role": "system", "content": instructions_text})
|
|
216
|
+
|
|
217
|
+
index = 0
|
|
218
|
+
while index < len(items):
|
|
219
|
+
item = items[index]
|
|
220
|
+
|
|
221
|
+
if isinstance(item, MessageItem):
|
|
222
|
+
if item.role == "user":
|
|
223
|
+
messages.append({"role": "user", "content": item.text})
|
|
224
|
+
index += 1
|
|
225
|
+
continue
|
|
226
|
+
assistant_message, next_index = self._consume_assistant_chat_turn(
|
|
227
|
+
items, index
|
|
228
|
+
)
|
|
229
|
+
if assistant_message is not None:
|
|
230
|
+
messages.append(assistant_message)
|
|
231
|
+
index = next_index
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
if isinstance(item, ToolResultItem):
|
|
235
|
+
messages.append(
|
|
236
|
+
{
|
|
237
|
+
"role": "tool",
|
|
238
|
+
"tool_call_id": item.call_id,
|
|
239
|
+
"content": self._serialize_tool_result(item),
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
index += 1
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
if isinstance(item, (ReasoningItem, ToolCallItem)):
|
|
246
|
+
assistant_message, next_index = self._consume_assistant_chat_turn(
|
|
247
|
+
items, index
|
|
248
|
+
)
|
|
249
|
+
if assistant_message is not None:
|
|
250
|
+
messages.append(assistant_message)
|
|
251
|
+
index = next_index
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
index += 1
|
|
255
|
+
|
|
256
|
+
return messages
|
|
257
|
+
|
|
258
|
+
def _to_responses_item(self, item: ConversationItem) -> dict[str, Any] | None:
|
|
259
|
+
if isinstance(item, MessageItem):
|
|
260
|
+
return {
|
|
261
|
+
"role": item.role,
|
|
262
|
+
"content": item.text,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if isinstance(item, ToolCallItem):
|
|
266
|
+
raw_arguments = item.raw_arguments
|
|
267
|
+
if raw_arguments is None:
|
|
268
|
+
raw_arguments = json.dumps(item.arguments, ensure_ascii=False)
|
|
269
|
+
return {
|
|
270
|
+
"type": "function_call",
|
|
271
|
+
"call_id": item.call_id,
|
|
272
|
+
"name": item.name,
|
|
273
|
+
"arguments": raw_arguments,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if isinstance(item, ToolResultItem):
|
|
277
|
+
return {
|
|
278
|
+
"type": "function_call_output",
|
|
279
|
+
"call_id": item.call_id,
|
|
280
|
+
"output": self._serialize_tool_result(item),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if item.replay_hint and item.raw_item:
|
|
284
|
+
return item.raw_item
|
|
285
|
+
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def _serialize_tool_result(self, item: ToolResultItem) -> str:
|
|
289
|
+
return item.output_text
|
|
290
|
+
|
|
291
|
+
def _consume_assistant_chat_turn(
|
|
292
|
+
self,
|
|
293
|
+
items: list[ConversationItem],
|
|
294
|
+
start_index: int,
|
|
295
|
+
) -> tuple[dict[str, Any] | None, int]:
|
|
296
|
+
index = start_index
|
|
297
|
+
consumed = False
|
|
298
|
+
content: str | None = None
|
|
299
|
+
content_seen = False
|
|
300
|
+
tool_calls: list[dict[str, Any]] = []
|
|
301
|
+
reasoning_fields: dict[str, Any] = {}
|
|
302
|
+
|
|
303
|
+
while index < len(items):
|
|
304
|
+
item = items[index]
|
|
305
|
+
|
|
306
|
+
if isinstance(item, ReasoningItem):
|
|
307
|
+
consumed = True
|
|
308
|
+
reasoning_fields.update(self._reasoning_item_to_chat_fields(item))
|
|
309
|
+
index += 1
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
if isinstance(item, MessageItem) and item.role == "assistant":
|
|
313
|
+
if content_seen:
|
|
314
|
+
break
|
|
315
|
+
consumed = True
|
|
316
|
+
content = item.text
|
|
317
|
+
content_seen = True
|
|
318
|
+
index += 1
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
if isinstance(item, ToolCallItem):
|
|
322
|
+
consumed = True
|
|
323
|
+
tool_calls.append(self._to_chat_tool_call(item))
|
|
324
|
+
index += 1
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
break
|
|
328
|
+
|
|
329
|
+
if not consumed:
|
|
330
|
+
return None, start_index + 1
|
|
331
|
+
|
|
332
|
+
message: dict[str, Any] = {"role": "assistant"}
|
|
333
|
+
if content_seen:
|
|
334
|
+
# Keep empty-string content as-is to preserve round-trip fidelity.
|
|
335
|
+
message["content"] = content
|
|
336
|
+
elif not tool_calls:
|
|
337
|
+
message["content"] = ""
|
|
338
|
+
|
|
339
|
+
if tool_calls:
|
|
340
|
+
message["tool_calls"] = tool_calls
|
|
341
|
+
if reasoning_fields:
|
|
342
|
+
message.update(reasoning_fields)
|
|
343
|
+
return message, index
|
|
344
|
+
|
|
345
|
+
def _reasoning_item_to_chat_fields(self, item: ReasoningItem) -> dict[str, Any]:
|
|
346
|
+
if not item.replay_hint:
|
|
347
|
+
return {}
|
|
348
|
+
|
|
349
|
+
raw = item.raw_item if isinstance(item.raw_item, dict) else None
|
|
350
|
+
if raw:
|
|
351
|
+
if raw.get("type") == "chat_reasoning":
|
|
352
|
+
field_name = raw.get("field")
|
|
353
|
+
if isinstance(field_name, str):
|
|
354
|
+
return {field_name: raw.get("value")}
|
|
355
|
+
for field_name in (
|
|
356
|
+
"reasoning",
|
|
357
|
+
"reasoning_content",
|
|
358
|
+
"reasoningContent",
|
|
359
|
+
"thinking",
|
|
360
|
+
):
|
|
361
|
+
if field_name in raw:
|
|
362
|
+
return {field_name: raw.get(field_name)}
|
|
363
|
+
|
|
364
|
+
if item.text is not None:
|
|
365
|
+
return {"reasoning": item.text}
|
|
366
|
+
if item.summary is not None:
|
|
367
|
+
return {"reasoning": item.summary}
|
|
368
|
+
return {}
|
|
369
|
+
|
|
370
|
+
def _to_chat_tool_call(self, item: ToolCallItem) -> dict[str, Any]:
|
|
371
|
+
raw_arguments = item.raw_arguments
|
|
372
|
+
if raw_arguments is None:
|
|
373
|
+
raw_arguments = json.dumps(item.arguments, ensure_ascii=False)
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
"id": item.call_id,
|
|
377
|
+
"type": "function",
|
|
378
|
+
"function": {
|
|
379
|
+
"name": item.name,
|
|
380
|
+
"arguments": raw_arguments,
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
def _parse_responses_response(self, response: Any) -> UnifiedLLMResponse:
|
|
385
|
+
raw_response = self._to_dict(response)
|
|
386
|
+
output = self._get(response, "output", []) or []
|
|
387
|
+
|
|
388
|
+
output_items: list[ConversationItem] = []
|
|
389
|
+
saw_refusal = False
|
|
390
|
+
|
|
391
|
+
for output_item in output:
|
|
392
|
+
item = self._to_dict(output_item)
|
|
393
|
+
item_type = str(item.get("type") or "")
|
|
394
|
+
|
|
395
|
+
if item_type == "reasoning":
|
|
396
|
+
output_items.append(self._to_reasoning_item(item))
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
if item_type == "function_call":
|
|
400
|
+
raw_arguments = item.get("arguments")
|
|
401
|
+
raw_arguments_str = (
|
|
402
|
+
raw_arguments if isinstance(raw_arguments, str) else None
|
|
403
|
+
)
|
|
404
|
+
arguments = self._parse_arguments(raw_arguments)
|
|
405
|
+
output_items.append(
|
|
406
|
+
ToolCallItem(
|
|
407
|
+
call_id=str(item.get("call_id") or item.get("id") or ""),
|
|
408
|
+
name=str(item.get("name") or ""),
|
|
409
|
+
arguments=arguments,
|
|
410
|
+
raw_arguments=raw_arguments_str,
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
if item_type == "message":
|
|
416
|
+
role = str(item.get("role") or "assistant")
|
|
417
|
+
if role not in {"assistant", "user"}:
|
|
418
|
+
role = "assistant"
|
|
419
|
+
|
|
420
|
+
content_items = item.get("content")
|
|
421
|
+
if isinstance(content_items, list):
|
|
422
|
+
for content_item in content_items:
|
|
423
|
+
content = self._to_dict(content_item)
|
|
424
|
+
ctype = str(content.get("type") or "")
|
|
425
|
+
if ctype in {"output_text", "text", "input_text"}:
|
|
426
|
+
text = str(content.get("text") or "")
|
|
427
|
+
if text:
|
|
428
|
+
output_items.append(MessageItem(role=role, text=text)) # type: ignore
|
|
429
|
+
elif ctype == "refusal":
|
|
430
|
+
refusal_text = str(
|
|
431
|
+
content.get("refusal") or content.get("text") or ""
|
|
432
|
+
)
|
|
433
|
+
if refusal_text:
|
|
434
|
+
saw_refusal = True
|
|
435
|
+
output_items.append(
|
|
436
|
+
MessageItem(role="assistant", text=refusal_text)
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
text = str(item.get("content") or "")
|
|
440
|
+
if text:
|
|
441
|
+
output_items.append(MessageItem(role=role, text=text)) # type: ignore
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
if item_type == "refusal":
|
|
445
|
+
refusal_text = str(item.get("refusal") or item.get("text") or "")
|
|
446
|
+
if refusal_text:
|
|
447
|
+
saw_refusal = True
|
|
448
|
+
output_items.append(
|
|
449
|
+
MessageItem(role="assistant", text=refusal_text)
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
status, reason = self._map_responses_status(response, output_items, saw_refusal)
|
|
453
|
+
|
|
454
|
+
output_text = self.render_output_text(
|
|
455
|
+
output_items,
|
|
456
|
+
raw_response if isinstance(raw_response, dict) else None,
|
|
457
|
+
)
|
|
458
|
+
if not output_text:
|
|
459
|
+
output_text = str(self._get(response, "output_text", "") or "").strip()
|
|
460
|
+
|
|
461
|
+
return UnifiedLLMResponse(
|
|
462
|
+
response_id=str(self._get(response, "id", "") or "") or None,
|
|
463
|
+
status=status,
|
|
464
|
+
reason=reason,
|
|
465
|
+
output_items=output_items,
|
|
466
|
+
output_text=output_text,
|
|
467
|
+
usage=self._parse_responses_usage(response),
|
|
468
|
+
state_patch=StatePatch(
|
|
469
|
+
new_provider_cursor=str(self._get(response, "id", "") or "") or None
|
|
470
|
+
),
|
|
471
|
+
provider_name="openai",
|
|
472
|
+
raw_response=raw_response if isinstance(raw_response, dict) else None,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
def _parse_chat_response(self, response: Any) -> UnifiedLLMResponse:
|
|
476
|
+
raw_response = self._to_dict(response)
|
|
477
|
+
choices = self._get(response, "choices", []) or []
|
|
478
|
+
if not choices:
|
|
479
|
+
raise ProviderError(
|
|
480
|
+
"OpenAI chat.completions response has no choices.",
|
|
481
|
+
issue=ProviderIssue(category="parse", retryable=False),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
choice = self._to_dict(choices[0])
|
|
485
|
+
message = self._to_dict(choice.get("message") or {})
|
|
486
|
+
if not message:
|
|
487
|
+
raise ProviderError(
|
|
488
|
+
"OpenAI chat.completions response missing choice message.",
|
|
489
|
+
issue=ProviderIssue(category="parse", retryable=False),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
output_items: list[ConversationItem] = []
|
|
493
|
+
|
|
494
|
+
output_items.extend(self._extract_chat_reasoning_items(message))
|
|
495
|
+
|
|
496
|
+
content = message.get("content")
|
|
497
|
+
if isinstance(content, str):
|
|
498
|
+
# Keep empty-string content so chat turn can be reconstructed exactly.
|
|
499
|
+
output_items.append(MessageItem(role="assistant", text=content))
|
|
500
|
+
elif isinstance(content, list):
|
|
501
|
+
text_content = self._chat_content_list_to_text(content)
|
|
502
|
+
if text_content is not None:
|
|
503
|
+
output_items.append(MessageItem(role="assistant", text=text_content))
|
|
504
|
+
|
|
505
|
+
saw_refusal = False
|
|
506
|
+
refusal = message.get("refusal")
|
|
507
|
+
if isinstance(refusal, str) and refusal.strip():
|
|
508
|
+
saw_refusal = True
|
|
509
|
+
output_items.append(MessageItem(role="assistant", text=refusal.strip()))
|
|
510
|
+
|
|
511
|
+
for tool_call in message.get("tool_calls") or []:
|
|
512
|
+
call = self._to_dict(tool_call)
|
|
513
|
+
if str(call.get("type") or "") != "function":
|
|
514
|
+
continue
|
|
515
|
+
function = self._to_dict(call.get("function") or {})
|
|
516
|
+
raw_arguments = function.get("arguments")
|
|
517
|
+
raw_arguments_str = (
|
|
518
|
+
raw_arguments if isinstance(raw_arguments, str) else None
|
|
519
|
+
)
|
|
520
|
+
output_items.append(
|
|
521
|
+
ToolCallItem(
|
|
522
|
+
call_id=str(call.get("id") or call.get("call_id") or ""),
|
|
523
|
+
name=str(function.get("name") or ""),
|
|
524
|
+
arguments=self._parse_arguments(raw_arguments),
|
|
525
|
+
raw_arguments=raw_arguments_str,
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
finish_reason = str(choice.get("finish_reason") or "")
|
|
530
|
+
status, reason = self._map_chat_status(
|
|
531
|
+
finish_reason=finish_reason,
|
|
532
|
+
output_items=output_items,
|
|
533
|
+
saw_refusal=saw_refusal,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
return UnifiedLLMResponse(
|
|
537
|
+
response_id=str(self._get(response, "id", "") or "") or None,
|
|
538
|
+
status=status,
|
|
539
|
+
reason=reason,
|
|
540
|
+
output_items=output_items,
|
|
541
|
+
output_text=self.render_output_text(
|
|
542
|
+
output_items,
|
|
543
|
+
raw_response if isinstance(raw_response, dict) else None,
|
|
544
|
+
),
|
|
545
|
+
usage=self._parse_chat_usage(response),
|
|
546
|
+
state_patch=StatePatch(),
|
|
547
|
+
provider_name="openai",
|
|
548
|
+
raw_response=raw_response if isinstance(raw_response, dict) else None,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def _map_responses_status(
|
|
552
|
+
self,
|
|
553
|
+
response: Any,
|
|
554
|
+
output_items: list[ConversationItem],
|
|
555
|
+
saw_refusal: bool,
|
|
556
|
+
) -> tuple[TurnStatus, CompletionReason]:
|
|
557
|
+
if any(isinstance(item, ToolCallItem) for item in output_items):
|
|
558
|
+
return "requires_tool", "tool_call"
|
|
559
|
+
|
|
560
|
+
if saw_refusal:
|
|
561
|
+
return "blocked", "refusal"
|
|
562
|
+
|
|
563
|
+
status = str(self._get(response, "status", "") or "").lower()
|
|
564
|
+
incomplete_details = self._to_dict(
|
|
565
|
+
self._get(response, "incomplete_details") or {}
|
|
566
|
+
)
|
|
567
|
+
incomplete_reason = str(incomplete_details.get("reason") or "").lower()
|
|
568
|
+
|
|
569
|
+
if incomplete_reason in {"max_output_tokens", "max_tokens", "length"}:
|
|
570
|
+
return "incomplete", "max_tokens"
|
|
571
|
+
if incomplete_reason in {
|
|
572
|
+
"content_filter",
|
|
573
|
+
"safety",
|
|
574
|
+
"safety_violation",
|
|
575
|
+
"recitation",
|
|
576
|
+
}:
|
|
577
|
+
return "blocked", "content_filter"
|
|
578
|
+
if incomplete_reason in {"pause", "pause_turn"}:
|
|
579
|
+
return "incomplete", "pause"
|
|
580
|
+
if incomplete_reason in {
|
|
581
|
+
"context_window",
|
|
582
|
+
"context_window_exceeded",
|
|
583
|
+
"model_context_window_exceeded",
|
|
584
|
+
}:
|
|
585
|
+
return "incomplete", "context_window"
|
|
586
|
+
if incomplete_reason == "refusal":
|
|
587
|
+
return "blocked", "refusal"
|
|
588
|
+
|
|
589
|
+
if status in {"failed", "error", "cancelled"}:
|
|
590
|
+
return "failed", "error"
|
|
591
|
+
if status == "incomplete":
|
|
592
|
+
return "incomplete", "unknown"
|
|
593
|
+
if status == "completed" or not status:
|
|
594
|
+
return "completed", "stop"
|
|
595
|
+
|
|
596
|
+
return "failed", "unknown"
|
|
597
|
+
|
|
598
|
+
def _map_chat_status(
|
|
599
|
+
self,
|
|
600
|
+
*,
|
|
601
|
+
finish_reason: str,
|
|
602
|
+
output_items: list[ConversationItem],
|
|
603
|
+
saw_refusal: bool,
|
|
604
|
+
) -> tuple[TurnStatus, CompletionReason]:
|
|
605
|
+
if any(isinstance(item, ToolCallItem) for item in output_items):
|
|
606
|
+
return "requires_tool", "tool_call"
|
|
607
|
+
|
|
608
|
+
if saw_refusal:
|
|
609
|
+
return "blocked", "refusal"
|
|
610
|
+
|
|
611
|
+
reason = finish_reason.lower()
|
|
612
|
+
if reason in {"stop", "stop_sequence", "end_turn", ""}:
|
|
613
|
+
return "completed", "stop"
|
|
614
|
+
if reason in {"tool_calls", "tool_call"}:
|
|
615
|
+
return "requires_tool", "tool_call"
|
|
616
|
+
if reason in {"length", "max_tokens", "max_output_tokens"}:
|
|
617
|
+
return "incomplete", "max_tokens"
|
|
618
|
+
if reason in {"content_filter", "safety", "recitation"}:
|
|
619
|
+
return "blocked", "content_filter"
|
|
620
|
+
if reason in {"refusal"}:
|
|
621
|
+
return "blocked", "refusal"
|
|
622
|
+
if reason in {"context_window", "context_window_exceeded"}:
|
|
623
|
+
return "incomplete", "context_window"
|
|
624
|
+
|
|
625
|
+
return "incomplete", "unknown"
|
|
626
|
+
|
|
627
|
+
def _parse_responses_usage(self, response: Any) -> Usage:
|
|
628
|
+
usage = self._to_dict(self._get(response, "usage") or {})
|
|
629
|
+
input_details = self._to_dict(
|
|
630
|
+
usage.get("input_tokens_details") or usage.get("input_token_details") or {}
|
|
631
|
+
)
|
|
632
|
+
output_details = self._to_dict(
|
|
633
|
+
usage.get("output_tokens_details")
|
|
634
|
+
or usage.get("output_token_details")
|
|
635
|
+
or {}
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
return Usage(
|
|
639
|
+
input_tokens=self._as_int(usage.get("input_tokens")),
|
|
640
|
+
output_tokens=self._as_int(usage.get("output_tokens")),
|
|
641
|
+
total_tokens=self._as_int(usage.get("total_tokens")),
|
|
642
|
+
reasoning_tokens=self._as_int(output_details.get("reasoning_tokens")),
|
|
643
|
+
cache_read_tokens=self._as_int(
|
|
644
|
+
input_details.get("cached_tokens") or usage.get("cached_tokens")
|
|
645
|
+
),
|
|
646
|
+
cache_write_tokens=self._as_int(
|
|
647
|
+
input_details.get("cache_creation_tokens")
|
|
648
|
+
or usage.get("cache_creation_tokens")
|
|
649
|
+
),
|
|
650
|
+
raw=usage or None,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def _parse_chat_usage(self, response: Any) -> Usage:
|
|
654
|
+
usage = self._to_dict(self._get(response, "usage") or {})
|
|
655
|
+
completion_details = self._to_dict(
|
|
656
|
+
usage.get("completion_tokens_details")
|
|
657
|
+
or usage.get("output_tokens_details")
|
|
658
|
+
or {}
|
|
659
|
+
)
|
|
660
|
+
prompt_details = self._to_dict(
|
|
661
|
+
usage.get("prompt_tokens_details")
|
|
662
|
+
or usage.get("input_tokens_details")
|
|
663
|
+
or {}
|
|
664
|
+
)
|
|
665
|
+
return Usage(
|
|
666
|
+
input_tokens=self._as_int(usage.get("prompt_tokens")),
|
|
667
|
+
output_tokens=self._as_int(usage.get("completion_tokens")),
|
|
668
|
+
total_tokens=self._as_int(usage.get("total_tokens")),
|
|
669
|
+
reasoning_tokens=self._as_int(completion_details.get("reasoning_tokens")),
|
|
670
|
+
cache_read_tokens=self._as_int(prompt_details.get("cached_tokens")),
|
|
671
|
+
raw=usage or None,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
def _to_reasoning_item(self, item: dict[str, Any]) -> ReasoningItem:
|
|
675
|
+
text: str | None = None
|
|
676
|
+
summary: str | None = None
|
|
677
|
+
|
|
678
|
+
raw_text = item.get("text")
|
|
679
|
+
if isinstance(raw_text, str) and raw_text.strip():
|
|
680
|
+
text = raw_text.strip()
|
|
681
|
+
|
|
682
|
+
raw_summary = item.get("summary")
|
|
683
|
+
if isinstance(raw_summary, str) and raw_summary.strip():
|
|
684
|
+
summary = raw_summary.strip()
|
|
685
|
+
elif isinstance(raw_summary, list):
|
|
686
|
+
summary_parts: list[str] = []
|
|
687
|
+
for part in raw_summary:
|
|
688
|
+
part_dict = self._to_dict(part)
|
|
689
|
+
part_text = (
|
|
690
|
+
part_dict.get("text")
|
|
691
|
+
or part_dict.get("summary")
|
|
692
|
+
or part_dict.get("content")
|
|
693
|
+
)
|
|
694
|
+
if isinstance(part_text, str) and part_text.strip():
|
|
695
|
+
summary_parts.append(part_text.strip())
|
|
696
|
+
if summary_parts:
|
|
697
|
+
summary = "\n".join(summary_parts)
|
|
698
|
+
|
|
699
|
+
if text is None and summary is None:
|
|
700
|
+
raw_thinking = item.get("thinking")
|
|
701
|
+
if isinstance(raw_thinking, str) and raw_thinking.strip():
|
|
702
|
+
text = raw_thinking.strip()
|
|
703
|
+
|
|
704
|
+
return ReasoningItem(
|
|
705
|
+
text=text, summary=summary, raw_item=item, replay_hint=True
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
def _extract_chat_reasoning_items(
|
|
709
|
+
self, message: dict[str, Any]
|
|
710
|
+
) -> list[ReasoningItem]:
|
|
711
|
+
reasoning_items: list[ReasoningItem] = []
|
|
712
|
+
for field_name in (
|
|
713
|
+
"reasoning",
|
|
714
|
+
"reasoning_content",
|
|
715
|
+
"thinking",
|
|
716
|
+
):
|
|
717
|
+
if field_name not in message:
|
|
718
|
+
continue
|
|
719
|
+
value = message.get(field_name)
|
|
720
|
+
text, summary = self._reasoning_text_and_summary_from_value(value)
|
|
721
|
+
reasoning_items.append(
|
|
722
|
+
ReasoningItem(
|
|
723
|
+
text=text,
|
|
724
|
+
summary=summary,
|
|
725
|
+
raw_item={
|
|
726
|
+
"type": "chat_reasoning",
|
|
727
|
+
"field": field_name,
|
|
728
|
+
"value": value,
|
|
729
|
+
},
|
|
730
|
+
replay_hint=True,
|
|
731
|
+
)
|
|
732
|
+
)
|
|
733
|
+
return reasoning_items
|
|
734
|
+
|
|
735
|
+
def _reasoning_text_and_summary_from_value(
|
|
736
|
+
self, value: Any
|
|
737
|
+
) -> tuple[str | None, str | None]:
|
|
738
|
+
if isinstance(value, str):
|
|
739
|
+
return value, None
|
|
740
|
+
if isinstance(value, dict):
|
|
741
|
+
item = self._to_reasoning_item(value)
|
|
742
|
+
return item.text, item.summary
|
|
743
|
+
if isinstance(value, list):
|
|
744
|
+
parts: list[str] = []
|
|
745
|
+
for entry in value:
|
|
746
|
+
if isinstance(entry, str):
|
|
747
|
+
parts.append(entry)
|
|
748
|
+
continue
|
|
749
|
+
entry_dict = self._to_dict(entry)
|
|
750
|
+
text = (
|
|
751
|
+
entry_dict.get("text")
|
|
752
|
+
or entry_dict.get("content")
|
|
753
|
+
or entry_dict.get("summary")
|
|
754
|
+
)
|
|
755
|
+
if isinstance(text, str):
|
|
756
|
+
parts.append(text)
|
|
757
|
+
if parts:
|
|
758
|
+
return "\n".join(parts), None
|
|
759
|
+
return None, None
|
|
760
|
+
|
|
761
|
+
def _chat_content_list_to_text(self, content: list[Any]) -> str | None:
|
|
762
|
+
parts: list[str] = []
|
|
763
|
+
for entry in content:
|
|
764
|
+
entry_dict = self._to_dict(entry)
|
|
765
|
+
text = entry_dict.get("text")
|
|
766
|
+
if isinstance(text, str):
|
|
767
|
+
parts.append(text)
|
|
768
|
+
if parts:
|
|
769
|
+
return "\n".join(parts)
|
|
770
|
+
return None
|
|
771
|
+
|
|
772
|
+
def _parse_arguments(self, value: Any) -> dict[str, Any]:
|
|
773
|
+
if isinstance(value, dict):
|
|
774
|
+
return value
|
|
775
|
+
if isinstance(value, str):
|
|
776
|
+
try:
|
|
777
|
+
parsed = json.loads(value)
|
|
778
|
+
except json.JSONDecodeError:
|
|
779
|
+
return {"_raw": value}
|
|
780
|
+
if isinstance(parsed, dict):
|
|
781
|
+
return parsed
|
|
782
|
+
return {"value": parsed}
|
|
783
|
+
if value is None:
|
|
784
|
+
return {}
|
|
785
|
+
return {"value": value}
|
|
786
|
+
|
|
787
|
+
def _issue_from_exception(self, exc: Exception) -> ProviderIssue:
|
|
788
|
+
status = getattr(exc, "status_code", None)
|
|
789
|
+
body = getattr(exc, "body", None)
|
|
790
|
+
code = getattr(exc, "code", None)
|
|
791
|
+
|
|
792
|
+
category = "unknown"
|
|
793
|
+
retryable = False
|
|
794
|
+
|
|
795
|
+
if isinstance(status, int):
|
|
796
|
+
if status in {401, 403}:
|
|
797
|
+
category = "auth"
|
|
798
|
+
elif status == 429:
|
|
799
|
+
category = "rate_limit"
|
|
800
|
+
retryable = True
|
|
801
|
+
elif status in {408, 504}:
|
|
802
|
+
category = "timeout"
|
|
803
|
+
retryable = True
|
|
804
|
+
elif 400 <= status < 500:
|
|
805
|
+
category = "invalid_request"
|
|
806
|
+
elif status >= 500:
|
|
807
|
+
category = "upstream"
|
|
808
|
+
retryable = True
|
|
809
|
+
|
|
810
|
+
message = str(exc).lower()
|
|
811
|
+
if "timeout" in message and category == "unknown":
|
|
812
|
+
category = "timeout"
|
|
813
|
+
retryable = True
|
|
814
|
+
if (
|
|
815
|
+
"content filter" in message or "safety" in message
|
|
816
|
+
) and category == "unknown":
|
|
817
|
+
category = "safety"
|
|
818
|
+
|
|
819
|
+
raw: dict[str, Any] | None = None
|
|
820
|
+
if isinstance(body, dict):
|
|
821
|
+
raw = body
|
|
822
|
+
|
|
823
|
+
return ProviderIssue(
|
|
824
|
+
category=category,
|
|
825
|
+
http_status=status if isinstance(status, int) else None,
|
|
826
|
+
provider_code=str(code) if code else None,
|
|
827
|
+
retryable=retryable,
|
|
828
|
+
raw=raw,
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
def _resolve_option(self, request_value: Any, config_value: Any) -> Any:
|
|
832
|
+
return request_value if request_value is not None else config_value
|
|
833
|
+
|
|
834
|
+
def _allow_reasoning_effort(self) -> bool:
|
|
835
|
+
return True
|
|
836
|
+
|
|
837
|
+
def _extra_responses_kwargs(self, req: UnifiedLLMRequest) -> dict[str, Any]:
|
|
838
|
+
del req
|
|
839
|
+
return {}
|
|
840
|
+
|
|
841
|
+
def _extra_chat_kwargs(self, req: UnifiedLLMRequest) -> dict[str, Any]:
|
|
842
|
+
del req
|
|
843
|
+
return {}
|
|
844
|
+
|
|
845
|
+
def _as_int(self, value: Any) -> int | None:
|
|
846
|
+
if isinstance(value, bool):
|
|
847
|
+
return int(value)
|
|
848
|
+
if isinstance(value, int):
|
|
849
|
+
return value
|
|
850
|
+
if isinstance(value, float):
|
|
851
|
+
return int(value)
|
|
852
|
+
if isinstance(value, str):
|
|
853
|
+
try:
|
|
854
|
+
return int(value)
|
|
855
|
+
except ValueError:
|
|
856
|
+
return None
|
|
857
|
+
return None
|
|
858
|
+
|
|
859
|
+
def _to_dict(self, item: Any) -> dict[str, Any]:
|
|
860
|
+
if isinstance(item, dict):
|
|
861
|
+
return item
|
|
862
|
+
if hasattr(item, "model_dump"):
|
|
863
|
+
try:
|
|
864
|
+
dumped = item.model_dump(mode="python")
|
|
865
|
+
if isinstance(dumped, dict):
|
|
866
|
+
return dumped
|
|
867
|
+
except Exception:
|
|
868
|
+
pass
|
|
869
|
+
if hasattr(item, "__dict__"):
|
|
870
|
+
try:
|
|
871
|
+
dumped = dict(vars(item))
|
|
872
|
+
if isinstance(dumped, dict):
|
|
873
|
+
return dumped
|
|
874
|
+
except Exception:
|
|
875
|
+
pass
|
|
876
|
+
return {}
|
|
877
|
+
|
|
878
|
+
def _get(self, obj: Any, key: str, default: Any = None) -> Any:
|
|
879
|
+
if isinstance(obj, dict):
|
|
880
|
+
return obj.get(key, default)
|
|
881
|
+
return getattr(obj, key, default)
|