power-loop 0.2.0__tar.gz → 0.4.1__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.
- power_loop-0.4.1/PKG-INFO +171 -0
- power_loop-0.4.1/README.md +134 -0
- power_loop-0.4.1/llm_client/anthropic_factory.py +389 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/capabilities.py +7 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/interface.py +15 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/llm_factory.py +6 -15
- {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/llm_tooling.py +10 -8
- {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/qwen_image.py +3 -3
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/__init__.py +31 -2
- power_loop-0.4.1/power_loop/agent/follow_up.py +61 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/agent/sink.py +22 -8
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/agent/stateful_loop.py +278 -20
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/agent/system_prompt.py +87 -4
- power_loop-0.4.1/power_loop/agent/types.py +64 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/pipeline.py +105 -24
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/state.py +6 -9
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/compact.py +1 -1
- power_loop-0.4.1/power_loop/runtime/env.py +148 -0
- power_loop-0.4.1/power_loop/runtime/human_input.py +52 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/provider.py +33 -21
- power_loop-0.4.1/power_loop/runtime/runtime_state.py +145 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/session_store.py +210 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/skills.py +20 -18
- power_loop-0.4.1/power_loop/tools/__init__.py +103 -0
- power_loop-0.4.1/power_loop/tools/default_manifest.py +326 -0
- power_loop-0.4.1/power_loop/tools/default_tools.py +1138 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/tools/registry.py +9 -7
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/tools/spawn_agent.py +3 -1
- power_loop-0.4.1/power_loop.egg-info/PKG-INFO +171 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop.egg-info/SOURCES.txt +4 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop.egg-info/requires.txt +1 -1
- {power_loop-0.2.0 → power_loop-0.4.1}/pyproject.toml +5 -5
- power_loop-0.2.0/PKG-INFO +0 -632
- power_loop-0.2.0/README.md +0 -595
- power_loop-0.2.0/power_loop/agent/types.py +0 -41
- power_loop-0.2.0/power_loop/runtime/env.py +0 -103
- power_loop-0.2.0/power_loop/tools/__init__.py +0 -51
- power_loop-0.2.0/power_loop/tools/default_manifest.py +0 -244
- power_loop-0.2.0/power_loop/tools/default_tools.py +0 -766
- power_loop-0.2.0/power_loop.egg-info/PKG-INFO +0 -632
- {power_loop-0.2.0 → power_loop-0.4.1}/LICENSE +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/__init__.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/llm_utils.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/multimodal.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/web_search.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/agent/__init__.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/__init__.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/errors.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/events.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/handlers.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/hooks.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/messages.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/protocols.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/tools.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/agent_context.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/events.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/hooks.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/phase.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/runner.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/budget.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/memory.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/retry.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/spec.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/structured.py +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-0.2.0 → power_loop-0.4.1}/setup.cfg +0 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: power-loop
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
|
|
5
|
+
Author-email: zhangran <zhangran24@126.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/PL-play/power-loop
|
|
8
|
+
Project-URL: Repository, https://github.com/PL-play/power-loop
|
|
9
|
+
Project-URL: Changelog, https://github.com/PL-play/power-loop/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Roadmap, https://github.com/PL-play/power-loop/blob/main/ROADMAP.md
|
|
11
|
+
Keywords: agent,llm,openai,anthropic,tool-use,hooks
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: anthropic>=0.42.0
|
|
25
|
+
Requires-Dist: openai>=1.52.0
|
|
26
|
+
Requires-Dist: socksio>=1.0.0
|
|
27
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
28
|
+
Requires-Dist: pyyaml>=6.0
|
|
29
|
+
Requires-Dist: pypdf>=5.3.0
|
|
30
|
+
Requires-Dist: certifi>=2024.0.0
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy>=1.10.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# power-loop
|
|
39
|
+
|
|
40
|
+
[Documentation](docs/en/index.md) | [中文文档](docs/zh/index.md) | [Examples](examples/README.md) | [Changelog](CHANGELOG.md)
|
|
41
|
+
|
|
42
|
+
Embeddable, stateful agent execution for Python.
|
|
43
|
+
|
|
44
|
+
power-loop gives application code one small interface, `StatefulAgentLoop`, and handles the repetitive agent runtime work around it: multi-turn LLM loops, tool calls, hooks, events, context compaction, sub-agents, retry/cancel, structured output, memory, and SQLite-backed session persistence.
|
|
45
|
+
|
|
46
|
+
It is a library, not a service or a full application framework. You keep ownership of product logic, HTTP APIs, auth, queues, RAG, UI, and deployment.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install power-loop
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For local development:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/PL-play/power-loop.git
|
|
58
|
+
cd power-loop
|
|
59
|
+
pip install -e ".[dev]"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Python 3.10+ is required.
|
|
63
|
+
|
|
64
|
+
## Quick Example
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import asyncio
|
|
68
|
+
|
|
69
|
+
from power_loop import AgentLoopConfig, StatefulAgentLoop, create_llm_service_from_env
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def main() -> None:
|
|
73
|
+
llm = create_llm_service_from_env()
|
|
74
|
+
loop = StatefulAgentLoop(
|
|
75
|
+
llm=llm,
|
|
76
|
+
db_path="./power_loop_sessions.db",
|
|
77
|
+
config=AgentLoopConfig(
|
|
78
|
+
system_prompt="You are a concise assistant.",
|
|
79
|
+
max_rounds=4,
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
sid = loop.new_session(metadata={"user_id": "demo"})
|
|
84
|
+
first = await loop.send("My favorite color is teal.", session_id=sid)
|
|
85
|
+
second = await loop.send("What is my favorite color?", session_id=sid)
|
|
86
|
+
|
|
87
|
+
print(second.final_text)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
asyncio.run(main())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Configure any OpenAI-compatible endpoint with environment variables:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
POWER_LOOP_BASE_URL=https://api.openai.com/v1
|
|
97
|
+
POWER_LOOP_API_KEY=sk-...
|
|
98
|
+
POWER_LOOP_MODEL=gpt-4o-mini
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See [Getting Started](docs/en/getting-started.md) for the complete first run.
|
|
102
|
+
|
|
103
|
+
## What It Provides
|
|
104
|
+
|
|
105
|
+
| Capability | Where to read more |
|
|
106
|
+
|---|---|
|
|
107
|
+
| Stateful sessions and cross-process resume | [Sessions](docs/en/user-guide/sessions.md) |
|
|
108
|
+
| Tool calling with JSON Schema validation | [Tools](docs/en/user-guide/tools.md) |
|
|
109
|
+
| Lifecycle hooks for control flow | [Hooks](docs/en/user-guide/hooks.md) |
|
|
110
|
+
| Typed events for streaming, audit, and metrics | [Events](docs/en/user-guide/events.md) |
|
|
111
|
+
| Context compaction | [Compaction](docs/en/user-guide/compaction.md) |
|
|
112
|
+
| Sub-agents with `AgentSpec` | [Sub-agents](docs/en/user-guide/subagents.md) |
|
|
113
|
+
| Retry, timeout, and cancellation | [Retry & Cancel](docs/en/user-guide/retry-cancel.md) |
|
|
114
|
+
| Structured JSON output | [Structured Output](docs/en/user-guide/structured-output.md) |
|
|
115
|
+
| Pluggable cross-session memory | [Memory](docs/en/user-guide/memory.md) |
|
|
116
|
+
| Provider configuration | [Providers](docs/en/user-guide/providers.md) |
|
|
117
|
+
|
|
118
|
+
## Public API
|
|
119
|
+
|
|
120
|
+
Stable imports are re-exported from `power_loop`:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from power_loop import (
|
|
124
|
+
AgentLoopConfig,
|
|
125
|
+
StatefulAgentLoop,
|
|
126
|
+
StatefulResult,
|
|
127
|
+
ToolDefinition,
|
|
128
|
+
ToolRegistry,
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The stability tiers are:
|
|
133
|
+
|
|
134
|
+
| Tier | Meaning |
|
|
135
|
+
|---|---|
|
|
136
|
+
| Stable | Backward compatible across minor releases. Listed in `power_loop.STABLE_API`. |
|
|
137
|
+
| Provisional | Available from the top-level package during 0.x, but may change. |
|
|
138
|
+
| Internal | Submodule imports such as `power_loop.core.*`; no compatibility promise. |
|
|
139
|
+
|
|
140
|
+
See the [API reference](docs/en/api/index.md) for the current surface.
|
|
141
|
+
|
|
142
|
+
## Examples
|
|
143
|
+
|
|
144
|
+
The `examples/` directory is ordered from minimal usage to full chatbot composition:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
python examples/00_hello_world.py
|
|
148
|
+
python examples/02_tool_calling.py
|
|
149
|
+
python examples/19_full_chatbot.py
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The full list is in [examples/README.md](examples/README.md).
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pip install -e ".[dev]"
|
|
158
|
+
ruff check .
|
|
159
|
+
pytest -q --no-real
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Real LLM examples/tests use `POWER_LOOP_*` or the legacy `OPENAI_COMPAT_*` variables.
|
|
163
|
+
|
|
164
|
+
## Project Links
|
|
165
|
+
|
|
166
|
+
- [Documentation index](docs/README.md)
|
|
167
|
+
- [Architecture](docs/en/architecture.md)
|
|
168
|
+
- [Roadmap](ROADMAP.md)
|
|
169
|
+
- [Changelog](CHANGELOG.md)
|
|
170
|
+
- [Contributing](CONTRIBUTING.md)
|
|
171
|
+
- [License](LICENSE)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# power-loop
|
|
2
|
+
|
|
3
|
+
[Documentation](docs/en/index.md) | [中文文档](docs/zh/index.md) | [Examples](examples/README.md) | [Changelog](CHANGELOG.md)
|
|
4
|
+
|
|
5
|
+
Embeddable, stateful agent execution for Python.
|
|
6
|
+
|
|
7
|
+
power-loop gives application code one small interface, `StatefulAgentLoop`, and handles the repetitive agent runtime work around it: multi-turn LLM loops, tool calls, hooks, events, context compaction, sub-agents, retry/cancel, structured output, memory, and SQLite-backed session persistence.
|
|
8
|
+
|
|
9
|
+
It is a library, not a service or a full application framework. You keep ownership of product logic, HTTP APIs, auth, queues, RAG, UI, and deployment.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install power-loop
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For local development:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
git clone https://github.com/PL-play/power-loop.git
|
|
21
|
+
cd power-loop
|
|
22
|
+
pip install -e ".[dev]"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Python 3.10+ is required.
|
|
26
|
+
|
|
27
|
+
## Quick Example
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import asyncio
|
|
31
|
+
|
|
32
|
+
from power_loop import AgentLoopConfig, StatefulAgentLoop, create_llm_service_from_env
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def main() -> None:
|
|
36
|
+
llm = create_llm_service_from_env()
|
|
37
|
+
loop = StatefulAgentLoop(
|
|
38
|
+
llm=llm,
|
|
39
|
+
db_path="./power_loop_sessions.db",
|
|
40
|
+
config=AgentLoopConfig(
|
|
41
|
+
system_prompt="You are a concise assistant.",
|
|
42
|
+
max_rounds=4,
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
sid = loop.new_session(metadata={"user_id": "demo"})
|
|
47
|
+
first = await loop.send("My favorite color is teal.", session_id=sid)
|
|
48
|
+
second = await loop.send("What is my favorite color?", session_id=sid)
|
|
49
|
+
|
|
50
|
+
print(second.final_text)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
asyncio.run(main())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Configure any OpenAI-compatible endpoint with environment variables:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
POWER_LOOP_BASE_URL=https://api.openai.com/v1
|
|
60
|
+
POWER_LOOP_API_KEY=sk-...
|
|
61
|
+
POWER_LOOP_MODEL=gpt-4o-mini
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
See [Getting Started](docs/en/getting-started.md) for the complete first run.
|
|
65
|
+
|
|
66
|
+
## What It Provides
|
|
67
|
+
|
|
68
|
+
| Capability | Where to read more |
|
|
69
|
+
|---|---|
|
|
70
|
+
| Stateful sessions and cross-process resume | [Sessions](docs/en/user-guide/sessions.md) |
|
|
71
|
+
| Tool calling with JSON Schema validation | [Tools](docs/en/user-guide/tools.md) |
|
|
72
|
+
| Lifecycle hooks for control flow | [Hooks](docs/en/user-guide/hooks.md) |
|
|
73
|
+
| Typed events for streaming, audit, and metrics | [Events](docs/en/user-guide/events.md) |
|
|
74
|
+
| Context compaction | [Compaction](docs/en/user-guide/compaction.md) |
|
|
75
|
+
| Sub-agents with `AgentSpec` | [Sub-agents](docs/en/user-guide/subagents.md) |
|
|
76
|
+
| Retry, timeout, and cancellation | [Retry & Cancel](docs/en/user-guide/retry-cancel.md) |
|
|
77
|
+
| Structured JSON output | [Structured Output](docs/en/user-guide/structured-output.md) |
|
|
78
|
+
| Pluggable cross-session memory | [Memory](docs/en/user-guide/memory.md) |
|
|
79
|
+
| Provider configuration | [Providers](docs/en/user-guide/providers.md) |
|
|
80
|
+
|
|
81
|
+
## Public API
|
|
82
|
+
|
|
83
|
+
Stable imports are re-exported from `power_loop`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from power_loop import (
|
|
87
|
+
AgentLoopConfig,
|
|
88
|
+
StatefulAgentLoop,
|
|
89
|
+
StatefulResult,
|
|
90
|
+
ToolDefinition,
|
|
91
|
+
ToolRegistry,
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The stability tiers are:
|
|
96
|
+
|
|
97
|
+
| Tier | Meaning |
|
|
98
|
+
|---|---|
|
|
99
|
+
| Stable | Backward compatible across minor releases. Listed in `power_loop.STABLE_API`. |
|
|
100
|
+
| Provisional | Available from the top-level package during 0.x, but may change. |
|
|
101
|
+
| Internal | Submodule imports such as `power_loop.core.*`; no compatibility promise. |
|
|
102
|
+
|
|
103
|
+
See the [API reference](docs/en/api/index.md) for the current surface.
|
|
104
|
+
|
|
105
|
+
## Examples
|
|
106
|
+
|
|
107
|
+
The `examples/` directory is ordered from minimal usage to full chatbot composition:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python examples/00_hello_world.py
|
|
111
|
+
python examples/02_tool_calling.py
|
|
112
|
+
python examples/19_full_chatbot.py
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The full list is in [examples/README.md](examples/README.md).
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pip install -e ".[dev]"
|
|
121
|
+
ruff check .
|
|
122
|
+
pytest -q --no-real
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Real LLM examples/tests use `POWER_LOOP_*` or the legacy `OPENAI_COMPAT_*` variables.
|
|
126
|
+
|
|
127
|
+
## Project Links
|
|
128
|
+
|
|
129
|
+
- [Documentation index](docs/README.md)
|
|
130
|
+
- [Architecture](docs/en/architecture.md)
|
|
131
|
+
- [Roadmap](ROADMAP.md)
|
|
132
|
+
- [Changelog](CHANGELOG.md)
|
|
133
|
+
- [Contributing](CONTRIBUTING.md)
|
|
134
|
+
- [License](LICENSE)
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Anthropic Messages API transport for the shared ``LLMService`` interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator, Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from anthropic import AsyncAnthropic
|
|
11
|
+
|
|
12
|
+
from .interface import AnthropicChatConfig, LLMRequest, LLMResponse, LLMService, LLMStreamChunk, LLMTokenUsage
|
|
13
|
+
from .llm_utils import parse_json_from_model_output_detailed
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _as_dict(obj: Any) -> dict[str, Any]:
|
|
19
|
+
if isinstance(obj, dict):
|
|
20
|
+
return obj
|
|
21
|
+
if hasattr(obj, "model_dump"):
|
|
22
|
+
try:
|
|
23
|
+
dumped = obj.model_dump()
|
|
24
|
+
if isinstance(dumped, dict):
|
|
25
|
+
return dumped
|
|
26
|
+
except Exception:
|
|
27
|
+
return {}
|
|
28
|
+
try:
|
|
29
|
+
return dict(obj.__dict__)
|
|
30
|
+
except Exception:
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AnthropicMessagesLLMService(LLMService):
|
|
35
|
+
"""Native Anthropic-compatible Messages API client.
|
|
36
|
+
|
|
37
|
+
The public ``LLMRequest`` shape stays OpenAI-like because the rest of
|
|
38
|
+
power-loop speaks that canonical format. This transport converts at the
|
|
39
|
+
edge: OpenAI tool schemas become Anthropic tools, assistant ``tool_calls``
|
|
40
|
+
become ``tool_use`` blocks, and ``tool`` messages become ``tool_result``
|
|
41
|
+
blocks.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, cfg: AnthropicChatConfig):
|
|
45
|
+
self._cfg = cfg
|
|
46
|
+
self._client: AsyncAnthropic | None = None
|
|
47
|
+
self._last_usage: dict[str, Any] = {}
|
|
48
|
+
logger.info(
|
|
49
|
+
"Anthropic LLM: base_url=%s model=%s timeout_s=%s max_tokens=%s temperature=%s api_key=%s",
|
|
50
|
+
cfg.base_url,
|
|
51
|
+
cfg.model,
|
|
52
|
+
cfg.timeout_s,
|
|
53
|
+
cfg.max_tokens,
|
|
54
|
+
cfg.temperature,
|
|
55
|
+
"set" if bool(cfg.api_key) else "missing",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def get_last_token_usage(self) -> dict[str, Any]:
|
|
59
|
+
return dict(self._last_usage or {})
|
|
60
|
+
|
|
61
|
+
def _ensure_client(self) -> AsyncAnthropic:
|
|
62
|
+
if self._client is None:
|
|
63
|
+
self._client = AsyncAnthropic(
|
|
64
|
+
api_key=self._cfg.api_key,
|
|
65
|
+
base_url=self._cfg.base_url,
|
|
66
|
+
timeout=self._cfg.timeout_s,
|
|
67
|
+
max_retries=self._cfg.max_retries,
|
|
68
|
+
)
|
|
69
|
+
return self._client
|
|
70
|
+
|
|
71
|
+
async def close(self) -> None:
|
|
72
|
+
if self._client is None:
|
|
73
|
+
return
|
|
74
|
+
try:
|
|
75
|
+
await self._client.close()
|
|
76
|
+
finally:
|
|
77
|
+
self._client = None
|
|
78
|
+
|
|
79
|
+
def _usage_dict_from_any(self, usage: Any) -> dict[str, Any]:
|
|
80
|
+
raw = _as_dict(usage)
|
|
81
|
+
|
|
82
|
+
def _int_or_none(value: Any) -> int | None:
|
|
83
|
+
try:
|
|
84
|
+
return None if value is None else int(value)
|
|
85
|
+
except Exception:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
prompt = _int_or_none(raw.get("input_tokens") or raw.get("prompt_tokens"))
|
|
89
|
+
completion = _int_or_none(raw.get("output_tokens") or raw.get("completion_tokens"))
|
|
90
|
+
total = _int_or_none(raw.get("total_tokens"))
|
|
91
|
+
if total is None and prompt is not None and completion is not None:
|
|
92
|
+
total = prompt + completion
|
|
93
|
+
raw["prompt_tokens"] = prompt
|
|
94
|
+
raw["completion_tokens"] = completion
|
|
95
|
+
raw["total_tokens"] = total
|
|
96
|
+
return raw
|
|
97
|
+
|
|
98
|
+
def _usage_obj(self, usage: Any = None) -> LLMTokenUsage:
|
|
99
|
+
d = self._last_usage if usage is None else self._usage_dict_from_any(usage)
|
|
100
|
+
|
|
101
|
+
def _int_or_none(value: Any) -> int | None:
|
|
102
|
+
try:
|
|
103
|
+
return None if value is None else int(value)
|
|
104
|
+
except Exception:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
return LLMTokenUsage(
|
|
108
|
+
prompt_tokens=_int_or_none(d.get("prompt_tokens")),
|
|
109
|
+
completion_tokens=_int_or_none(d.get("completion_tokens")),
|
|
110
|
+
total_tokens=_int_or_none(d.get("total_tokens")),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _record_usage(self, usage: Any) -> None:
|
|
114
|
+
self._last_usage = self._usage_dict_from_any(usage)
|
|
115
|
+
|
|
116
|
+
def _system_and_messages(self, request: LLMRequest) -> tuple[str | None, list[dict[str, Any]]]:
|
|
117
|
+
system_parts: list[str] = []
|
|
118
|
+
if request.system_prompt:
|
|
119
|
+
system_parts.append(request.system_prompt)
|
|
120
|
+
|
|
121
|
+
messages: list[dict[str, Any]] = []
|
|
122
|
+
pending_tool_results: list[dict[str, Any]] = []
|
|
123
|
+
|
|
124
|
+
def flush_tool_results() -> None:
|
|
125
|
+
nonlocal pending_tool_results
|
|
126
|
+
if pending_tool_results:
|
|
127
|
+
messages.append({"role": "user", "content": pending_tool_results})
|
|
128
|
+
pending_tool_results = []
|
|
129
|
+
|
|
130
|
+
for raw in request.messages or []:
|
|
131
|
+
msg = dict(raw)
|
|
132
|
+
role = str(msg.get("role") or "user")
|
|
133
|
+
if role == "system":
|
|
134
|
+
content = self._text_from_content(msg.get("content"))
|
|
135
|
+
if content:
|
|
136
|
+
system_parts.append(content)
|
|
137
|
+
continue
|
|
138
|
+
if role == "tool":
|
|
139
|
+
pending_tool_results.append({
|
|
140
|
+
"type": "tool_result",
|
|
141
|
+
"tool_use_id": str(msg.get("tool_call_id") or ""),
|
|
142
|
+
"content": self._text_from_content(msg.get("content")),
|
|
143
|
+
})
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
flush_tool_results()
|
|
147
|
+
anthropic_role = "assistant" if role == "assistant" else "user"
|
|
148
|
+
blocks = self._anthropic_content_blocks(msg)
|
|
149
|
+
self._append_or_merge(messages, {"role": anthropic_role, "content": blocks})
|
|
150
|
+
|
|
151
|
+
flush_tool_results()
|
|
152
|
+
|
|
153
|
+
if request.response_format is not None:
|
|
154
|
+
instruction = self._json_instruction(request.response_format)
|
|
155
|
+
if instruction:
|
|
156
|
+
system_parts.append(instruction)
|
|
157
|
+
|
|
158
|
+
system = "\n\n".join(part for part in system_parts if part).strip() or None
|
|
159
|
+
return system, messages
|
|
160
|
+
|
|
161
|
+
def _anthropic_content_blocks(self, msg: dict[str, Any]) -> list[dict[str, Any]]:
|
|
162
|
+
blocks: list[dict[str, Any]] = []
|
|
163
|
+
text = self._text_from_content(msg.get("content"))
|
|
164
|
+
if text:
|
|
165
|
+
blocks.append({"type": "text", "text": text})
|
|
166
|
+
|
|
167
|
+
for call in self._normalize_openai_tool_calls(msg.get("tool_calls") or msg.get("function_call")):
|
|
168
|
+
fn = call.get("function") or {}
|
|
169
|
+
name = str(fn.get("name") or "")
|
|
170
|
+
if not name:
|
|
171
|
+
continue
|
|
172
|
+
blocks.append({
|
|
173
|
+
"type": "tool_use",
|
|
174
|
+
"id": str(call.get("id") or f"toolu_{len(blocks)}"),
|
|
175
|
+
"name": name,
|
|
176
|
+
"input": self._json_object(fn.get("arguments")),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
if not blocks:
|
|
180
|
+
blocks.append({"type": "text", "text": ""})
|
|
181
|
+
return blocks
|
|
182
|
+
|
|
183
|
+
def _append_or_merge(self, messages: list[dict[str, Any]], item: dict[str, Any]) -> None:
|
|
184
|
+
if messages and messages[-1].get("role") == item.get("role"):
|
|
185
|
+
prev = messages[-1].setdefault("content", [])
|
|
186
|
+
if isinstance(prev, list) and isinstance(item.get("content"), list):
|
|
187
|
+
prev.extend(item["content"])
|
|
188
|
+
return
|
|
189
|
+
messages.append(item)
|
|
190
|
+
|
|
191
|
+
def _text_from_content(self, content: Any) -> str:
|
|
192
|
+
if content is None:
|
|
193
|
+
return ""
|
|
194
|
+
if isinstance(content, str):
|
|
195
|
+
return content
|
|
196
|
+
if isinstance(content, list):
|
|
197
|
+
parts: list[str] = []
|
|
198
|
+
for item in content:
|
|
199
|
+
if isinstance(item, str):
|
|
200
|
+
parts.append(item)
|
|
201
|
+
elif isinstance(item, dict):
|
|
202
|
+
text = item.get("text") or item.get("content")
|
|
203
|
+
if isinstance(text, str):
|
|
204
|
+
parts.append(text)
|
|
205
|
+
return "".join(parts)
|
|
206
|
+
return str(content)
|
|
207
|
+
|
|
208
|
+
def _normalize_openai_tool_calls(self, value: Any) -> list[dict[str, Any]]:
|
|
209
|
+
if not value:
|
|
210
|
+
return []
|
|
211
|
+
if isinstance(value, dict) and value.get("name"):
|
|
212
|
+
return [{"type": "function", "function": value}]
|
|
213
|
+
items = value if isinstance(value, list) else [value]
|
|
214
|
+
out: list[dict[str, Any]] = []
|
|
215
|
+
for item in items:
|
|
216
|
+
data = item if isinstance(item, dict) else _as_dict(item)
|
|
217
|
+
if isinstance(data, dict):
|
|
218
|
+
out.append(data)
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
def _json_object(self, value: Any) -> dict[str, Any]:
|
|
222
|
+
if isinstance(value, dict):
|
|
223
|
+
return value
|
|
224
|
+
if isinstance(value, str) and value.strip():
|
|
225
|
+
try:
|
|
226
|
+
parsed = json.loads(value)
|
|
227
|
+
if isinstance(parsed, dict):
|
|
228
|
+
return parsed
|
|
229
|
+
except Exception:
|
|
230
|
+
return {"arguments": value}
|
|
231
|
+
return {}
|
|
232
|
+
|
|
233
|
+
def _json_instruction(self, response_format: dict[str, Any]) -> str:
|
|
234
|
+
schema = response_format.get("json_schema")
|
|
235
|
+
if not isinstance(schema, dict):
|
|
236
|
+
return "Return only valid JSON. Do not wrap it in Markdown."
|
|
237
|
+
payload = schema.get("schema")
|
|
238
|
+
try:
|
|
239
|
+
rendered = json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
|
240
|
+
except Exception:
|
|
241
|
+
rendered = "{}"
|
|
242
|
+
return f"Return only valid JSON matching this JSON Schema. Do not wrap it in Markdown.\nSchema: {rendered}"
|
|
243
|
+
|
|
244
|
+
def _anthropic_tools(self, tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
|
|
245
|
+
if not tools:
|
|
246
|
+
return None
|
|
247
|
+
out: list[dict[str, Any]] = []
|
|
248
|
+
for tool in tools:
|
|
249
|
+
fn = tool.get("function") if isinstance(tool, dict) else None
|
|
250
|
+
if not isinstance(fn, dict):
|
|
251
|
+
continue
|
|
252
|
+
name = fn.get("name")
|
|
253
|
+
if not name:
|
|
254
|
+
continue
|
|
255
|
+
out.append({
|
|
256
|
+
"name": str(name),
|
|
257
|
+
"description": str(fn.get("description") or ""),
|
|
258
|
+
"input_schema": fn.get("parameters") if isinstance(fn.get("parameters"), dict) else {"type": "object"},
|
|
259
|
+
})
|
|
260
|
+
return out or None
|
|
261
|
+
|
|
262
|
+
def _tool_choice(self, choice: Any) -> Any:
|
|
263
|
+
if choice in (None, "auto"):
|
|
264
|
+
return None
|
|
265
|
+
if choice == "none":
|
|
266
|
+
return {"type": "none"}
|
|
267
|
+
if isinstance(choice, dict):
|
|
268
|
+
if "dashscope.aliyuncs.com" in self._cfg.base_url:
|
|
269
|
+
logger.warning(
|
|
270
|
+
"Skipping forced Anthropic tool_choice for DashScope endpoint; "
|
|
271
|
+
"the endpoint rejects object tool_choice in thinking mode."
|
|
272
|
+
)
|
|
273
|
+
return None
|
|
274
|
+
fn = choice.get("function")
|
|
275
|
+
if isinstance(fn, dict) and fn.get("name"):
|
|
276
|
+
return {"type": "tool", "name": str(fn["name"])}
|
|
277
|
+
if choice.get("name"):
|
|
278
|
+
return {"type": "tool", "name": str(choice["name"])}
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
def _request_kwargs(self, request: LLMRequest) -> dict[str, Any]:
|
|
282
|
+
system, messages = self._system_and_messages(request)
|
|
283
|
+
kwargs: dict[str, Any] = dict(request.extra or {})
|
|
284
|
+
kwargs["model"] = request.model or self._cfg.model
|
|
285
|
+
kwargs["messages"] = messages
|
|
286
|
+
kwargs["max_tokens"] = int(request.max_tokens if request.max_tokens is not None else self._cfg.max_tokens)
|
|
287
|
+
|
|
288
|
+
temperature = request.temperature if request.temperature is not None else self._cfg.temperature
|
|
289
|
+
if temperature is not None:
|
|
290
|
+
kwargs["temperature"] = float(temperature)
|
|
291
|
+
if system:
|
|
292
|
+
kwargs["system"] = system
|
|
293
|
+
tools = self._anthropic_tools(request.tools)
|
|
294
|
+
if tools:
|
|
295
|
+
kwargs["tools"] = tools
|
|
296
|
+
tool_choice = self._tool_choice(request.tool_choice)
|
|
297
|
+
if tool_choice:
|
|
298
|
+
kwargs["tool_choice"] = tool_choice
|
|
299
|
+
return kwargs
|
|
300
|
+
|
|
301
|
+
async def complete(
|
|
302
|
+
self,
|
|
303
|
+
request: LLMRequest,
|
|
304
|
+
*,
|
|
305
|
+
on_chunk_delta_text: Callable[[str], Any] | None = None,
|
|
306
|
+
on_chunk_think: Callable[[str], Any] | None = None,
|
|
307
|
+
on_stream_end: Callable[[LLMResponse], Any] | None = None,
|
|
308
|
+
) -> LLMResponse:
|
|
309
|
+
client = self._ensure_client()
|
|
310
|
+
kwargs = self._request_kwargs(request)
|
|
311
|
+
response = await client.messages.create(**kwargs)
|
|
312
|
+
text, think, tool_calls = self._extract_response(response)
|
|
313
|
+
self._record_usage(getattr(response, "usage", None))
|
|
314
|
+
|
|
315
|
+
if request.parse_json:
|
|
316
|
+
result = parse_json_from_model_output_detailed(text)
|
|
317
|
+
else:
|
|
318
|
+
result = LLMResponse(raw_text=text, content_text=text)
|
|
319
|
+
result.raw_completion = response
|
|
320
|
+
result.raw_message = response
|
|
321
|
+
result.think = think
|
|
322
|
+
result.tool_calls = tool_calls
|
|
323
|
+
result.token_usage = self._usage_obj()
|
|
324
|
+
|
|
325
|
+
if on_chunk_delta_text and text:
|
|
326
|
+
maybe = on_chunk_delta_text(text)
|
|
327
|
+
if hasattr(maybe, "__await__"):
|
|
328
|
+
await maybe
|
|
329
|
+
if on_chunk_think and think:
|
|
330
|
+
maybe = on_chunk_think(think)
|
|
331
|
+
if hasattr(maybe, "__await__"):
|
|
332
|
+
await maybe
|
|
333
|
+
if on_stream_end:
|
|
334
|
+
maybe = on_stream_end(result)
|
|
335
|
+
if hasattr(maybe, "__await__"):
|
|
336
|
+
await maybe
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
async def stream(self, request: LLMRequest) -> AsyncIterator[LLMStreamChunk]:
|
|
340
|
+
response = await self.complete(request)
|
|
341
|
+
if response.raw_text or response.think or response.tool_calls:
|
|
342
|
+
yield LLMStreamChunk(
|
|
343
|
+
delta_text=response.raw_text,
|
|
344
|
+
think=response.think,
|
|
345
|
+
tool_calls=response.tool_calls,
|
|
346
|
+
token_usage=None,
|
|
347
|
+
raw_event=response.raw_completion,
|
|
348
|
+
is_final=False,
|
|
349
|
+
)
|
|
350
|
+
yield LLMStreamChunk(
|
|
351
|
+
delta_text="",
|
|
352
|
+
think="",
|
|
353
|
+
tool_calls=response.tool_calls,
|
|
354
|
+
token_usage=response.token_usage,
|
|
355
|
+
raw_event=response.raw_completion,
|
|
356
|
+
is_final=True,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _extract_response(self, response: Any) -> tuple[str, str, list[dict[str, Any]]]:
|
|
360
|
+
text_parts: list[str] = []
|
|
361
|
+
think_parts: list[str] = []
|
|
362
|
+
tool_calls: list[dict[str, Any]] = []
|
|
363
|
+
for block in getattr(response, "content", []) or []:
|
|
364
|
+
data = _as_dict(block)
|
|
365
|
+
block_type = str(data.get("type") or "").lower()
|
|
366
|
+
if block_type == "text":
|
|
367
|
+
text = data.get("text")
|
|
368
|
+
if isinstance(text, str):
|
|
369
|
+
text_parts.append(text)
|
|
370
|
+
elif block_type in {"thinking", "reasoning"}:
|
|
371
|
+
text = data.get("thinking") or data.get("text")
|
|
372
|
+
if isinstance(text, str):
|
|
373
|
+
think_parts.append(text)
|
|
374
|
+
elif block_type == "tool_use":
|
|
375
|
+
name = data.get("name")
|
|
376
|
+
if not name:
|
|
377
|
+
continue
|
|
378
|
+
tool_calls.append({
|
|
379
|
+
"id": data.get("id") or f"toolu_{len(tool_calls)}",
|
|
380
|
+
"type": "function",
|
|
381
|
+
"function": {
|
|
382
|
+
"name": name,
|
|
383
|
+
"arguments": json.dumps(data.get("input") or {}, ensure_ascii=False),
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
return "".join(text_parts), "".join(think_parts), tool_calls
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
__all__ = ["AnthropicMessagesLLMService"]
|