mycode-coding-agent 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 (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
mycode/llm.py ADDED
@@ -0,0 +1,771 @@
1
+ from collections.abc import Iterator, Mapping
2
+ from dataclasses import dataclass, field
3
+ import json
4
+ from time import perf_counter
5
+ from typing import Any, Literal, Protocol
6
+ from urllib.parse import urlsplit
7
+
8
+ import httpx
9
+ from openai import OpenAI
10
+
11
+ from mycode.agent.events import AgentEvent, AgentModelResponse, AgentToolCall
12
+ from mycode.config import LLMConfig, ReasoningEffort
13
+ from mycode.context.budget import TokenUsage
14
+ from mycode.conversation import Conversation
15
+ from mycode.error_handling import extract_provider_diagnostic
16
+ from mycode.messages import Message
17
+ from mycode.reasoning import ReasoningState
18
+
19
+
20
+ SDK_MAX_RETRIES = 2
21
+ SDK_TIMEOUT = httpx.Timeout(
22
+ connect=5.0,
23
+ read=120.0,
24
+ write=30.0,
25
+ pool=10.0,
26
+ )
27
+ _OPENCODE_GO_HOST = "opencode.ai"
28
+ _OPENCODE_GO_PATHS = frozenset({"/zen/go", "/zen/go/v1"})
29
+
30
+
31
+ def _is_opencode_go_base_url(base_url: str) -> bool:
32
+ try:
33
+ parsed = urlsplit(base_url)
34
+ port = parsed.port
35
+ except ValueError:
36
+ return False
37
+ return (
38
+ parsed.scheme.casefold() == "https"
39
+ and parsed.hostname is not None
40
+ and parsed.hostname.casefold() == _OPENCODE_GO_HOST
41
+ and parsed.username is None
42
+ and parsed.password is None
43
+ and port in {None, 443}
44
+ and parsed.path.rstrip("/") in _OPENCODE_GO_PATHS
45
+ and parsed.query == ""
46
+ and parsed.fragment == ""
47
+ )
48
+
49
+
50
+ class LLMClient(Protocol):
51
+ last_token_usage: TokenUsage | None
52
+ last_reasoning_char_count: int
53
+ last_model_response: dict[str, object] | None
54
+
55
+ def complete(self, conversation: Conversation) -> Message:
56
+ pass
57
+
58
+ def stream_complete(self, conversation: Conversation) -> Iterator[str]:
59
+ pass
60
+
61
+ def stream_with_tools(
62
+ self,
63
+ conversation: Conversation,
64
+ tools: list[dict[str, object]],
65
+ ) -> Iterator[AgentEvent]:
66
+ pass
67
+
68
+
69
+ @dataclass
70
+ class OpenAICompatibleLLMClient:
71
+ config: LLMConfig
72
+ model: str | None = None
73
+ thinking_enabled: bool | None = None
74
+ reasoning_effort: ReasoningEffort | None = None
75
+ session_id: str | None = field(default=None, repr=False)
76
+ _client: Any = field(default=None, repr=False)
77
+ last_token_usage: TokenUsage | None = field(default=None, init=False)
78
+ last_reasoning_char_count: int = field(default=0, init=False)
79
+ last_model_response: dict[str, object] | None = field(default=None, init=False)
80
+
81
+ def __post_init__(self) -> None:
82
+ self.model = self.config.model if self.model is None else self.model.strip()
83
+ if self.model == "":
84
+ raise ValueError("LLM client model must not be empty.")
85
+ self.thinking_enabled = (
86
+ self.config.thinking_enabled
87
+ if self.thinking_enabled is None
88
+ else self.thinking_enabled
89
+ )
90
+ if self.thinking_enabled is True:
91
+ self.reasoning_effort = (
92
+ self.config.reasoning_effort
93
+ if self.reasoning_effort is None
94
+ else self.reasoning_effort
95
+ )
96
+ if self.reasoning_effort is None:
97
+ self.reasoning_effort = "high"
98
+ elif self.reasoning_effort is not None:
99
+ raise ValueError("reasoning_effort requires thinking_enabled=True.")
100
+ if self._client is None:
101
+ self._client = OpenAI(
102
+ api_key=self.config.api_key,
103
+ base_url=self.config.base_url,
104
+ max_retries=SDK_MAX_RETRIES,
105
+ timeout=SDK_TIMEOUT,
106
+ )
107
+
108
+ def complete(self, conversation: Conversation) -> Message:
109
+ self.last_token_usage = None
110
+ self.last_reasoning_char_count = 0
111
+ self.last_model_response = None
112
+ observation = _ModelResponseAccumulator(model=self.model, stream=False)
113
+ try:
114
+ response = self._client.chat.completions.create(
115
+ **self._request(conversation, stream=False)
116
+ )
117
+ except Exception as error:
118
+ self.last_model_response = observation.finish(error=error)
119
+ raise
120
+ observation.observe_response(response)
121
+ self.last_token_usage = _extract_token_usage(response)
122
+
123
+ choice = _first_choice(response)
124
+ if choice is None:
125
+ self.last_model_response = observation.finish(usage=self.last_token_usage)
126
+ return Message(role="assistant", content="")
127
+
128
+ message = getattr(choice, "message", None)
129
+ content = getattr(message, "content", None) or ""
130
+ reasoning = _extract_reasoning_field(message)
131
+ self.last_reasoning_char_count = len(reasoning.content or "")
132
+ observation.observe_content(content)
133
+ observation.observe_reasoning(reasoning)
134
+ self.last_model_response = observation.finish(usage=self.last_token_usage)
135
+
136
+ return Message(role="assistant", content=content)
137
+
138
+ def stream_complete(self, conversation: Conversation) -> Iterator[str]:
139
+ self.last_token_usage = None
140
+ self.last_reasoning_char_count = 0
141
+ self.last_model_response = None
142
+ observation = _ModelResponseAccumulator(model=self.model, stream=True)
143
+ try:
144
+ response = self._client.chat.completions.create(
145
+ **self._request(conversation, stream=True)
146
+ )
147
+ observation.observe_response(response)
148
+
149
+ for chunk in response:
150
+ observation.observe_chunk(chunk)
151
+ usage = _extract_token_usage(chunk)
152
+ if usage is not None:
153
+ self.last_token_usage = usage
154
+
155
+ choices = getattr(chunk, "choices", None) or []
156
+ if not choices:
157
+ continue
158
+
159
+ delta = getattr(choices[0], "delta", None)
160
+ if delta is None:
161
+ continue
162
+
163
+ reasoning = _extract_reasoning_field(delta)
164
+ self.last_reasoning_char_count += len(reasoning.content or "")
165
+ observation.observe_reasoning(reasoning)
166
+ content = getattr(delta, "content", None) or ""
167
+ observation.observe_content(content)
168
+
169
+ if content != "":
170
+ yield content
171
+ except Exception as error:
172
+ self.last_model_response = observation.finish(
173
+ usage=self.last_token_usage,
174
+ error=error,
175
+ )
176
+ raise
177
+ self.last_model_response = observation.finish(usage=self.last_token_usage)
178
+
179
+ def stream_with_tools(
180
+ self,
181
+ conversation: Conversation,
182
+ tools: list[dict[str, object]],
183
+ ) -> Iterator[AgentEvent]:
184
+ self.last_token_usage = None
185
+ self.last_reasoning_char_count = 0
186
+ self.last_model_response = None
187
+ observation = _ModelResponseAccumulator(model=self.model, stream=True)
188
+ tool_call_buffers: dict[int, _ToolCallBuffer] = {}
189
+ reasoning_state: _RawReasoningState = "absent"
190
+ try:
191
+ response = self._client.chat.completions.create(
192
+ **self._request(conversation, tools=tools, stream=True)
193
+ )
194
+ observation.observe_response(response)
195
+
196
+ for chunk in response:
197
+ observation.observe_chunk(chunk)
198
+ usage = _extract_token_usage(chunk)
199
+ if usage is not None:
200
+ self.last_token_usage = usage
201
+
202
+ choices = getattr(chunk, "choices", None) or []
203
+ if not choices:
204
+ continue
205
+
206
+ delta = getattr(choices[0], "delta", None)
207
+ if delta is None:
208
+ continue
209
+
210
+ reasoning = _extract_reasoning_field(delta)
211
+ reasoning_state = _merge_reasoning_state(
212
+ reasoning_state,
213
+ reasoning.state,
214
+ )
215
+ observation.observe_reasoning(reasoning)
216
+ if reasoning.content is not None:
217
+ self.last_reasoning_char_count += len(reasoning.content)
218
+ yield AgentEvent(
219
+ type="reasoning_delta",
220
+ reasoning_content=reasoning.content,
221
+ )
222
+
223
+ content = getattr(delta, "content", None) or ""
224
+ observation.observe_content(content)
225
+ if content != "":
226
+ yield AgentEvent(type="text_delta", content=content)
227
+
228
+ tool_call_deltas = getattr(delta, "tool_calls", None) or []
229
+ if tool_call_deltas:
230
+ observation.observe_meaningful_delta()
231
+ for tool_call_delta in tool_call_deltas:
232
+ _accumulate_tool_call_delta(tool_call_buffers, tool_call_delta)
233
+ except Exception as error:
234
+ self.last_model_response = observation.finish(
235
+ usage=self.last_token_usage,
236
+ error=error,
237
+ )
238
+ raise
239
+
240
+ parsed_tool_calls = _parse_tool_call_buffers(tool_call_buffers)
241
+ if isinstance(parsed_tool_calls, str):
242
+ observation.error_type = "ToolCallParseError"
243
+ self.last_model_response = observation.finish(usage=self.last_token_usage)
244
+ yield AgentEvent(type="error", error=parsed_tool_calls)
245
+ return
246
+
247
+ observation.tool_names = tuple(call.name for call in parsed_tool_calls)
248
+
249
+ if (
250
+ parsed_tool_calls
251
+ and self.thinking_enabled is True
252
+ and reasoning_state == "absent"
253
+ ):
254
+ observation.error_type = "ReasoningProtocolError"
255
+ self.last_model_response = observation.finish(usage=self.last_token_usage)
256
+ yield AgentEvent(
257
+ type="error",
258
+ error=(
259
+ "Thinking tool-call response omitted the "
260
+ "reasoning_content field."
261
+ ),
262
+ )
263
+ return
264
+
265
+ if parsed_tool_calls and reasoning_state != "absent":
266
+ yield AgentEvent(
267
+ type="reasoning_state",
268
+ reasoning_state=_message_reasoning_state(reasoning_state),
269
+ )
270
+
271
+ for tool_call in parsed_tool_calls:
272
+ yield AgentEvent(type="tool_call", tool_call=tool_call)
273
+ self.last_model_response = observation.finish(usage=self.last_token_usage)
274
+
275
+ def _request(
276
+ self,
277
+ conversation: Conversation,
278
+ *,
279
+ stream: bool,
280
+ tools: list[dict[str, object]] | None = None,
281
+ ) -> dict[str, object]:
282
+ request: dict[str, object] = {
283
+ "model": self.model,
284
+ "messages": conversation.to_model_messages(),
285
+ "stream": stream,
286
+ }
287
+ if _is_opencode_go_base_url(self.config.base_url) and self.session_id:
288
+ request["extra_headers"] = {
289
+ "User-Agent": "mycode-agent",
290
+ "x-opencode-session": self.session_id,
291
+ }
292
+ if tools:
293
+ request["tools"] = _format_openai_tools(tools)
294
+ if stream and self.config.stream_include_usage:
295
+ request["stream_options"] = {"include_usage": True}
296
+ if self.config.max_output_tokens is not None:
297
+ request["max_tokens"] = self.config.max_output_tokens
298
+ if self.thinking_enabled is not None:
299
+ request["extra_body"] = {
300
+ "thinking": {
301
+ "type": "enabled" if self.thinking_enabled else "disabled"
302
+ }
303
+ }
304
+ if self.thinking_enabled is True:
305
+ request["reasoning_effort"] = self.reasoning_effort
306
+ return request
307
+
308
+
309
+ @dataclass
310
+ class FakeLLMClient:
311
+ responses: list[str]
312
+ stream_chunk_size: int | None = None
313
+ tool_responses: list[AgentModelResponse] = field(default_factory=list)
314
+ token_usages: list[TokenUsage | None] = field(default_factory=list)
315
+ last_token_usage: TokenUsage | None = field(default=None, init=False)
316
+ last_reasoning_char_count: int = field(default=0, init=False)
317
+ last_model_response: dict[str, object] | None = field(default=None, init=False)
318
+
319
+ def complete(self, conversation: Conversation) -> Message:
320
+ self._consume_token_usage()
321
+ self.last_reasoning_char_count = 0
322
+ self.last_model_response = None
323
+ if not self.responses:
324
+ raise RuntimeError("FakeLLMClient has no responses left")
325
+
326
+ return Message(role="assistant", content=self.responses.pop(0))
327
+
328
+ def stream_complete(self, conversation: Conversation) -> Iterator[str]:
329
+ content = self.complete(conversation).content
330
+
331
+ if self.stream_chunk_size is None:
332
+ yield content
333
+ return
334
+
335
+ for start in range(0, len(content), self.stream_chunk_size):
336
+ yield content[start : start + self.stream_chunk_size]
337
+
338
+ def stream_with_tools(
339
+ self,
340
+ conversation: Conversation,
341
+ tools: list[dict[str, object]],
342
+ ) -> Iterator[AgentEvent]:
343
+ self._consume_token_usage()
344
+ if not self.tool_responses:
345
+ raise RuntimeError("FakeLLMClient has no tool responses left")
346
+
347
+ response = self.tool_responses.pop(0)
348
+ self.last_reasoning_char_count = len(response.reasoning_content or "")
349
+ usage = self.last_token_usage
350
+ self.last_model_response = {
351
+ "model": None,
352
+ "request_id": None,
353
+ "provider_request_id": None,
354
+ "finish_reason": None,
355
+ "stop_reason": response.stop_reason,
356
+ "content_chars": len(response.content),
357
+ "content_non_whitespace_chars": sum(
358
+ not character.isspace() for character in response.content
359
+ ),
360
+ "tool_call_count": len(response.tool_calls),
361
+ "tool_names": [call.name for call in response.tool_calls],
362
+ "reasoning_field_present": response.reasoning_state != "absent",
363
+ "reasoning_chars": len(response.reasoning_content or ""),
364
+ "prompt_tokens": None if usage is None else usage.prompt_tokens,
365
+ "completion_tokens": None if usage is None else usage.completion_tokens,
366
+ "total_tokens": None if usage is None else usage.total_tokens,
367
+ "latency_ms": None,
368
+ "first_token_latency_ms": None,
369
+ "stream_chunk_count": None,
370
+ "retry_count": None,
371
+ "error_type": None,
372
+ "http_status": None,
373
+ "empty_response": (
374
+ not response.content.strip() and not response.tool_calls
375
+ ),
376
+ }
377
+
378
+ if response.reasoning_content is not None:
379
+ chunks = (
380
+ [response.reasoning_content]
381
+ if self.stream_chunk_size is None
382
+ else [
383
+ response.reasoning_content[start : start + self.stream_chunk_size]
384
+ for start in range(
385
+ 0,
386
+ len(response.reasoning_content),
387
+ self.stream_chunk_size,
388
+ )
389
+ ]
390
+ )
391
+ for chunk in chunks:
392
+ yield AgentEvent(
393
+ type="reasoning_delta",
394
+ reasoning_content=chunk,
395
+ )
396
+
397
+ if response.tool_calls and response.reasoning_state != "absent":
398
+ yield AgentEvent(
399
+ type="reasoning_state",
400
+ reasoning_state=response.reasoning_state,
401
+ )
402
+
403
+ if response.content != "":
404
+ if self.stream_chunk_size is None:
405
+ yield AgentEvent(type="text_delta", content=response.content)
406
+ else:
407
+ for start in range(0, len(response.content), self.stream_chunk_size):
408
+ yield AgentEvent(
409
+ type="text_delta",
410
+ content=response.content[start : start + self.stream_chunk_size],
411
+ )
412
+
413
+ for tool_call in response.tool_calls:
414
+ yield AgentEvent(type="tool_call", tool_call=tool_call)
415
+
416
+ def _consume_token_usage(self) -> None:
417
+ self.last_token_usage = self.token_usages.pop(0) if self.token_usages else None
418
+
419
+
420
+ @dataclass
421
+ class _ModelResponseAccumulator:
422
+ model: str | None
423
+ stream: bool
424
+ started_at: float = field(default_factory=perf_counter)
425
+ request_id: str | None = None
426
+ provider_request_id: str | None = None
427
+ finish_reason: str | None = None
428
+ stop_reason: str | None = None
429
+ content_chars: int = 0
430
+ content_non_whitespace_chars: int = 0
431
+ reasoning_field_present: bool = False
432
+ reasoning_chars: int = 0
433
+ stream_chunk_count: int = 0
434
+ first_token_at: float | None = None
435
+ tool_names: tuple[str, ...] = ()
436
+ error_type: str | None = None
437
+
438
+ def observe_response(self, response: Any) -> None:
439
+ self.request_id = self.request_id or _optional_string(
440
+ _value(response, "id")
441
+ )
442
+ self.provider_request_id = self.provider_request_id or _optional_string(
443
+ _value(response, "_request_id")
444
+ )
445
+ choices = _value(response, "choices") or []
446
+ if choices:
447
+ self._observe_choice(choices[0])
448
+
449
+ def observe_chunk(self, chunk: Any) -> None:
450
+ self.stream_chunk_count += 1
451
+ self.observe_response(chunk)
452
+
453
+ def observe_content(self, content: str) -> None:
454
+ self.content_chars += len(content)
455
+ self.content_non_whitespace_chars += sum(
456
+ not character.isspace() for character in content
457
+ )
458
+ if content:
459
+ self.observe_meaningful_delta()
460
+
461
+ def observe_reasoning(self, reasoning: "_ReasoningField") -> None:
462
+ if reasoning.state != "absent":
463
+ self.reasoning_field_present = True
464
+ if reasoning.content is not None:
465
+ self.reasoning_chars += len(reasoning.content)
466
+ if reasoning.content:
467
+ self.observe_meaningful_delta()
468
+
469
+ def observe_meaningful_delta(self) -> None:
470
+ if self.first_token_at is None:
471
+ self.first_token_at = perf_counter()
472
+
473
+ def finish(
474
+ self,
475
+ *,
476
+ usage: TokenUsage | None = None,
477
+ error: Exception | None = None,
478
+ ) -> dict[str, object]:
479
+ finished_at = perf_counter()
480
+ provider_diagnostic = (
481
+ None if error is None else extract_provider_diagnostic(error)
482
+ )
483
+ if error is not None:
484
+ self.error_type = type(error).__name__
485
+ return {
486
+ "model": self.model,
487
+ "request_id": self.request_id,
488
+ "provider_request_id": (
489
+ self.provider_request_id
490
+ if provider_diagnostic is None
491
+ else self.provider_request_id or provider_diagnostic.request_id
492
+ ),
493
+ "finish_reason": self.finish_reason,
494
+ "stop_reason": self.stop_reason,
495
+ "content_chars": self.content_chars,
496
+ "content_non_whitespace_chars": self.content_non_whitespace_chars,
497
+ "tool_call_count": len(self.tool_names),
498
+ "tool_names": list(self.tool_names),
499
+ "reasoning_field_present": self.reasoning_field_present,
500
+ "reasoning_chars": self.reasoning_chars,
501
+ "prompt_tokens": None if usage is None else usage.prompt_tokens,
502
+ "completion_tokens": None if usage is None else usage.completion_tokens,
503
+ "total_tokens": None if usage is None else usage.total_tokens,
504
+ "latency_ms": round((finished_at - self.started_at) * 1000),
505
+ "first_token_latency_ms": (
506
+ None
507
+ if self.first_token_at is None
508
+ else round((self.first_token_at - self.started_at) * 1000)
509
+ ),
510
+ "stream_chunk_count": self.stream_chunk_count if self.stream else None,
511
+ "retry_count": _retry_count(error),
512
+ "error_type": self.error_type,
513
+ "http_status": _http_status(error),
514
+ "provider_error_code": (
515
+ None if provider_diagnostic is None else provider_diagnostic.code
516
+ ),
517
+ "provider_error_type": (
518
+ None if provider_diagnostic is None else provider_diagnostic.error_type
519
+ ),
520
+ "provider_error_message": (
521
+ None if provider_diagnostic is None else provider_diagnostic.message
522
+ ),
523
+ "empty_response": (
524
+ self.content_non_whitespace_chars == 0 and not self.tool_names
525
+ ),
526
+ }
527
+
528
+ def _observe_choice(self, choice: Any) -> None:
529
+ finish_reason = _optional_string(_value(choice, "finish_reason"))
530
+ stop_reason = _optional_string(_value(choice, "stop_reason"))
531
+ if finish_reason is not None:
532
+ self.finish_reason = finish_reason
533
+ if stop_reason is not None:
534
+ self.stop_reason = stop_reason
535
+
536
+
537
+ @dataclass
538
+ class _ToolCallBuffer:
539
+ id: str = ""
540
+ name: str = ""
541
+ arguments: str = ""
542
+
543
+
544
+ _RawReasoningState = Literal["absent", "null", "empty", "nonempty"]
545
+
546
+
547
+ @dataclass(frozen=True)
548
+ class _ReasoningField:
549
+ state: _RawReasoningState
550
+ content: str | None = None
551
+
552
+ @property
553
+ def message_state(self) -> ReasoningState:
554
+ return _message_reasoning_state(self.state)
555
+
556
+
557
+ def _format_openai_tools(tools: list[dict[str, object]]) -> list[dict[str, object]]:
558
+ return [{"type": "function", "function": dict(tool)} for tool in tools]
559
+
560
+
561
+ def _value(value: Any, name: str) -> Any:
562
+ if isinstance(value, Mapping):
563
+ return value.get(name)
564
+ direct = getattr(value, name, None)
565
+ if direct is not None:
566
+ return direct
567
+ model_extra = getattr(value, "model_extra", None)
568
+ return model_extra.get(name) if isinstance(model_extra, Mapping) else None
569
+
570
+
571
+ def _optional_string(value: Any) -> str | None:
572
+ return value if isinstance(value, str) and value != "" else None
573
+
574
+
575
+ def _http_status(error: Exception | None) -> int | None:
576
+ if error is None:
577
+ return None
578
+ status = getattr(error, "status_code", None)
579
+ if not isinstance(status, int):
580
+ response = getattr(error, "response", None)
581
+ status = getattr(response, "status_code", None)
582
+ return status if isinstance(status, int) else None
583
+
584
+
585
+ def _retry_count(error: Exception | None) -> int | None:
586
+ if error is None:
587
+ return None
588
+ request = getattr(error, "request", None)
589
+ headers = getattr(request, "headers", None)
590
+ if not isinstance(headers, Mapping):
591
+ return None
592
+ value = headers.get("x-stainless-retry-count")
593
+ try:
594
+ count = int(value)
595
+ except (TypeError, ValueError):
596
+ return None
597
+ return count if count >= 0 else None
598
+
599
+
600
+ def _first_choice(response: Any) -> Any | None:
601
+ choices = getattr(response, "choices", None) or []
602
+ if not choices:
603
+ return None
604
+
605
+ return choices[0]
606
+
607
+
608
+ def _extract_reasoning_field(value: Any) -> _ReasoningField:
609
+ if value is None:
610
+ return _ReasoningField(state="absent")
611
+
612
+ found = False
613
+ reasoning_content: object = None
614
+ if isinstance(value, Mapping):
615
+ if "reasoning_content" in value:
616
+ found = True
617
+ reasoning_content = value["reasoning_content"]
618
+ else:
619
+ model_extra = getattr(value, "model_extra", None)
620
+ if isinstance(model_extra, Mapping) and "reasoning_content" in model_extra:
621
+ found = True
622
+ reasoning_content = model_extra["reasoning_content"]
623
+ else:
624
+ model_fields_set = getattr(value, "model_fields_set", None)
625
+ if (
626
+ isinstance(model_fields_set, (set, frozenset))
627
+ and "reasoning_content" in model_fields_set
628
+ ):
629
+ found = True
630
+ reasoning_content = getattr(value, "reasoning_content", None)
631
+ else:
632
+ instance_values = getattr(value, "__dict__", None)
633
+ if (
634
+ isinstance(instance_values, Mapping)
635
+ and "reasoning_content" in instance_values
636
+ ):
637
+ found = True
638
+ reasoning_content = instance_values["reasoning_content"]
639
+
640
+ if not found:
641
+ return _ReasoningField(state="absent")
642
+ if reasoning_content is None:
643
+ return _ReasoningField(state="null")
644
+ if reasoning_content == "":
645
+ return _ReasoningField(state="empty")
646
+ if not isinstance(reasoning_content, str):
647
+ raise TypeError("Model reasoning_content must be a string when provided.")
648
+ return _ReasoningField(state="nonempty", content=reasoning_content)
649
+
650
+
651
+ def _merge_reasoning_state(
652
+ current: _RawReasoningState,
653
+ incoming: _RawReasoningState,
654
+ ) -> _RawReasoningState:
655
+ priority = {"absent": 0, "null": 1, "empty": 2, "nonempty": 3}
656
+ return incoming if priority[incoming] > priority[current] else current
657
+
658
+
659
+ def _message_reasoning_state(state: _RawReasoningState) -> ReasoningState:
660
+ if state == "absent":
661
+ return "absent"
662
+ if state == "nonempty":
663
+ return "present_nonempty"
664
+ return "present_empty"
665
+
666
+
667
+ def _extract_token_usage(response: Any) -> TokenUsage | None:
668
+ usage = getattr(response, "usage", None)
669
+ if usage is None and isinstance(response, Mapping):
670
+ usage = response.get("usage")
671
+ if usage is None:
672
+ return None
673
+
674
+ prompt_tokens = _usage_value(usage, "prompt_tokens")
675
+ completion_tokens = _usage_value(usage, "completion_tokens")
676
+ total_tokens = _usage_value(usage, "total_tokens")
677
+ if prompt_tokens is None:
678
+ return None
679
+
680
+ completion_tokens = 0 if completion_tokens is None else completion_tokens
681
+ total_tokens = (
682
+ prompt_tokens + completion_tokens if total_tokens is None else total_tokens
683
+ )
684
+ return TokenUsage(
685
+ prompt_tokens=prompt_tokens,
686
+ completion_tokens=completion_tokens,
687
+ total_tokens=total_tokens,
688
+ )
689
+
690
+
691
+ def _usage_value(usage: Any, name: str) -> int | None:
692
+ value = usage.get(name) if isinstance(usage, Mapping) else getattr(usage, name, None)
693
+ return value if isinstance(value, int) and value >= 0 else None
694
+
695
+
696
+ def _parse_tool_calls(tool_calls: list[Any]) -> list[AgentToolCall] | str:
697
+ parsed_tool_calls: list[AgentToolCall] = []
698
+
699
+ for tool_call in tool_calls:
700
+ function = getattr(tool_call, "function", None)
701
+ parsed_tool_call = _parse_tool_call(
702
+ id=getattr(tool_call, "id", ""),
703
+ name=getattr(function, "name", ""),
704
+ arguments=getattr(function, "arguments", None) or "{}",
705
+ )
706
+ if isinstance(parsed_tool_call, str):
707
+ return parsed_tool_call
708
+
709
+ parsed_tool_calls.append(parsed_tool_call)
710
+
711
+ return parsed_tool_calls
712
+
713
+
714
+ def _parse_tool_call_buffers(
715
+ tool_call_buffers: dict[int, _ToolCallBuffer],
716
+ ) -> list[AgentToolCall] | str:
717
+ parsed_tool_calls: list[AgentToolCall] = []
718
+
719
+ for index in sorted(tool_call_buffers):
720
+ buffer = tool_call_buffers[index]
721
+ parsed_tool_call = _parse_tool_call(
722
+ id=buffer.id,
723
+ name=buffer.name,
724
+ arguments=buffer.arguments or "{}",
725
+ )
726
+ if isinstance(parsed_tool_call, str):
727
+ return parsed_tool_call
728
+
729
+ parsed_tool_calls.append(parsed_tool_call)
730
+
731
+ return parsed_tool_calls
732
+
733
+
734
+ def _parse_tool_call(*, id: str, name: str, arguments: str) -> AgentToolCall | str:
735
+ try:
736
+ parsed_arguments = json.loads(arguments)
737
+ except json.JSONDecodeError as error:
738
+ return f"Invalid tool call arguments for {name}: {error.msg}"
739
+
740
+ if not isinstance(parsed_arguments, dict):
741
+ return f"Invalid tool call arguments for {name}: expected a JSON object"
742
+
743
+ return AgentToolCall(
744
+ id=id,
745
+ name=name,
746
+ arguments=parsed_arguments,
747
+ )
748
+
749
+
750
+ def _accumulate_tool_call_delta(
751
+ tool_call_buffers: dict[int, _ToolCallBuffer],
752
+ tool_call_delta: Any,
753
+ ) -> None:
754
+ index = getattr(tool_call_delta, "index", len(tool_call_buffers))
755
+ buffer = tool_call_buffers.setdefault(index, _ToolCallBuffer())
756
+
757
+ tool_call_id = getattr(tool_call_delta, "id", None)
758
+ if tool_call_id:
759
+ buffer.id = tool_call_id
760
+
761
+ function = getattr(tool_call_delta, "function", None)
762
+ if function is None:
763
+ return
764
+
765
+ function_name = getattr(function, "name", None)
766
+ if function_name:
767
+ buffer.name += function_name
768
+
769
+ function_arguments = getattr(function, "arguments", None)
770
+ if function_arguments:
771
+ buffer.arguments += function_arguments