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.
- steerable_agent_runtime/__init__.py +34 -0
- steerable_agent_runtime/errors.py +34 -0
- steerable_agent_runtime/llm/__init__.py +104 -0
- steerable_agent_runtime/llm/anthropic_native.py +261 -0
- steerable_agent_runtime/llm/openai_compat.py +256 -0
- steerable_agent_runtime/storage/__init__.py +82 -0
- steerable_agent_runtime/storage/in_memory.py +151 -0
- steerable_agent_runtime/storage/sqlalchemy_store.py +340 -0
- steerable_agent_runtime/tools.py +251 -0
- steerable_agent_runtime/transport/__init__.py +39 -0
- steerable_agent_runtime/transport/fastapi_sse.py +116 -0
- steerable_agent_runtime/transport/stdio_jsonrpc.py +319 -0
- steerable_agent_runtime-0.1.0.dist-info/METADATA +61 -0
- steerable_agent_runtime-0.1.0.dist-info/RECORD +16 -0
- steerable_agent_runtime-0.1.0.dist-info/WHEEL +5 -0
- steerable_agent_runtime-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|