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.
Files changed (70) hide show
  1. power_loop-0.4.1/PKG-INFO +171 -0
  2. power_loop-0.4.1/README.md +134 -0
  3. power_loop-0.4.1/llm_client/anthropic_factory.py +389 -0
  4. {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/capabilities.py +7 -0
  5. {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/interface.py +15 -0
  6. {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/llm_factory.py +6 -15
  7. {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/llm_tooling.py +10 -8
  8. {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/qwen_image.py +3 -3
  9. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/__init__.py +31 -2
  10. power_loop-0.4.1/power_loop/agent/follow_up.py +61 -0
  11. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/agent/sink.py +22 -8
  12. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/agent/stateful_loop.py +278 -20
  13. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/agent/system_prompt.py +87 -4
  14. power_loop-0.4.1/power_loop/agent/types.py +64 -0
  15. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/pipeline.py +105 -24
  16. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/state.py +6 -9
  17. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/compact.py +1 -1
  18. power_loop-0.4.1/power_loop/runtime/env.py +148 -0
  19. power_loop-0.4.1/power_loop/runtime/human_input.py +52 -0
  20. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/provider.py +33 -21
  21. power_loop-0.4.1/power_loop/runtime/runtime_state.py +145 -0
  22. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/session_store.py +210 -0
  23. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/skills.py +20 -18
  24. power_loop-0.4.1/power_loop/tools/__init__.py +103 -0
  25. power_loop-0.4.1/power_loop/tools/default_manifest.py +326 -0
  26. power_loop-0.4.1/power_loop/tools/default_tools.py +1138 -0
  27. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/tools/registry.py +9 -7
  28. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/tools/spawn_agent.py +3 -1
  29. power_loop-0.4.1/power_loop.egg-info/PKG-INFO +171 -0
  30. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop.egg-info/SOURCES.txt +4 -0
  31. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop.egg-info/requires.txt +1 -1
  32. {power_loop-0.2.0 → power_loop-0.4.1}/pyproject.toml +5 -5
  33. power_loop-0.2.0/PKG-INFO +0 -632
  34. power_loop-0.2.0/README.md +0 -595
  35. power_loop-0.2.0/power_loop/agent/types.py +0 -41
  36. power_loop-0.2.0/power_loop/runtime/env.py +0 -103
  37. power_loop-0.2.0/power_loop/tools/__init__.py +0 -51
  38. power_loop-0.2.0/power_loop/tools/default_manifest.py +0 -244
  39. power_loop-0.2.0/power_loop/tools/default_tools.py +0 -766
  40. power_loop-0.2.0/power_loop.egg-info/PKG-INFO +0 -632
  41. {power_loop-0.2.0 → power_loop-0.4.1}/LICENSE +0 -0
  42. {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/__init__.py +0 -0
  43. {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/llm_utils.py +0 -0
  44. {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/multimodal.py +0 -0
  45. {power_loop-0.2.0 → power_loop-0.4.1}/llm_client/web_search.py +0 -0
  46. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/agent/__init__.py +0 -0
  47. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/__init__.py +0 -0
  48. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/errors.py +0 -0
  49. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/event_payloads.py +0 -0
  50. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/events.py +0 -0
  51. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/handlers.py +0 -0
  52. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/hook_contexts.py +0 -0
  53. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/hooks.py +0 -0
  54. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/messages.py +0 -0
  55. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/protocols.py +0 -0
  56. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/contracts/tools.py +0 -0
  57. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/agent_context.py +0 -0
  58. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/events.py +0 -0
  59. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/hooks.py +0 -0
  60. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/phase.py +0 -0
  61. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/core/runner.py +0 -0
  62. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/budget.py +0 -0
  63. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/cancellation.py +0 -0
  64. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/memory.py +0 -0
  65. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/retry.py +0 -0
  66. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/spec.py +0 -0
  67. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop/runtime/structured.py +0 -0
  68. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop.egg-info/dependency_links.txt +0 -0
  69. {power_loop-0.2.0 → power_loop-0.4.1}/power_loop.egg-info/top_level.txt +0 -0
  70. {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"]