steerable-agent-runtime 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.
@@ -0,0 +1,34 @@
1
+ """Steerable agent runtime — Tier 3 adapter package."""
2
+
3
+ from .errors import (
4
+ BudgetExhaustedError,
5
+ PolicyDeniedError,
6
+ RuntimeError as SteerableRuntimeError,
7
+ StorageError,
8
+ ToolDispatchError,
9
+ TransportError,
10
+ )
11
+ from .llm import LLMMessage, LLMProvider, LLMStreamChunk, LLMUsage
12
+ from .storage import StorageAdapter
13
+ from .tools import RegisteredTool, ToolRouter, tool
14
+ from .transport import TransportAdapter
15
+
16
+ __all__ = [
17
+ "BudgetExhaustedError",
18
+ "PolicyDeniedError",
19
+ "SteerableRuntimeError",
20
+ "StorageError",
21
+ "ToolDispatchError",
22
+ "TransportError",
23
+ "LLMMessage",
24
+ "LLMProvider",
25
+ "LLMStreamChunk",
26
+ "LLMUsage",
27
+ "RegisteredTool",
28
+ "ToolRouter",
29
+ "tool",
30
+ "StorageAdapter",
31
+ "TransportAdapter",
32
+ ]
33
+
34
+ __version__ = "0.1.0"
@@ -0,0 +1,34 @@
1
+ """Common runtime exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class RuntimeError(Exception): # noqa: A001 - intentional override of builtin
9
+ """Base class for all steerable-agent-runtime errors."""
10
+
11
+ def __init__(self, message: str, *, data: Any | None = None) -> None:
12
+ super().__init__(message)
13
+ self.message = message
14
+ self.data = data
15
+
16
+
17
+ class StorageError(RuntimeError):
18
+ """Persistence layer failure."""
19
+
20
+
21
+ class ToolDispatchError(RuntimeError):
22
+ """Tool router could not satisfy a ToolCall."""
23
+
24
+
25
+ class PolicyDeniedError(ToolDispatchError):
26
+ """A tool call was denied by policy (e.g. destructive without consent)."""
27
+
28
+
29
+ class BudgetExhaustedError(RuntimeError):
30
+ """The harness budget would be violated by the next operation."""
31
+
32
+
33
+ class TransportError(RuntimeError):
34
+ """Wire-level failure (SSE close, JSON-RPC parse error, etc.)."""
@@ -0,0 +1,104 @@
1
+ """LLMProvider interface and reference implementations.
2
+
3
+ The runtime intentionally keeps LLMProvider small. Higher-level concerns
4
+ (retry, budget, multi-step orchestration) live in `steerable_agent_harness`.
5
+
6
+ The interface speaks the protocol-level types (`ToolCall`, `ToolResult`,
7
+ `ChatMessage`) but accepts a slightly looser `LLMMessage` shape for inputs so
8
+ callers do not have to materialise full ChatMessage records when constructing
9
+ prompts.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import AsyncIterator, Iterable, Sequence
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Literal, Protocol, runtime_checkable
17
+
18
+ from steerable_agent_protocol.generated import ToolCall
19
+
20
+ LLMRole = Literal["system", "user", "assistant", "tool"]
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class LLMMessage:
25
+ """A single chat-message item passed to an LLMProvider."""
26
+
27
+ role: LLMRole
28
+ content: str
29
+ name: str | None = None
30
+ tool_call_id: str | None = None
31
+ tool_calls: list[ToolCall] | None = None
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class LLMUsage:
36
+ prompt_tokens: int = 0
37
+ completion_tokens: int = 0
38
+ total_tokens: int = 0
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class LLMStreamChunk:
43
+ """Provider-agnostic stream chunk."""
44
+
45
+ content_delta: str | None = None
46
+ reasoning_delta: str | None = None
47
+ tool_call_delta: ToolCall | None = None
48
+ finish_reason: str | None = None
49
+ usage: LLMUsage | None = None
50
+ raw: Any | None = None
51
+
52
+
53
+ @runtime_checkable
54
+ class LLMProvider(Protocol):
55
+ """Async chat-completion adapter.
56
+
57
+ Implementations must support both `complete()` (one-shot) and `stream()`
58
+ (incremental). Both flavors must:
59
+ * Accept a sequence of LLMMessage records.
60
+ * Optionally accept a list of tool descriptors (already in OpenAI
61
+ function-calling shape; providers that need a different shape transform
62
+ internally).
63
+ * Return / yield content alongside any tool calls the model proposed.
64
+ * Surface usage tokens whenever the upstream provider reports them.
65
+ """
66
+
67
+ name: str
68
+ model: str
69
+
70
+ async def complete(
71
+ self,
72
+ messages: Sequence[LLMMessage],
73
+ *,
74
+ tools: Iterable[dict[str, Any]] | None = None,
75
+ temperature: float | None = None,
76
+ max_tokens: int | None = None,
77
+ **kwargs: Any,
78
+ ) -> tuple[LLMMessage, LLMUsage]:
79
+ ...
80
+
81
+ def stream(
82
+ self,
83
+ messages: Sequence[LLMMessage],
84
+ *,
85
+ tools: Iterable[dict[str, Any]] | None = None,
86
+ temperature: float | None = None,
87
+ max_tokens: int | None = None,
88
+ **kwargs: Any,
89
+ ) -> AsyncIterator[LLMStreamChunk]:
90
+ ...
91
+
92
+
93
+ from .openai_compat import OpenAICompatProvider # noqa: E402
94
+ from .anthropic_native import AnthropicProvider # noqa: E402
95
+
96
+ __all__ = [
97
+ "LLMMessage",
98
+ "LLMProvider",
99
+ "LLMRole",
100
+ "LLMStreamChunk",
101
+ "LLMUsage",
102
+ "OpenAICompatProvider",
103
+ "AnthropicProvider",
104
+ ]
@@ -0,0 +1,261 @@
1
+ """Anthropic native-protocol provider.
2
+
3
+ Why a separate provider instead of always using OpenAI-compatible endpoints?
4
+ Some hosted gateways aggressively buffer the OpenAI-compatible SSE stream and
5
+ break true byte-level streaming. Going directly to the Anthropic native
6
+ ``/v1/messages`` endpoint (or its vendor mirror) produces sub-second first-byte
7
+ latency under those gateways.
8
+
9
+ This implementation is intentionally a thin shim around the official
10
+ ``anthropic`` Python SDK so we inherit auth, retries, and SSE parsing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from collections.abc import AsyncIterator, Iterable, Sequence
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ from steerable_agent_protocol.generated import ToolCall
21
+
22
+ from . import LLMMessage, LLMStreamChunk, LLMUsage
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class AnthropicProvider:
27
+ name: str
28
+ model: str
29
+ api_key: str | None = None
30
+ base_url: str | None = None
31
+ default_temperature: float | None = None
32
+ default_max_tokens: int = 1024
33
+
34
+ async def complete(
35
+ self,
36
+ messages: Sequence[LLMMessage],
37
+ *,
38
+ tools: Iterable[dict[str, Any]] | None = None,
39
+ temperature: float | None = None,
40
+ max_tokens: int | None = None,
41
+ **kwargs: Any,
42
+ ) -> tuple[LLMMessage, LLMUsage]:
43
+ client = self._client()
44
+ body = self._build_body(
45
+ messages=messages,
46
+ tools=tools,
47
+ temperature=temperature,
48
+ max_tokens=max_tokens,
49
+ extra=kwargs,
50
+ )
51
+ message = await client.messages.create(**body)
52
+ text_chunks: list[str] = []
53
+ tool_calls: list[ToolCall] = []
54
+ for block in message.content or []:
55
+ block_type = getattr(block, "type", None)
56
+ if block_type == "text":
57
+ text_chunks.append(getattr(block, "text", "") or "")
58
+ elif block_type == "tool_use":
59
+ tool_calls.append(
60
+ ToolCall(
61
+ id=getattr(block, "id", "") or "",
62
+ name=getattr(block, "name", "") or "",
63
+ arguments=getattr(block, "input", {}) or {},
64
+ )
65
+ )
66
+ usage = getattr(message, "usage", None)
67
+ out_usage = LLMUsage(
68
+ prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0),
69
+ completion_tokens=int(getattr(usage, "output_tokens", 0) or 0),
70
+ total_tokens=int(
71
+ (getattr(usage, "input_tokens", 0) or 0)
72
+ + (getattr(usage, "output_tokens", 0) or 0)
73
+ ),
74
+ )
75
+ return (
76
+ LLMMessage(
77
+ role="assistant",
78
+ content="".join(text_chunks),
79
+ tool_calls=tool_calls or None,
80
+ ),
81
+ out_usage,
82
+ )
83
+
84
+ async def stream( # type: ignore[override]
85
+ self,
86
+ messages: Sequence[LLMMessage],
87
+ *,
88
+ tools: Iterable[dict[str, Any]] | None = None,
89
+ temperature: float | None = None,
90
+ max_tokens: int | None = None,
91
+ **kwargs: Any,
92
+ ) -> AsyncIterator[LLMStreamChunk]:
93
+ client = self._client()
94
+ body = self._build_body(
95
+ messages=messages,
96
+ tools=tools,
97
+ temperature=temperature,
98
+ max_tokens=max_tokens,
99
+ extra=kwargs,
100
+ )
101
+ async with client.messages.stream(**body) as stream:
102
+ async for event in stream:
103
+ chunk = _parse_anthropic_event(event)
104
+ if chunk is not None:
105
+ yield chunk
106
+ final = await stream.get_final_message()
107
+ usage = getattr(final, "usage", None)
108
+ if usage is not None:
109
+ yield LLMStreamChunk(
110
+ usage=LLMUsage(
111
+ prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0),
112
+ completion_tokens=int(getattr(usage, "output_tokens", 0) or 0),
113
+ total_tokens=int(
114
+ (getattr(usage, "input_tokens", 0) or 0)
115
+ + (getattr(usage, "output_tokens", 0) or 0)
116
+ ),
117
+ ),
118
+ finish_reason="stop",
119
+ )
120
+
121
+ # ------------------------------------------------------------------
122
+ # Helpers
123
+ # ------------------------------------------------------------------
124
+
125
+ def _client(self) -> Any:
126
+ from anthropic import AsyncAnthropic # local import keeps optional dep optional
127
+
128
+ kwargs: dict[str, Any] = {}
129
+ if self.api_key:
130
+ kwargs["api_key"] = self.api_key
131
+ if self.base_url:
132
+ kwargs["base_url"] = self.base_url
133
+ return AsyncAnthropic(**kwargs)
134
+
135
+ def _build_body(
136
+ self,
137
+ *,
138
+ messages: Sequence[LLMMessage],
139
+ tools: Iterable[dict[str, Any]] | None,
140
+ temperature: float | None,
141
+ max_tokens: int | None,
142
+ extra: dict[str, Any],
143
+ ) -> dict[str, Any]:
144
+ system_text, formatted = _split_system_and_messages(messages)
145
+ body: dict[str, Any] = {
146
+ "model": self.model,
147
+ "messages": formatted,
148
+ "max_tokens": max_tokens or self.default_max_tokens,
149
+ }
150
+ if system_text:
151
+ body["system"] = system_text
152
+ eff_temperature = temperature if temperature is not None else self.default_temperature
153
+ if eff_temperature is not None:
154
+ body["temperature"] = eff_temperature
155
+ if tools:
156
+ anth_tools = [_openai_tool_to_anthropic(t) for t in tools if isinstance(t, dict)]
157
+ if anth_tools:
158
+ body["tools"] = anth_tools
159
+ body.update(extra)
160
+ return body
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Helpers (pure functions kept module-level for unit-testability)
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ def _split_system_and_messages(
169
+ messages: Sequence[LLMMessage],
170
+ ) -> tuple[str | None, list[dict[str, Any]]]:
171
+ system_chunks: list[str] = []
172
+ out: list[dict[str, Any]] = []
173
+ for message in messages:
174
+ if message.role == "system":
175
+ if message.content:
176
+ system_chunks.append(message.content)
177
+ continue
178
+ if message.role == "tool":
179
+ out.append(
180
+ {
181
+ "role": "user",
182
+ "content": [
183
+ {
184
+ "type": "tool_result",
185
+ "tool_use_id": message.tool_call_id or "",
186
+ "content": message.content,
187
+ }
188
+ ],
189
+ }
190
+ )
191
+ continue
192
+ if message.tool_calls:
193
+ blocks: list[dict[str, Any]] = []
194
+ if message.content:
195
+ blocks.append({"type": "text", "text": message.content})
196
+ for tc in message.tool_calls:
197
+ blocks.append(
198
+ {
199
+ "type": "tool_use",
200
+ "id": tc.id or "",
201
+ "name": tc.name,
202
+ "input": tc.arguments or {},
203
+ }
204
+ )
205
+ out.append({"role": "assistant", "content": blocks})
206
+ continue
207
+ out.append({"role": message.role, "content": message.content})
208
+ system = "\n\n".join(system_chunks) if system_chunks else None
209
+ return system, out
210
+
211
+
212
+ def _openai_tool_to_anthropic(tool: dict[str, Any]) -> dict[str, Any]:
213
+ if "name" in tool and "input_schema" in tool:
214
+ return tool # already anthropic-shaped
215
+ function = tool.get("function") or {}
216
+ return {
217
+ "name": function.get("name") or tool.get("name"),
218
+ "description": function.get("description") or tool.get("description") or "",
219
+ "input_schema": function.get("parameters") or {"type": "object", "properties": {}},
220
+ }
221
+
222
+
223
+ def _parse_anthropic_event(event: Any) -> LLMStreamChunk | None:
224
+ event_type = getattr(event, "type", None)
225
+ if event_type == "content_block_delta":
226
+ delta = getattr(event, "delta", None)
227
+ delta_type = getattr(delta, "type", None)
228
+ if delta_type == "text_delta":
229
+ return LLMStreamChunk(content_delta=getattr(delta, "text", "") or None, raw=event)
230
+ if delta_type == "thinking_delta":
231
+ return LLMStreamChunk(
232
+ reasoning_delta=getattr(delta, "thinking", "") or None,
233
+ raw=event,
234
+ )
235
+ if delta_type == "input_json_delta":
236
+ partial = getattr(delta, "partial_json", "") or ""
237
+ try:
238
+ args = json.loads(partial) if partial else {}
239
+ except json.JSONDecodeError:
240
+ args = {}
241
+ return LLMStreamChunk(
242
+ tool_call_delta=ToolCall(id="", name="", arguments=args),
243
+ raw=event,
244
+ )
245
+ if event_type == "content_block_start":
246
+ block = getattr(event, "content_block", None)
247
+ if getattr(block, "type", None) == "tool_use":
248
+ return LLMStreamChunk(
249
+ tool_call_delta=ToolCall(
250
+ id=getattr(block, "id", "") or "",
251
+ name=getattr(block, "name", "") or "",
252
+ arguments={},
253
+ ),
254
+ raw=event,
255
+ )
256
+ if event_type == "message_delta":
257
+ delta = getattr(event, "delta", None)
258
+ finish_reason = getattr(delta, "stop_reason", None)
259
+ if finish_reason:
260
+ return LLMStreamChunk(finish_reason=str(finish_reason), raw=event)
261
+ return None
@@ -0,0 +1,256 @@
1
+ """OpenAI-compatible chat-completions provider.
2
+
3
+ Covers the OpenAI API itself plus any vendor that exposes a `/chat/completions`
4
+ endpoint matching the OpenAI v1 schema:
5
+
6
+ * Ollama (`http://localhost:11434/v1`)
7
+ * vLLM
8
+ * SiliconFlow
9
+ * DeepSeek
10
+ * 万界 wanjiedata (OpenAI-compatible path)
11
+
12
+ The implementation is dependency-light: it uses `httpx` directly so end users do
13
+ not need to install the heavyweight `openai` SDK.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from collections.abc import AsyncIterator, Iterable, Sequence
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ from steerable_agent_protocol.generated import ToolCall
24
+
25
+ from . import LLMMessage, LLMStreamChunk, LLMUsage
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class OpenAICompatProvider:
30
+ """OpenAI-compatible chat-completions provider."""
31
+
32
+ name: str
33
+ model: str
34
+ base_url: str
35
+ api_key: str | None = None
36
+ default_temperature: float | None = None
37
+
38
+ def __post_init__(self) -> None:
39
+ if not self.base_url:
40
+ raise ValueError("OpenAICompatProvider requires base_url")
41
+
42
+ # ------------------------------------------------------------------
43
+ # Public API
44
+ # ------------------------------------------------------------------
45
+
46
+ async def complete(
47
+ self,
48
+ messages: Sequence[LLMMessage],
49
+ *,
50
+ tools: Iterable[dict[str, Any]] | None = None,
51
+ temperature: float | None = None,
52
+ max_tokens: int | None = None,
53
+ **kwargs: Any,
54
+ ) -> tuple[LLMMessage, LLMUsage]:
55
+ import httpx # local import — keeps the runtime importable without httpx
56
+
57
+ body = self._build_body(
58
+ messages=messages,
59
+ tools=tools,
60
+ temperature=temperature,
61
+ max_tokens=max_tokens,
62
+ stream=False,
63
+ extra=kwargs,
64
+ )
65
+ async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
66
+ response = await client.post(
67
+ f"{self.base_url.rstrip('/')}/chat/completions",
68
+ headers=self._headers(),
69
+ json=body,
70
+ )
71
+ response.raise_for_status()
72
+ payload = response.json()
73
+
74
+ choice = payload["choices"][0]
75
+ message = choice["message"]
76
+ usage = payload.get("usage") or {}
77
+ out = LLMMessage(
78
+ role="assistant",
79
+ content=message.get("content") or "",
80
+ tool_calls=_decode_tool_calls(message.get("tool_calls")),
81
+ )
82
+ return out, LLMUsage(
83
+ prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
84
+ completion_tokens=int(usage.get("completion_tokens", 0) or 0),
85
+ total_tokens=int(usage.get("total_tokens", 0) or 0),
86
+ )
87
+
88
+ async def stream( # type: ignore[override]
89
+ self,
90
+ messages: Sequence[LLMMessage],
91
+ *,
92
+ tools: Iterable[dict[str, Any]] | None = None,
93
+ temperature: float | None = None,
94
+ max_tokens: int | None = None,
95
+ **kwargs: Any,
96
+ ) -> AsyncIterator[LLMStreamChunk]:
97
+ import httpx
98
+
99
+ body = self._build_body(
100
+ messages=messages,
101
+ tools=tools,
102
+ temperature=temperature,
103
+ max_tokens=max_tokens,
104
+ stream=True,
105
+ extra=kwargs,
106
+ )
107
+ async with httpx.AsyncClient(timeout=httpx.Timeout(None)) as client:
108
+ async with client.stream(
109
+ "POST",
110
+ f"{self.base_url.rstrip('/')}/chat/completions",
111
+ headers=self._headers(),
112
+ json=body,
113
+ ) as response:
114
+ response.raise_for_status()
115
+ async for line in response.aiter_lines():
116
+ if not line:
117
+ continue
118
+ if line.startswith(":"): # comment/keepalive
119
+ continue
120
+ if line.startswith("data:"):
121
+ line = line[5:].strip()
122
+ if line == "[DONE]":
123
+ return
124
+ try:
125
+ chunk = json.loads(line)
126
+ except json.JSONDecodeError:
127
+ continue
128
+ parsed = _parse_stream_chunk(chunk)
129
+ if parsed is not None:
130
+ yield parsed
131
+
132
+ # ------------------------------------------------------------------
133
+ # Helpers
134
+ # ------------------------------------------------------------------
135
+
136
+ def _headers(self) -> dict[str, str]:
137
+ headers = {"Content-Type": "application/json"}
138
+ if self.api_key:
139
+ headers["Authorization"] = f"Bearer {self.api_key}"
140
+ return headers
141
+
142
+ def _build_body(
143
+ self,
144
+ *,
145
+ messages: Sequence[LLMMessage],
146
+ tools: Iterable[dict[str, Any]] | None,
147
+ temperature: float | None,
148
+ max_tokens: int | None,
149
+ stream: bool,
150
+ extra: dict[str, Any],
151
+ ) -> dict[str, Any]:
152
+ body: dict[str, Any] = {
153
+ "model": self.model,
154
+ "messages": [_encode_message(m) for m in messages],
155
+ "stream": stream,
156
+ }
157
+ eff_temperature = temperature if temperature is not None else self.default_temperature
158
+ if eff_temperature is not None:
159
+ body["temperature"] = eff_temperature
160
+ if max_tokens is not None:
161
+ body["max_tokens"] = max_tokens
162
+ if tools is not None:
163
+ tools_list = list(tools)
164
+ if tools_list:
165
+ body["tools"] = tools_list
166
+ body.update(extra)
167
+ return body
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Wire-format helpers (kept pure functions for unit-testability)
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ def _encode_message(message: LLMMessage) -> dict[str, Any]:
176
+ out: dict[str, Any] = {"role": message.role, "content": message.content}
177
+ if message.name is not None:
178
+ out["name"] = message.name
179
+ if message.tool_call_id is not None:
180
+ out["tool_call_id"] = message.tool_call_id
181
+ if message.tool_calls:
182
+ out["tool_calls"] = [
183
+ {
184
+ "id": tc.id,
185
+ "type": "function",
186
+ "function": {
187
+ "name": tc.name,
188
+ "arguments": json.dumps(tc.arguments),
189
+ },
190
+ }
191
+ for tc in message.tool_calls
192
+ ]
193
+ return out
194
+
195
+
196
+ def _decode_tool_calls(value: Any) -> list[ToolCall] | None:
197
+ if not value:
198
+ return None
199
+ out: list[ToolCall] = []
200
+ for item in value:
201
+ function = item.get("function") or {}
202
+ try:
203
+ arguments = json.loads(function.get("arguments") or "{}")
204
+ except (TypeError, json.JSONDecodeError):
205
+ arguments = {}
206
+ out.append(
207
+ ToolCall(
208
+ id=item.get("id") or "",
209
+ name=function.get("name") or "",
210
+ arguments=arguments,
211
+ )
212
+ )
213
+ return out or None
214
+
215
+
216
+ def _parse_stream_chunk(chunk: dict[str, Any]) -> LLMStreamChunk | None:
217
+ choices = chunk.get("choices") or []
218
+ if not choices:
219
+ usage = chunk.get("usage")
220
+ if usage:
221
+ return LLMStreamChunk(
222
+ usage=LLMUsage(
223
+ prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
224
+ completion_tokens=int(usage.get("completion_tokens", 0) or 0),
225
+ total_tokens=int(usage.get("total_tokens", 0) or 0),
226
+ ),
227
+ raw=chunk,
228
+ )
229
+ return None
230
+
231
+ choice = choices[0]
232
+ delta = choice.get("delta") or {}
233
+ finish_reason = choice.get("finish_reason")
234
+ content = delta.get("content")
235
+ reasoning = delta.get("reasoning_content")
236
+ tool_call_delta: ToolCall | None = None
237
+ raw_tool_calls = delta.get("tool_calls")
238
+ if raw_tool_calls:
239
+ first = raw_tool_calls[0]
240
+ function = first.get("function") or {}
241
+ try:
242
+ arguments = json.loads(function.get("arguments") or "{}")
243
+ except (TypeError, json.JSONDecodeError):
244
+ arguments = {}
245
+ tool_call_delta = ToolCall(
246
+ id=first.get("id") or "",
247
+ name=function.get("name") or "",
248
+ arguments=arguments,
249
+ )
250
+ return LLMStreamChunk(
251
+ content_delta=content,
252
+ reasoning_delta=reasoning,
253
+ tool_call_delta=tool_call_delta,
254
+ finish_reason=finish_reason,
255
+ raw=chunk,
256
+ )