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.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|