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.
Files changed (51) hide show
  1. agentkit/__init__.py +35 -0
  2. agentkit/agent/__init__.py +7 -0
  3. agentkit/agent/agent.py +368 -0
  4. agentkit/agent/budgets.py +48 -0
  5. agentkit/agent/report.py +166 -0
  6. agentkit/agent/tool_runtime.py +77 -0
  7. agentkit/cli/__init__.py +5 -0
  8. agentkit/cli/main.py +108 -0
  9. agentkit/config/__init__.py +23 -0
  10. agentkit/config/loader.py +108 -0
  11. agentkit/config/provider_defaults.py +96 -0
  12. agentkit/config/schema.py +148 -0
  13. agentkit/constants.py +21 -0
  14. agentkit/errors.py +58 -0
  15. agentkit/llm/__init__.py +53 -0
  16. agentkit/llm/base.py +36 -0
  17. agentkit/llm/factory.py +27 -0
  18. agentkit/llm/providers/__init__.py +15 -0
  19. agentkit/llm/providers/anthropic_provider.py +371 -0
  20. agentkit/llm/providers/gemini_provider.py +396 -0
  21. agentkit/llm/providers/openai_provider.py +881 -0
  22. agentkit/llm/providers/qwen_provider.py +34 -0
  23. agentkit/llm/providers/vllm_provider.py +47 -0
  24. agentkit/llm/types.py +215 -0
  25. agentkit/llm/usage.py +72 -0
  26. agentkit/py.typed +0 -0
  27. agentkit/runlog/__init__.py +15 -0
  28. agentkit/runlog/events.py +67 -0
  29. agentkit/runlog/jsonl.py +90 -0
  30. agentkit/runlog/recorder.py +94 -0
  31. agentkit/runlog/sinks.py +15 -0
  32. agentkit/tools/__init__.py +16 -0
  33. agentkit/tools/base.py +139 -0
  34. agentkit/tools/library/__init__.py +8 -0
  35. agentkit/tools/library/_fs_common.py +330 -0
  36. agentkit/tools/library/create_file.py +168 -0
  37. agentkit/tools/library/fs_tools.py +21 -0
  38. agentkit/tools/library/str_replace.py +241 -0
  39. agentkit/tools/library/view.py +372 -0
  40. agentkit/tools/library/word_count.py +138 -0
  41. agentkit/tools/loader.py +81 -0
  42. agentkit/tools/registry.py +284 -0
  43. agentkit/tools/types.py +98 -0
  44. agentkit/workspace/__init__.py +6 -0
  45. agentkit/workspace/fs.py +288 -0
  46. agentkit/workspace/layout.py +33 -0
  47. base_agentkit-0.1.0.dist-info/METADATA +142 -0
  48. base_agentkit-0.1.0.dist-info/RECORD +51 -0
  49. base_agentkit-0.1.0.dist-info/WHEEL +4 -0
  50. base_agentkit-0.1.0.dist-info/entry_points.txt +3 -0
  51. 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)