openlcm 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.
- openlcm/__init__.py +80 -0
- openlcm/adapters/__init__.py +256 -0
- openlcm/adapters/anthropic.py +283 -0
- openlcm/adapters/autogen.py +342 -0
- openlcm/adapters/base.py +64 -0
- openlcm/adapters/crewai.py +144 -0
- openlcm/adapters/gemini.py +310 -0
- openlcm/adapters/google_adk.py +381 -0
- openlcm/adapters/haystack.py +272 -0
- openlcm/adapters/langchain.py +235 -0
- openlcm/adapters/langgraph.py +246 -0
- openlcm/adapters/llamaindex.py +234 -0
- openlcm/adapters/openai.py +241 -0
- openlcm/backends/__init__.py +14 -0
- openlcm/backends/anthropic.py +83 -0
- openlcm/backends/base.py +42 -0
- openlcm/backends/callable.py +131 -0
- openlcm/backends/litellm.py +98 -0
- openlcm/backends/openai.py +91 -0
- openlcm/cli/__init__.py +0 -0
- openlcm/cli/main.py +319 -0
- openlcm/core/__init__.py +1 -0
- openlcm/core/config.py +350 -0
- openlcm/core/dag.py +620 -0
- openlcm/core/db_bootstrap.py +453 -0
- openlcm/core/engine.py +1782 -0
- openlcm/core/escalation.py +298 -0
- openlcm/core/externalize.py +427 -0
- openlcm/core/extraction.py +232 -0
- openlcm/core/ingest_protection.py +1198 -0
- openlcm/core/lifecycle_state.py +572 -0
- openlcm/core/message_content.py +91 -0
- openlcm/core/message_patterns.py +116 -0
- openlcm/core/model_routing.py +104 -0
- openlcm/core/presets.py +294 -0
- openlcm/core/schemas.py +319 -0
- openlcm/core/search_query.py +283 -0
- openlcm/core/session_patterns.py +52 -0
- openlcm/core/store.py +1058 -0
- openlcm/core/tokens.py +61 -0
- openlcm/core/tools.py +1855 -0
- openlcm/viz/__init__.py +0 -0
- openlcm/viz/events.py +138 -0
- openlcm/viz/server.py +389 -0
- openlcm/viz/static/dashboard.js +967 -0
- openlcm/viz/static/index.html +294 -0
- openlcm/viz/static/styles.css +477 -0
- openlcm-0.1.0.dist-info/METADATA +226 -0
- openlcm-0.1.0.dist-info/RECORD +51 -0
- openlcm-0.1.0.dist-info/WHEEL +4 -0
- openlcm-0.1.0.dist-info/entry_points.txt +2 -0
openlcm/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""OpenLCM — Framework-agnostic Lossless Context Management SDK.
|
|
2
|
+
|
|
3
|
+
One install, every provider::
|
|
4
|
+
|
|
5
|
+
pip install openlcm
|
|
6
|
+
|
|
7
|
+
Quick start — pass any LiteLLM model string::
|
|
8
|
+
|
|
9
|
+
from openlcm import LCMEngine
|
|
10
|
+
|
|
11
|
+
# Anthropic
|
|
12
|
+
engine = LCMEngine(model="anthropic/claude-haiku-4-5-20251001")
|
|
13
|
+
|
|
14
|
+
# Azure OpenAI
|
|
15
|
+
engine = LCMEngine(model="azure/gpt-4o")
|
|
16
|
+
|
|
17
|
+
# AWS Bedrock
|
|
18
|
+
engine = LCMEngine(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
|
19
|
+
|
|
20
|
+
# Google Gemini
|
|
21
|
+
engine = LCMEngine(model="gemini/gemini-2.0-flash")
|
|
22
|
+
|
|
23
|
+
# Vertex AI
|
|
24
|
+
engine = LCMEngine(model="vertex_ai/gemini-pro")
|
|
25
|
+
|
|
26
|
+
# Ollama (local)
|
|
27
|
+
engine = LCMEngine(model="ollama/llama3.2", api_base="http://localhost:11434")
|
|
28
|
+
|
|
29
|
+
# WatsonX
|
|
30
|
+
engine = LCMEngine(model="watsonx/ibm/granite-13b-chat-v2")
|
|
31
|
+
|
|
32
|
+
# Custom OpenAI-compatible endpoint
|
|
33
|
+
engine = LCMEngine(model="openai/my-model", api_base="http://my-server/v1")
|
|
34
|
+
|
|
35
|
+
Bind a session and compress each turn::
|
|
36
|
+
|
|
37
|
+
engine.bind_session("session-abc", context_length=200_000)
|
|
38
|
+
compressed = await engine.compress(messages) # call before every LLM turn
|
|
39
|
+
|
|
40
|
+
Already using a framework LLM? Pass it directly — no separate model config needed::
|
|
41
|
+
|
|
42
|
+
from langchain_anthropic import ChatAnthropic
|
|
43
|
+
from openlcm.adapters.langgraph import LCMCheckpointer
|
|
44
|
+
|
|
45
|
+
llm = ChatAnthropic(model="claude-3-haiku-20240307") # your existing LLM
|
|
46
|
+
checkpointer = LCMCheckpointer(llm=llm) # reuses it for summarization
|
|
47
|
+
|
|
48
|
+
# Works the same way for CrewAI, AutoGen, Google ADK:
|
|
49
|
+
LCMStorage(llm=my_crewai_llm)
|
|
50
|
+
LCMContext(llm=my_autogen_client)
|
|
51
|
+
LCMSessionService(llm=my_gemini_model)
|
|
52
|
+
|
|
53
|
+
# Or pass any callable to LCMEngine directly:
|
|
54
|
+
engine = LCMEngine(summarize_fn=llm)
|
|
55
|
+
engine = LCMEngine(summarize_fn=lambda prompt, max_tokens: llm.invoke(prompt).content)
|
|
56
|
+
|
|
57
|
+
Live dashboard::
|
|
58
|
+
|
|
59
|
+
openlcm viz # http://localhost:7842
|
|
60
|
+
|
|
61
|
+
Advanced — bring your own backend (custom inference server, etc.)::
|
|
62
|
+
|
|
63
|
+
from openlcm.backends.base import SummaryBackend
|
|
64
|
+
|
|
65
|
+
class MyBackend(SummaryBackend):
|
|
66
|
+
async def summarize(self, prompt, max_tokens, model="", timeout=None):
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
engine = LCMEngine(backend=MyBackend())
|
|
70
|
+
|
|
71
|
+
Full provider list: https://docs.litellm.ai/docs/providers
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
from .core.engine import LCMEngine
|
|
75
|
+
from .core.config import LCMConfig
|
|
76
|
+
from .backends.base import SummaryBackend
|
|
77
|
+
from .backends.callable import CallableBackend
|
|
78
|
+
|
|
79
|
+
__version__ = "0.1.0"
|
|
80
|
+
__all__ = ["LCMEngine", "LCMConfig", "SummaryBackend", "CallableBackend"]
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Framework adapters for OpenLCM.
|
|
2
|
+
|
|
3
|
+
Two types of adapter live here:
|
|
4
|
+
|
|
5
|
+
1. **Message converters** — translate message formats between frameworks and
|
|
6
|
+
LCM's internal dict format. Use these when you manage the conversation
|
|
7
|
+
history yourself (raw SDK, custom agent loop, etc.).
|
|
8
|
+
|
|
9
|
+
2. **Framework lifecycle adapters** — plug LCM into a framework's built-in
|
|
10
|
+
memory / checkpointing / context system. Use these when you want LCM to
|
|
11
|
+
work *transparently* inside an existing framework graph or crew.
|
|
12
|
+
|
|
13
|
+
──────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
Message converters (``to_lcm`` / ``from_lcm``)
|
|
15
|
+
──────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
OpenAI (and every OpenAI-compatible API)::
|
|
18
|
+
|
|
19
|
+
from openlcm.adapters.openai import OpenAIMessages
|
|
20
|
+
|
|
21
|
+
lcm_msgs = OpenAIMessages.to_lcm(openai_messages)
|
|
22
|
+
if engine.should_compress_preflight(lcm_msgs):
|
|
23
|
+
lcm_msgs = await engine.compress(lcm_msgs)
|
|
24
|
+
openai_msgs = OpenAIMessages.from_lcm(lcm_msgs)
|
|
25
|
+
|
|
26
|
+
# Compatible with: OpenAI, Groq, Together, Mistral, Perplexity, Fireworks,
|
|
27
|
+
# Azure OpenAI, Ollama, vLLM, LM Studio, OpenRouter, Anyscale
|
|
28
|
+
|
|
29
|
+
Anthropic::
|
|
30
|
+
|
|
31
|
+
from openlcm.adapters.anthropic import AnthropicMessages
|
|
32
|
+
|
|
33
|
+
lcm_msgs = AnthropicMessages.to_lcm(messages, system=system_prompt)
|
|
34
|
+
system_out, an_msgs = AnthropicMessages.from_lcm(lcm_msgs)
|
|
35
|
+
# Note: Anthropic returns (system_str, messages) because system is a separate param
|
|
36
|
+
|
|
37
|
+
LangChain (any backend)::
|
|
38
|
+
|
|
39
|
+
from openlcm.adapters.langchain import LangChainMessages
|
|
40
|
+
|
|
41
|
+
lcm_msgs = LangChainMessages.to_lcm(lc_messages) # list[BaseMessage]
|
|
42
|
+
lc_msgs = LangChainMessages.from_lcm(lcm_msgs) # list[BaseMessage]
|
|
43
|
+
|
|
44
|
+
# Compatible with: langchain_openai, langchain_anthropic, langchain_google_genai,
|
|
45
|
+
# langchain_cohere, langchain_mistralai, langchain_groq,
|
|
46
|
+
# langchain_ollama, langchain_aws (Bedrock), langchain_together, …
|
|
47
|
+
|
|
48
|
+
LlamaIndex::
|
|
49
|
+
|
|
50
|
+
from openlcm.adapters.llamaindex import LlamaIndexMessages
|
|
51
|
+
|
|
52
|
+
lcm_msgs = LlamaIndexMessages.to_lcm(chat_messages) # list[ChatMessage]
|
|
53
|
+
li_msgs = LlamaIndexMessages.from_lcm(lcm_msgs)
|
|
54
|
+
|
|
55
|
+
# Compatible with: llama_index.llms.openai, llama_index.llms.anthropic,
|
|
56
|
+
# llama_index.llms.gemini, llama_index.llms.ollama, …
|
|
57
|
+
|
|
58
|
+
Haystack v2::
|
|
59
|
+
|
|
60
|
+
from openlcm.adapters.haystack import HaystackMessages
|
|
61
|
+
|
|
62
|
+
lcm_msgs = HaystackMessages.to_lcm(hs_messages) # list[ChatMessage]
|
|
63
|
+
hs_msgs = HaystackMessages.from_lcm(lcm_msgs)
|
|
64
|
+
|
|
65
|
+
# Compatible with: OpenAIChatGenerator, AnthropicChatGenerator,
|
|
66
|
+
# HuggingFaceAPIChatGenerator, AzureOpenAIChatGenerator, …
|
|
67
|
+
|
|
68
|
+
Auto-detect the right converter from your message list::
|
|
69
|
+
|
|
70
|
+
from openlcm.adapters import auto_detect
|
|
71
|
+
|
|
72
|
+
converter = auto_detect(messages)
|
|
73
|
+
lcm_msgs = converter.to_lcm(messages)
|
|
74
|
+
|
|
75
|
+
──────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
Framework lifecycle adapters
|
|
77
|
+
──────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
LangGraph — transparent checkpointing::
|
|
80
|
+
|
|
81
|
+
from openlcm.adapters.langgraph import LCMCheckpointer # pip install openlcm[langgraph]
|
|
82
|
+
|
|
83
|
+
graph = StateGraph(MyState).compile(checkpointer=LCMCheckpointer(llm=my_llm))
|
|
84
|
+
|
|
85
|
+
CrewAI — long-term memory storage::
|
|
86
|
+
|
|
87
|
+
from openlcm.adapters.crewai import LCMStorage # pip install openlcm[crewai]
|
|
88
|
+
|
|
89
|
+
crew = Crew(..., long_term_memory=LongTermMemory(storage=LCMStorage(llm=my_llm)))
|
|
90
|
+
|
|
91
|
+
AutoGen — model context::
|
|
92
|
+
|
|
93
|
+
from openlcm.adapters.autogen import LCMContext # pip install openlcm[autogen]
|
|
94
|
+
|
|
95
|
+
agent = AssistantAgent("bot", model_client=client, model_context=LCMContext(llm=client))
|
|
96
|
+
|
|
97
|
+
Google ADK — session service::
|
|
98
|
+
|
|
99
|
+
from openlcm.adapters.google_adk import LCMSessionService # pip install openlcm[google-adk]
|
|
100
|
+
|
|
101
|
+
runner = Runner(agent=my_agent, session_service=LCMSessionService(llm=model))
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
from __future__ import annotations
|
|
105
|
+
|
|
106
|
+
from typing import Any
|
|
107
|
+
|
|
108
|
+
# Top-level imports so `from openlcm.adapters import X` works directly.
|
|
109
|
+
# Each import is wrapped so the package loads even if a framework isn't installed.
|
|
110
|
+
try:
|
|
111
|
+
from .openai import OpenAIMessages
|
|
112
|
+
except ImportError:
|
|
113
|
+
OpenAIMessages = None # type: ignore[assignment,misc]
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
from .anthropic import AnthropicMessages
|
|
117
|
+
except ImportError:
|
|
118
|
+
AnthropicMessages = None # type: ignore[assignment,misc]
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
from .langchain import LangChainMessages
|
|
122
|
+
except ImportError:
|
|
123
|
+
LangChainMessages = None # type: ignore[assignment,misc]
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
from .llamaindex import LlamaIndexMessages
|
|
127
|
+
except ImportError:
|
|
128
|
+
LlamaIndexMessages = None # type: ignore[assignment,misc]
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
from .haystack import HaystackMessages
|
|
132
|
+
except ImportError:
|
|
133
|
+
HaystackMessages = None # type: ignore[assignment,misc]
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
from .gemini import GeminiMessages
|
|
137
|
+
except ImportError:
|
|
138
|
+
GeminiMessages = None # type: ignore[assignment,misc]
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
from .autogen import AutoGenMessages, LCMContext
|
|
142
|
+
except ImportError:
|
|
143
|
+
AutoGenMessages = None # type: ignore[assignment,misc]
|
|
144
|
+
LCMContext = None # type: ignore[assignment,misc]
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
from .langgraph import LCMCheckpointer
|
|
148
|
+
except ImportError:
|
|
149
|
+
LCMCheckpointer = None # type: ignore[assignment,misc]
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
from .crewai import LCMStorage
|
|
153
|
+
except ImportError:
|
|
154
|
+
LCMStorage = None # type: ignore[assignment,misc]
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
from .google_adk import LCMSessionService, lcm_compress_callback
|
|
158
|
+
except ImportError:
|
|
159
|
+
LCMSessionService = None # type: ignore[assignment,misc]
|
|
160
|
+
lcm_compress_callback = None # type: ignore[assignment,misc]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def auto_detect(messages: list) -> Any:
|
|
164
|
+
"""Return the right message converter for the given message list.
|
|
165
|
+
|
|
166
|
+
Inspects the first non-empty element and returns the appropriate converter
|
|
167
|
+
class (with static ``to_lcm`` / ``from_lcm`` methods).
|
|
168
|
+
|
|
169
|
+
Supported detection:
|
|
170
|
+
- LangChain ``BaseMessage`` subclasses → ``LangChainMessages``
|
|
171
|
+
- Anthropic content-block dicts (``type: tool_use``) → ``AnthropicMessages``
|
|
172
|
+
- Haystack ``ChatMessage`` objects → ``HaystackMessages``
|
|
173
|
+
- LlamaIndex ``ChatMessage`` objects → ``LlamaIndexMessages``
|
|
174
|
+
- OpenAI dicts (``tool_calls`` key / plain role+content) → ``OpenAIMessages``
|
|
175
|
+
|
|
176
|
+
Falls back to ``OpenAIMessages`` if the format cannot be determined.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
messages: The conversation list whose format you want to detect.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
A converter class with static ``to_lcm()`` and ``from_lcm()`` methods.
|
|
183
|
+
|
|
184
|
+
Example::
|
|
185
|
+
|
|
186
|
+
from openlcm.adapters import auto_detect
|
|
187
|
+
|
|
188
|
+
conv = openai_client.chat.completions.create(...).choices[0].message
|
|
189
|
+
converter = auto_detect(my_messages)
|
|
190
|
+
lcm = converter.to_lcm(my_messages)
|
|
191
|
+
"""
|
|
192
|
+
if not messages:
|
|
193
|
+
from .openai import OpenAIMessages
|
|
194
|
+
return OpenAIMessages
|
|
195
|
+
|
|
196
|
+
first = next((m for m in messages if m is not None), None)
|
|
197
|
+
if first is None:
|
|
198
|
+
from .openai import OpenAIMessages
|
|
199
|
+
return OpenAIMessages
|
|
200
|
+
|
|
201
|
+
# ── LangChain BaseMessage ─────────────────────────────────────────────────
|
|
202
|
+
try:
|
|
203
|
+
from langchain_core.messages import BaseMessage
|
|
204
|
+
if isinstance(first, BaseMessage):
|
|
205
|
+
from .langchain import LangChainMessages
|
|
206
|
+
return LangChainMessages
|
|
207
|
+
except ImportError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
# ── Haystack ChatMessage ──────────────────────────────────────────────────
|
|
211
|
+
try:
|
|
212
|
+
from haystack.dataclasses import ChatMessage as HaystackCM
|
|
213
|
+
if isinstance(first, HaystackCM):
|
|
214
|
+
from .haystack import HaystackMessages
|
|
215
|
+
return HaystackMessages
|
|
216
|
+
except ImportError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
# ── LlamaIndex ChatMessage ────────────────────────────────────────────────
|
|
220
|
+
try:
|
|
221
|
+
from llama_index.core.llms import ChatMessage as LlamaCM
|
|
222
|
+
if isinstance(first, LlamaCM):
|
|
223
|
+
from .llamaindex import LlamaIndexMessages
|
|
224
|
+
return LlamaIndexMessages
|
|
225
|
+
except ImportError:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
# ── Anthropic content-block style ─────────────────────────────────────────
|
|
229
|
+
if isinstance(first, dict):
|
|
230
|
+
content = first.get("content", "")
|
|
231
|
+
if isinstance(content, list) and content:
|
|
232
|
+
block = content[0]
|
|
233
|
+
if isinstance(block, dict) and block.get("type") in ("text", "tool_use", "tool_result", "image"):
|
|
234
|
+
from .anthropic import AnthropicMessages
|
|
235
|
+
return AnthropicMessages
|
|
236
|
+
|
|
237
|
+
# ── Default: OpenAI dict format ───────────────────────────────────────────
|
|
238
|
+
from .openai import OpenAIMessages
|
|
239
|
+
return OpenAIMessages
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
__all__ = [
|
|
243
|
+
# Auto-detection
|
|
244
|
+
"auto_detect",
|
|
245
|
+
# Message converters
|
|
246
|
+
"OpenAIMessages",
|
|
247
|
+
"AnthropicMessages",
|
|
248
|
+
"LangChainMessages",
|
|
249
|
+
"LlamaIndexMessages",
|
|
250
|
+
"HaystackMessages",
|
|
251
|
+
# Framework lifecycle adapters
|
|
252
|
+
"LCMCheckpointer",
|
|
253
|
+
"LCMStorage",
|
|
254
|
+
"LCMContext",
|
|
255
|
+
"LCMSessionService",
|
|
256
|
+
]
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Anthropic message converter for LCM.
|
|
2
|
+
|
|
3
|
+
Handles Anthropic Claude's message format, including multi-part content blocks,
|
|
4
|
+
tool_use / tool_result pairs, and the separate system parameter.
|
|
5
|
+
|
|
6
|
+
Install: pip install anthropic
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
import anthropic
|
|
11
|
+
from openlcm import LCMEngine
|
|
12
|
+
from openlcm.adapters.anthropic import AnthropicMessages
|
|
13
|
+
|
|
14
|
+
client = anthropic.AsyncAnthropic()
|
|
15
|
+
engine = LCMEngine(model="anthropic/claude-haiku-4-5-20251001")
|
|
16
|
+
engine.bind_session("my-session", context_length=200_000)
|
|
17
|
+
|
|
18
|
+
system = "You are a helpful assistant."
|
|
19
|
+
conv = [] # only user / assistant messages (no system in Anthropic conv list)
|
|
20
|
+
|
|
21
|
+
async def chat(user_msg: str) -> str:
|
|
22
|
+
conv.append({"role": "user", "content": user_msg})
|
|
23
|
+
|
|
24
|
+
# Anthropic keeps system separate — AnthropicMessages handles that
|
|
25
|
+
lcm_msgs = AnthropicMessages.to_lcm(conv, system=system)
|
|
26
|
+
|
|
27
|
+
if engine.should_compress_preflight(lcm_msgs):
|
|
28
|
+
lcm_msgs = await engine.compress(lcm_msgs)
|
|
29
|
+
system_out, conv[:] = AnthropicMessages.from_lcm(lcm_msgs)
|
|
30
|
+
# system_out is the same system string passed in (preserved through LCM)
|
|
31
|
+
|
|
32
|
+
resp = await client.messages.create(
|
|
33
|
+
model="claude-haiku-4-5-20251001",
|
|
34
|
+
max_tokens=4096,
|
|
35
|
+
system=system,
|
|
36
|
+
messages=conv,
|
|
37
|
+
tools=MY_TOOLS,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Append assistant message from response
|
|
41
|
+
conv.append({"role": "assistant", "content": resp.content})
|
|
42
|
+
return next((b.text for b in resp.content if hasattr(b, "text")), "")
|
|
43
|
+
|
|
44
|
+
Tool-calling::
|
|
45
|
+
|
|
46
|
+
# After the model returns tool_use blocks, execute them and append results:
|
|
47
|
+
for block in resp.content:
|
|
48
|
+
if block.type == "tool_use":
|
|
49
|
+
result = run_tool(block.name, block.input)
|
|
50
|
+
conv.append({
|
|
51
|
+
"role": "user",
|
|
52
|
+
"content": [{
|
|
53
|
+
"type": "tool_result",
|
|
54
|
+
"tool_use_id": block.id,
|
|
55
|
+
"content": [{"type": "text", "text": json.dumps(result)}],
|
|
56
|
+
}],
|
|
57
|
+
})
|
|
58
|
+
# Then call chat() again.
|
|
59
|
+
|
|
60
|
+
Notes
|
|
61
|
+
-----
|
|
62
|
+
- Anthropic does NOT use a ``system`` role in the messages list. Pass it via
|
|
63
|
+
``AnthropicMessages.to_lcm(conv, system="...")`` and recover it with the
|
|
64
|
+
second element of ``AnthropicMessages.from_lcm()``'s return value.
|
|
65
|
+
- ``from_lcm()`` returns ``(system_str, messages_list)`` — a 2-tuple — because
|
|
66
|
+
the Anthropic API requires system as a separate top-level parameter.
|
|
67
|
+
- Tool results in Anthropic go back as user-role messages with ``tool_result``
|
|
68
|
+
content blocks. The converter handles this automatically.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
from __future__ import annotations
|
|
72
|
+
|
|
73
|
+
import json
|
|
74
|
+
from typing import Any
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _text_from_block(block: Any) -> str:
|
|
78
|
+
"""Extract plain text from an Anthropic content block (dict or SDK object)."""
|
|
79
|
+
if isinstance(block, dict):
|
|
80
|
+
return block.get("text", "")
|
|
81
|
+
return getattr(block, "text", "") or ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _block_type(block: Any) -> str:
|
|
85
|
+
if isinstance(block, dict):
|
|
86
|
+
return block.get("type", "")
|
|
87
|
+
return getattr(block, "type", "") or ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AnthropicMessages:
|
|
91
|
+
"""Convert between Anthropic SDK message format and LCM internal format.
|
|
92
|
+
|
|
93
|
+
All methods are static — no instantiation needed::
|
|
94
|
+
|
|
95
|
+
lcm = AnthropicMessages.to_lcm(messages, system="You are...")
|
|
96
|
+
system_str, messages = AnthropicMessages.from_lcm(lcm_messages)
|
|
97
|
+
|
|
98
|
+
LCM internal format
|
|
99
|
+
-------------------
|
|
100
|
+
See ``openlcm.adapters.openai.OpenAIMessages`` for the canonical definition.
|
|
101
|
+
Anthropic-specific mappings:
|
|
102
|
+
|
|
103
|
+
- ``tool_use`` content block → assistant message with JSON ``tool_calls``
|
|
104
|
+
- ``tool_result`` user block → ``{"role":"tool", ...}`` message
|
|
105
|
+
- Multi-part text blocks → joined with newline into a single string
|
|
106
|
+
- System message extracted to the ``system=`` kwarg of ``to_lcm()``
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def to_lcm(messages: list, system: str = "") -> list[dict]:
|
|
111
|
+
"""Convert Anthropic messages to LCM internal format.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
messages: The ``messages`` list passed to ``client.messages.create()``.
|
|
115
|
+
Each entry is either a dict or an Anthropic SDK Message object.
|
|
116
|
+
system: The ``system`` parameter from ``client.messages.create()``.
|
|
117
|
+
If provided, it is prepended as a ``{"role":"system"}`` entry.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
LCM internal message list.
|
|
121
|
+
"""
|
|
122
|
+
result: list[dict] = []
|
|
123
|
+
|
|
124
|
+
if system:
|
|
125
|
+
result.append({"role": "system", "content": str(system)})
|
|
126
|
+
|
|
127
|
+
for m in messages:
|
|
128
|
+
role = (m.get("role") if isinstance(m, dict) else getattr(m, "role", "user")) or "user"
|
|
129
|
+
content_raw = m.get("content") if isinstance(m, dict) else getattr(m, "content", "")
|
|
130
|
+
|
|
131
|
+
# ── Simple string content ────────────────────────────────────────
|
|
132
|
+
if isinstance(content_raw, str):
|
|
133
|
+
result.append({"role": str(role), "content": content_raw})
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# ── SDK Message / ContentBlock objects ───────────────────────────
|
|
137
|
+
if not isinstance(content_raw, list):
|
|
138
|
+
# e.g. Anthropic SDK response object — try to get .content
|
|
139
|
+
content_raw = getattr(content_raw, "content", []) or []
|
|
140
|
+
|
|
141
|
+
# ── List of content blocks ───────────────────────────────────────
|
|
142
|
+
if role == "assistant":
|
|
143
|
+
text_parts: list[str] = []
|
|
144
|
+
tool_calls: list[dict] = []
|
|
145
|
+
|
|
146
|
+
for block in content_raw:
|
|
147
|
+
btype = _block_type(block)
|
|
148
|
+
if btype == "text":
|
|
149
|
+
text_parts.append(_text_from_block(block))
|
|
150
|
+
elif btype == "tool_use":
|
|
151
|
+
bid = (block.get("id") if isinstance(block, dict) else getattr(block, "id", "")) or ""
|
|
152
|
+
bname = (block.get("name") if isinstance(block, dict) else getattr(block, "name", "")) or ""
|
|
153
|
+
binp = (block.get("input") if isinstance(block, dict) else getattr(block, "input", {})) or {}
|
|
154
|
+
tool_calls.append({"id": bid, "name": bname, "args": dict(binp), "type": "function"})
|
|
155
|
+
|
|
156
|
+
if tool_calls:
|
|
157
|
+
content_str = json.dumps(
|
|
158
|
+
{"text": "\n".join(text_parts), "tool_calls": tool_calls},
|
|
159
|
+
ensure_ascii=False,
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
content_str = "\n".join(text_parts)
|
|
163
|
+
result.append({"role": "assistant", "content": content_str})
|
|
164
|
+
|
|
165
|
+
elif role == "user":
|
|
166
|
+
text_parts = []
|
|
167
|
+
tool_results: list[dict] = []
|
|
168
|
+
|
|
169
|
+
for block in content_raw:
|
|
170
|
+
btype = _block_type(block)
|
|
171
|
+
if btype == "text":
|
|
172
|
+
text_parts.append(_text_from_block(block))
|
|
173
|
+
elif btype == "tool_result":
|
|
174
|
+
tr_id = (block.get("tool_use_id") if isinstance(block, dict) else getattr(block, "tool_use_id", "")) or ""
|
|
175
|
+
tr_cont = (block.get("content") if isinstance(block, dict) else getattr(block, "content", "")) or ""
|
|
176
|
+
# content may itself be a list of text blocks
|
|
177
|
+
if isinstance(tr_cont, list):
|
|
178
|
+
tr_text = "\n".join(
|
|
179
|
+
_text_from_block(b) for b in tr_cont if _block_type(b) == "text"
|
|
180
|
+
)
|
|
181
|
+
elif isinstance(tr_cont, str):
|
|
182
|
+
tr_text = tr_cont
|
|
183
|
+
else:
|
|
184
|
+
tr_text = str(tr_cont)
|
|
185
|
+
tool_results.append({
|
|
186
|
+
"role": "tool",
|
|
187
|
+
"content": tr_text,
|
|
188
|
+
"tool_call_id": tr_id,
|
|
189
|
+
"name": "",
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
# Tool results come first; any remaining text becomes a user message
|
|
193
|
+
result.extend(tool_results)
|
|
194
|
+
if text_parts:
|
|
195
|
+
result.append({"role": "user", "content": "\n".join(text_parts)})
|
|
196
|
+
|
|
197
|
+
else:
|
|
198
|
+
# system role inside messages list (uncommon but handle it)
|
|
199
|
+
text = "\n".join(
|
|
200
|
+
_text_from_block(b) for b in content_raw if _block_type(b) == "text"
|
|
201
|
+
) or str(content_raw)
|
|
202
|
+
result.append({"role": str(role), "content": text})
|
|
203
|
+
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def from_lcm(messages: list[dict]) -> tuple[str, list[dict]]:
|
|
208
|
+
"""Convert LCM internal format to Anthropic-ready messages.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
messages: LCM internal message list (from ``engine.compress()``).
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
``(system_str, anthropic_messages)`` where:
|
|
215
|
+
- ``system_str`` is the value for ``client.messages.create(system=...)``.
|
|
216
|
+
- ``anthropic_messages`` is the value for ``client.messages.create(messages=...)``.
|
|
217
|
+
|
|
218
|
+
Usage::
|
|
219
|
+
|
|
220
|
+
system, msgs = AnthropicMessages.from_lcm(lcm_messages)
|
|
221
|
+
resp = await client.messages.create(system=system, messages=msgs, ...)
|
|
222
|
+
"""
|
|
223
|
+
system = ""
|
|
224
|
+
result: list[dict] = []
|
|
225
|
+
pending_tool_results: list[dict] = []
|
|
226
|
+
|
|
227
|
+
for m in messages:
|
|
228
|
+
role = (m.get("role") or "user").lower()
|
|
229
|
+
content = m.get("content", "")
|
|
230
|
+
|
|
231
|
+
# ── system ───────────────────────────────────────────────────────
|
|
232
|
+
if role == "system":
|
|
233
|
+
system = content
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Flush accumulated tool_result blocks when a non-tool message arrives
|
|
237
|
+
if pending_tool_results and role != "tool":
|
|
238
|
+
result.append({"role": "user", "content": pending_tool_results})
|
|
239
|
+
pending_tool_results = []
|
|
240
|
+
|
|
241
|
+
# ── assistant ────────────────────────────────────────────────────
|
|
242
|
+
if role == "assistant":
|
|
243
|
+
try:
|
|
244
|
+
parsed = json.loads(content)
|
|
245
|
+
if isinstance(parsed, dict) and "tool_calls" in parsed:
|
|
246
|
+
blocks: list[dict] = []
|
|
247
|
+
text = parsed.get("text", "")
|
|
248
|
+
if text:
|
|
249
|
+
blocks.append({"type": "text", "text": text})
|
|
250
|
+
for tc in parsed["tool_calls"]:
|
|
251
|
+
blocks.append({
|
|
252
|
+
"type": "tool_use",
|
|
253
|
+
"id": tc.get("id", ""),
|
|
254
|
+
"name": tc.get("name", ""),
|
|
255
|
+
"input": tc.get("args", {}),
|
|
256
|
+
})
|
|
257
|
+
result.append({"role": "assistant", "content": blocks})
|
|
258
|
+
continue
|
|
259
|
+
except (ValueError, TypeError):
|
|
260
|
+
pass
|
|
261
|
+
result.append({"role": "assistant", "content": content})
|
|
262
|
+
|
|
263
|
+
# ── tool result ──────────────────────────────────────────────────
|
|
264
|
+
elif role == "tool":
|
|
265
|
+
# Anthropic wraps tool_result blocks inside a user-role message
|
|
266
|
+
pending_tool_results.append({
|
|
267
|
+
"type": "tool_result",
|
|
268
|
+
"tool_use_id": m.get("tool_call_id", ""),
|
|
269
|
+
"content": [{"type": "text", "text": content}],
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
# ── user ─────────────────────────────────────────────────────────
|
|
273
|
+
elif role == "user":
|
|
274
|
+
result.append({"role": "user", "content": content})
|
|
275
|
+
|
|
276
|
+
else:
|
|
277
|
+
result.append({"role": role, "content": content})
|
|
278
|
+
|
|
279
|
+
# Flush any remaining tool_results
|
|
280
|
+
if pending_tool_results:
|
|
281
|
+
result.append({"role": "user", "content": pending_tool_results})
|
|
282
|
+
|
|
283
|
+
return system, result
|