steerable-agent-runtime 0.1.0__tar.gz
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-0.1.0/PKG-INFO +61 -0
- steerable_agent_runtime-0.1.0/README.md +36 -0
- steerable_agent_runtime-0.1.0/pyproject.toml +44 -0
- steerable_agent_runtime-0.1.0/setup.cfg +4 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/__init__.py +34 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/errors.py +34 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/llm/__init__.py +104 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/llm/anthropic_native.py +261 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/llm/openai_compat.py +256 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/storage/__init__.py +82 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/storage/in_memory.py +151 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/storage/sqlalchemy_store.py +340 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/tools.py +251 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/transport/__init__.py +39 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/transport/fastapi_sse.py +116 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +319 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime.egg-info/PKG-INFO +61 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime.egg-info/SOURCES.txt +24 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime.egg-info/dependency_links.txt +1 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime.egg-info/requires.txt +23 -0
- steerable_agent_runtime-0.1.0/src/steerable_agent_runtime.egg-info/top_level.txt +1 -0
- steerable_agent_runtime-0.1.0/tests/test_in_memory_storage.py +174 -0
- steerable_agent_runtime-0.1.0/tests/test_llm_wire_helpers.py +213 -0
- steerable_agent_runtime-0.1.0/tests/test_tool_router.py +167 -0
- steerable_agent_runtime-0.1.0/tests/test_transport_jsonrpc.py +134 -0
- steerable_agent_runtime-0.1.0/tests/test_transport_sse.py +75 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: steerable-agent-runtime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Steerable agent runtime: LLM, tool, storage, and transport adapters.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pydantic>=2.10.0
|
|
8
|
+
Requires-Dist: steerable-agent-protocol<1.0.0,>=0.1.0
|
|
9
|
+
Requires-Dist: steerable-agent-harness<1.0.0,>=0.1.0
|
|
10
|
+
Provides-Extra: sqlalchemy
|
|
11
|
+
Requires-Dist: sqlalchemy>=2.0; extra == "sqlalchemy"
|
|
12
|
+
Provides-Extra: fastapi
|
|
13
|
+
Requires-Dist: fastapi>=0.110; extra == "fastapi"
|
|
14
|
+
Requires-Dist: starlette>=0.37; extra == "fastapi"
|
|
15
|
+
Provides-Extra: openai
|
|
16
|
+
Requires-Dist: httpx>=0.27; extra == "openai"
|
|
17
|
+
Provides-Extra: anthropic
|
|
18
|
+
Requires-Dist: anthropic>=0.40; extra == "anthropic"
|
|
19
|
+
Provides-Extra: all
|
|
20
|
+
Requires-Dist: sqlalchemy>=2.0; extra == "all"
|
|
21
|
+
Requires-Dist: fastapi>=0.110; extra == "all"
|
|
22
|
+
Requires-Dist: starlette>=0.37; extra == "all"
|
|
23
|
+
Requires-Dist: httpx>=0.27; extra == "all"
|
|
24
|
+
Requires-Dist: anthropic>=0.40; extra == "all"
|
|
25
|
+
|
|
26
|
+
# steerable-agent-runtime
|
|
27
|
+
|
|
28
|
+
Tier 3 runtime for the Steerable framework.
|
|
29
|
+
|
|
30
|
+
Provides four orthogonal pluggable adapters:
|
|
31
|
+
|
|
32
|
+
- `LLMProvider` — chat-completion / streaming / tool-call abstraction, with
|
|
33
|
+
reference implementations for OpenAI-compatible servers (covers OpenAI,
|
|
34
|
+
Ollama, vLLM, SiliconFlow, etc.) and Anthropic native.
|
|
35
|
+
- `ToolRouter` — in-process tool registry. Auto-classifies tools into
|
|
36
|
+
`ToolMode`s using `steerable_agent_harness.policy`, supports per-tool
|
|
37
|
+
permission overrides, and dispatches `ToolCall` → `ToolResult`.
|
|
38
|
+
- `StorageAdapter` — persistence interface for `AgentSession`, `ChatMessage`,
|
|
39
|
+
`ChatAgent`, and `HarnessTrace + spans + events`. Reference impls: in-memory
|
|
40
|
+
(default for sidecar/dev) and SQLAlchemy (for hosted backends).
|
|
41
|
+
- `TransportAdapter` — wire format. `FastAPISseTransport` exports SSE for
|
|
42
|
+
hosted setups; `StdioJsonRpcTransport` powers the steerable-sidecar.
|
|
43
|
+
|
|
44
|
+
The runtime is **Python only** by design — frontends never depend on it
|
|
45
|
+
directly. Browsers/Electron consume runtime output via either the SSE transport
|
|
46
|
+
(over HTTP) or the stdio JSON-RPC transport (sidecar pattern).
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install steerable-agent-runtime[all]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Selectively install just the bits you need:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install "steerable-agent-runtime[openai]"
|
|
58
|
+
pip install "steerable-agent-runtime[anthropic]"
|
|
59
|
+
pip install "steerable-agent-runtime[sqlalchemy]"
|
|
60
|
+
pip install "steerable-agent-runtime[fastapi]"
|
|
61
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# steerable-agent-runtime
|
|
2
|
+
|
|
3
|
+
Tier 3 runtime for the Steerable framework.
|
|
4
|
+
|
|
5
|
+
Provides four orthogonal pluggable adapters:
|
|
6
|
+
|
|
7
|
+
- `LLMProvider` — chat-completion / streaming / tool-call abstraction, with
|
|
8
|
+
reference implementations for OpenAI-compatible servers (covers OpenAI,
|
|
9
|
+
Ollama, vLLM, SiliconFlow, etc.) and Anthropic native.
|
|
10
|
+
- `ToolRouter` — in-process tool registry. Auto-classifies tools into
|
|
11
|
+
`ToolMode`s using `steerable_agent_harness.policy`, supports per-tool
|
|
12
|
+
permission overrides, and dispatches `ToolCall` → `ToolResult`.
|
|
13
|
+
- `StorageAdapter` — persistence interface for `AgentSession`, `ChatMessage`,
|
|
14
|
+
`ChatAgent`, and `HarnessTrace + spans + events`. Reference impls: in-memory
|
|
15
|
+
(default for sidecar/dev) and SQLAlchemy (for hosted backends).
|
|
16
|
+
- `TransportAdapter` — wire format. `FastAPISseTransport` exports SSE for
|
|
17
|
+
hosted setups; `StdioJsonRpcTransport` powers the steerable-sidecar.
|
|
18
|
+
|
|
19
|
+
The runtime is **Python only** by design — frontends never depend on it
|
|
20
|
+
directly. Browsers/Electron consume runtime output via either the SSE transport
|
|
21
|
+
(over HTTP) or the stdio JSON-RPC transport (sidecar pattern).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install steerable-agent-runtime[all]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Selectively install just the bits you need:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install "steerable-agent-runtime[openai]"
|
|
33
|
+
pip install "steerable-agent-runtime[anthropic]"
|
|
34
|
+
pip install "steerable-agent-runtime[sqlalchemy]"
|
|
35
|
+
pip install "steerable-agent-runtime[fastapi]"
|
|
36
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "steerable-agent-runtime"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Steerable agent runtime: LLM, tool, storage, and transport adapters."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
# Pre-1.0 inter-package pins use `>=X,<1.0.0` ranges so release-please can
|
|
8
|
+
# bump individual packages without forcing a coordinated re-pin here.
|
|
9
|
+
# See packages/agent-harness/py/pyproject.toml for the rationale.
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pydantic>=2.10.0",
|
|
12
|
+
"steerable-agent-protocol>=0.1.0,<1.0.0",
|
|
13
|
+
"steerable-agent-harness>=0.1.0,<1.0.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
sqlalchemy = ["sqlalchemy>=2.0"]
|
|
18
|
+
fastapi = ["fastapi>=0.110", "starlette>=0.37"]
|
|
19
|
+
openai = ["httpx>=0.27"]
|
|
20
|
+
anthropic = ["anthropic>=0.40"]
|
|
21
|
+
all = [
|
|
22
|
+
"sqlalchemy>=2.0",
|
|
23
|
+
"fastapi>=0.110",
|
|
24
|
+
"starlette>=0.37",
|
|
25
|
+
"httpx>=0.27",
|
|
26
|
+
"anthropic>=0.40",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["setuptools>=68", "wheel"]
|
|
31
|
+
build-backend = "setuptools.build_meta"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
package-dir = {"" = "src"}
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.uv.sources]
|
|
40
|
+
steerable-agent-protocol = { workspace = true }
|
|
41
|
+
steerable-agent-harness = { workspace = true }
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
@@ -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
|