claude-code-langchain 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.
- claude_code_langchain/__init__.py +37 -0
- claude_code_langchain/chat_models.py +759 -0
- claude_code_langchain/py.typed +0 -0
- claude_code_langchain/tools.py +42 -0
- claude_code_langchain-0.1.0.dist-info/METADATA +254 -0
- claude_code_langchain-0.1.0.dist-info/RECORD +8 -0
- claude_code_langchain-0.1.0.dist-info/WHEEL +4 -0
- claude_code_langchain-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""LangChain integration for Claude Code, built on the Claude Agent SDK."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
from claude_code_langchain.chat_models import (
|
|
6
|
+
ChatClaudeCode,
|
|
7
|
+
convert_messages,
|
|
8
|
+
convert_to_claude_tool,
|
|
9
|
+
)
|
|
10
|
+
from claude_code_langchain.tools import (
|
|
11
|
+
ALL_TOOLS,
|
|
12
|
+
NETWORK_TOOLS,
|
|
13
|
+
READ_ONLY_TOOLS,
|
|
14
|
+
SHELL_TOOLS,
|
|
15
|
+
WRITE_TOOLS,
|
|
16
|
+
ClaudeTool,
|
|
17
|
+
normalize_tools,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = metadata.version(__package__ or "claude-code-langchain")
|
|
22
|
+
except metadata.PackageNotFoundError: # pragma: no cover, local checkouts
|
|
23
|
+
__version__ = "0.0.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ALL_TOOLS",
|
|
27
|
+
"NETWORK_TOOLS",
|
|
28
|
+
"READ_ONLY_TOOLS",
|
|
29
|
+
"SHELL_TOOLS",
|
|
30
|
+
"WRITE_TOOLS",
|
|
31
|
+
"ChatClaudeCode",
|
|
32
|
+
"ClaudeTool",
|
|
33
|
+
"__version__",
|
|
34
|
+
"convert_messages",
|
|
35
|
+
"convert_to_claude_tool",
|
|
36
|
+
"normalize_tools",
|
|
37
|
+
]
|
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
|
6
|
+
from operator import itemgetter
|
|
7
|
+
from typing import Any, Literal, cast
|
|
8
|
+
|
|
9
|
+
from claude_agent_sdk import (
|
|
10
|
+
AssistantMessage,
|
|
11
|
+
ClaudeAgentOptions,
|
|
12
|
+
ResultMessage,
|
|
13
|
+
SdkMcpTool,
|
|
14
|
+
StreamEvent,
|
|
15
|
+
TextBlock,
|
|
16
|
+
ThinkingBlock,
|
|
17
|
+
ToolUseBlock,
|
|
18
|
+
create_sdk_mcp_server,
|
|
19
|
+
query,
|
|
20
|
+
)
|
|
21
|
+
from langchain_core.callbacks import (
|
|
22
|
+
AsyncCallbackManagerForLLMRun,
|
|
23
|
+
CallbackManagerForLLMRun,
|
|
24
|
+
)
|
|
25
|
+
from langchain_core.language_models import LanguageModelInput
|
|
26
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
27
|
+
from langchain_core.messages import (
|
|
28
|
+
AIMessage,
|
|
29
|
+
AIMessageChunk,
|
|
30
|
+
BaseMessage,
|
|
31
|
+
HumanMessage,
|
|
32
|
+
SystemMessage,
|
|
33
|
+
ToolMessage,
|
|
34
|
+
)
|
|
35
|
+
from langchain_core.messages.ai import UsageMetadata
|
|
36
|
+
from langchain_core.messages.tool import ToolCall
|
|
37
|
+
from langchain_core.messages.tool import tool_call as create_tool_call
|
|
38
|
+
from langchain_core.messages.tool import tool_call_chunk as create_tool_call_chunk
|
|
39
|
+
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
|
40
|
+
from langchain_core.runnables import Runnable, RunnableLambda, RunnablePassthrough
|
|
41
|
+
from langchain_core.tools import BaseTool
|
|
42
|
+
from langchain_core.utils.function_calling import convert_to_openai_tool
|
|
43
|
+
from pydantic import BaseModel, Field
|
|
44
|
+
|
|
45
|
+
from claude_code_langchain.tools import ClaudeTool, normalize_tools
|
|
46
|
+
|
|
47
|
+
MCP_SERVER_NAME = "langchain"
|
|
48
|
+
"""Name of the in-process MCP server used to expose ``bind_tools`` tools."""
|
|
49
|
+
|
|
50
|
+
TOOL_PREFIX = f"mcp__{MCP_SERVER_NAME}__"
|
|
51
|
+
"""Prefix the CLI gives to tools served by the in-process MCP server."""
|
|
52
|
+
|
|
53
|
+
_TOOL_HELD = (
|
|
54
|
+
"The tool call was captured by the host application. Stop here; the result "
|
|
55
|
+
"will be provided in a later turn."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _image_block(item: dict[str, Any]) -> dict[str, Any] | None:
|
|
60
|
+
"""Convert a LangChain image block to an Anthropic image block."""
|
|
61
|
+
if item.get("type") == "image_url": # LangChain v0 style
|
|
62
|
+
image = item.get("image_url", {})
|
|
63
|
+
url = image if isinstance(image, str) else image.get("url", "")
|
|
64
|
+
if url.startswith("data:"):
|
|
65
|
+
header, _, data = url.partition(",")
|
|
66
|
+
media_type = header.split(":", 1)[-1].split(";")[0]
|
|
67
|
+
return {
|
|
68
|
+
"type": "image",
|
|
69
|
+
"source": {
|
|
70
|
+
"type": "base64",
|
|
71
|
+
"media_type": media_type,
|
|
72
|
+
"data": data,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
return {"type": "image", "source": {"type": "url", "url": url}}
|
|
76
|
+
|
|
77
|
+
if item.get("type") != "image":
|
|
78
|
+
return None
|
|
79
|
+
if "source" in item: # already an Anthropic block
|
|
80
|
+
return item
|
|
81
|
+
if item.get("base64"): # LangChain v1 standard block
|
|
82
|
+
return {
|
|
83
|
+
"type": "image",
|
|
84
|
+
"source": {
|
|
85
|
+
"type": "base64",
|
|
86
|
+
"media_type": item.get("mime_type", "image/png"),
|
|
87
|
+
"data": item["base64"],
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
if item.get("url"):
|
|
91
|
+
return {"type": "image", "source": {"type": "url", "url": item["url"]}}
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _message_text(message: BaseMessage) -> str:
|
|
96
|
+
"""Best-effort plain text for a message, ignoring non-text blocks."""
|
|
97
|
+
content = message.content
|
|
98
|
+
if isinstance(content, str):
|
|
99
|
+
return content
|
|
100
|
+
parts: list[str] = []
|
|
101
|
+
for item in content:
|
|
102
|
+
if isinstance(item, str):
|
|
103
|
+
parts.append(item)
|
|
104
|
+
elif isinstance(item, dict) and item.get("type") == "text":
|
|
105
|
+
parts.append(item.get("text", ""))
|
|
106
|
+
return "".join(parts)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _label(message: BaseMessage) -> str:
|
|
110
|
+
if isinstance(message, HumanMessage):
|
|
111
|
+
return "Human"
|
|
112
|
+
if isinstance(message, AIMessage):
|
|
113
|
+
return "Assistant"
|
|
114
|
+
if isinstance(message, ToolMessage):
|
|
115
|
+
return "Tool result"
|
|
116
|
+
return message.type.capitalize()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _render_turn(message: BaseMessage) -> str:
|
|
120
|
+
"""Render a non-system message as a labelled transcript entry."""
|
|
121
|
+
text = _message_text(message)
|
|
122
|
+
if isinstance(message, AIMessage) and message.tool_calls:
|
|
123
|
+
calls = "\n".join(
|
|
124
|
+
f"[called tool {call['name']} with {json.dumps(call['args'])}]"
|
|
125
|
+
for call in message.tool_calls
|
|
126
|
+
)
|
|
127
|
+
text = f"{text}\n{calls}".strip()
|
|
128
|
+
if isinstance(message, ToolMessage):
|
|
129
|
+
return f"Tool result for {message.tool_call_id}:\n{text}"
|
|
130
|
+
return f"{_label(message)}: {text}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def convert_messages(
|
|
134
|
+
messages: Sequence[BaseMessage],
|
|
135
|
+
) -> tuple[str | None, list[dict[str, Any]]]:
|
|
136
|
+
"""Convert LangChain messages to a system prompt plus one user message.
|
|
137
|
+
|
|
138
|
+
The Claude Agent SDK drives a stateful CLI session rather than a stateless
|
|
139
|
+
chat completion, so the conversation is flattened into a single user turn.
|
|
140
|
+
Returns ``(system_prompt, content_blocks)``.
|
|
141
|
+
"""
|
|
142
|
+
system_parts: list[str] = []
|
|
143
|
+
turns: list[BaseMessage] = []
|
|
144
|
+
for message in messages:
|
|
145
|
+
if isinstance(message, SystemMessage):
|
|
146
|
+
system_parts.append(_message_text(message))
|
|
147
|
+
else:
|
|
148
|
+
turns.append(message)
|
|
149
|
+
|
|
150
|
+
images: list[dict[str, Any]] = []
|
|
151
|
+
for message in turns:
|
|
152
|
+
if isinstance(message.content, list):
|
|
153
|
+
for item in message.content:
|
|
154
|
+
if isinstance(item, dict):
|
|
155
|
+
block = _image_block(item)
|
|
156
|
+
if block is not None:
|
|
157
|
+
images.append(block)
|
|
158
|
+
|
|
159
|
+
if len(turns) == 1 and isinstance(turns[0], HumanMessage):
|
|
160
|
+
text = _message_text(turns[0])
|
|
161
|
+
else:
|
|
162
|
+
text = "\n\n".join(_render_turn(message) for message in turns)
|
|
163
|
+
|
|
164
|
+
content: list[dict[str, Any]] = [*images, {"type": "text", "text": text}]
|
|
165
|
+
return ("\n\n".join(p for p in system_parts if p) or None, content)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def convert_to_claude_tool(
|
|
169
|
+
tool: dict[str, Any] | type | Callable[..., Any] | BaseTool,
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
"""Convert a LangChain tool to an Anthropic-style tool schema."""
|
|
172
|
+
if isinstance(tool, dict) and "input_schema" in tool:
|
|
173
|
+
return tool
|
|
174
|
+
function = convert_to_openai_tool(tool)["function"]
|
|
175
|
+
schema = function.get("parameters") or {"type": "object", "properties": {}}
|
|
176
|
+
schema.setdefault("type", "object")
|
|
177
|
+
schema.setdefault("properties", {})
|
|
178
|
+
return {
|
|
179
|
+
"name": function["name"],
|
|
180
|
+
"description": function.get("description", ""),
|
|
181
|
+
"input_schema": schema,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _json_schema(schema: dict[str, Any] | type) -> dict[str, Any]:
|
|
186
|
+
"""Extract a plain JSON schema from a dict, TypedDict or Pydantic model."""
|
|
187
|
+
if isinstance(schema, dict) and schema.get("type") == "object":
|
|
188
|
+
return schema
|
|
189
|
+
return convert_to_claude_tool(schema)["input_schema"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _mcp_server(schemas: list[dict[str, Any]]) -> Any:
|
|
193
|
+
"""Build an in-process MCP server exposing the bound tool schemas.
|
|
194
|
+
|
|
195
|
+
The handlers are only reached if the CLI executes a call before the caller
|
|
196
|
+
stops consuming the stream; they never produce a real result, since tool
|
|
197
|
+
execution belongs to the LangChain caller.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def make(schema: dict[str, Any]) -> SdkMcpTool[Any]:
|
|
201
|
+
async def handler(_args: Any) -> dict[str, Any]:
|
|
202
|
+
return {"content": [{"type": "text", "text": _TOOL_HELD}]}
|
|
203
|
+
|
|
204
|
+
return SdkMcpTool(
|
|
205
|
+
name=schema["name"],
|
|
206
|
+
description=schema.get("description", ""),
|
|
207
|
+
input_schema=schema["input_schema"],
|
|
208
|
+
handler=handler,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return create_sdk_mcp_server(MCP_SERVER_NAME, tools=[make(s) for s in schemas])
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _usage_metadata(usage: dict[str, Any] | None) -> UsageMetadata | None:
|
|
215
|
+
if not usage:
|
|
216
|
+
return None
|
|
217
|
+
cache_read = usage.get("cache_read_input_tokens", 0) or 0
|
|
218
|
+
cache_creation = usage.get("cache_creation_input_tokens", 0) or 0
|
|
219
|
+
input_tokens = (usage.get("input_tokens", 0) or 0) + cache_read + cache_creation
|
|
220
|
+
output_tokens = usage.get("output_tokens", 0) or 0
|
|
221
|
+
metadata: UsageMetadata = {
|
|
222
|
+
"input_tokens": input_tokens,
|
|
223
|
+
"output_tokens": output_tokens,
|
|
224
|
+
"total_tokens": input_tokens + output_tokens,
|
|
225
|
+
}
|
|
226
|
+
if cache_read or cache_creation:
|
|
227
|
+
metadata["input_token_details"] = {
|
|
228
|
+
"cache_read": cache_read,
|
|
229
|
+
"cache_creation": cache_creation,
|
|
230
|
+
}
|
|
231
|
+
return metadata
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class _Turn:
|
|
235
|
+
"""Everything collected from one Claude Agent SDK query."""
|
|
236
|
+
|
|
237
|
+
def __init__(self) -> None:
|
|
238
|
+
self.text: list[str] = []
|
|
239
|
+
self.thinking: list[str] = []
|
|
240
|
+
self.tool_calls: list[ToolCall] = []
|
|
241
|
+
self.result: ResultMessage | None = None
|
|
242
|
+
self.usage: dict[str, Any] | None = None
|
|
243
|
+
self.model: str | None = None
|
|
244
|
+
self.session_id: str | None = None
|
|
245
|
+
|
|
246
|
+
def add_assistant(self, message: AssistantMessage) -> None:
|
|
247
|
+
"""Record an assistant message."""
|
|
248
|
+
if self.tool_calls:
|
|
249
|
+
return
|
|
250
|
+
self.model = message.model or self.model
|
|
251
|
+
self.usage = message.usage or self.usage
|
|
252
|
+
self.session_id = message.session_id or self.session_id
|
|
253
|
+
for block in message.content:
|
|
254
|
+
if isinstance(block, TextBlock):
|
|
255
|
+
self.text.append(block.text)
|
|
256
|
+
elif isinstance(block, ThinkingBlock) and block.thinking:
|
|
257
|
+
self.thinking.append(block.thinking)
|
|
258
|
+
elif isinstance(block, ToolUseBlock) and block.name.startswith(TOOL_PREFIX):
|
|
259
|
+
self.tool_calls.append(
|
|
260
|
+
create_tool_call(
|
|
261
|
+
name=block.name[len(TOOL_PREFIX) :],
|
|
262
|
+
args=block.input,
|
|
263
|
+
id=block.id,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def add_result(self, message: ResultMessage) -> None:
|
|
268
|
+
self.result = message
|
|
269
|
+
if not self.tool_calls:
|
|
270
|
+
self.usage = message.usage or self.usage
|
|
271
|
+
|
|
272
|
+
def to_message(self) -> AIMessage:
|
|
273
|
+
text = "".join(self.text)
|
|
274
|
+
content: str | list[str | dict[Any, Any]] = text
|
|
275
|
+
if self.thinking:
|
|
276
|
+
content = [
|
|
277
|
+
*(
|
|
278
|
+
{"type": "reasoning", "reasoning": thought}
|
|
279
|
+
for thought in self.thinking
|
|
280
|
+
),
|
|
281
|
+
{"type": "text", "text": text},
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
metadata: dict[str, Any] = {
|
|
285
|
+
"model_name": self.model,
|
|
286
|
+
"session_id": self.session_id,
|
|
287
|
+
}
|
|
288
|
+
extra: dict[str, Any] = {}
|
|
289
|
+
result = self.result
|
|
290
|
+
if result is not None:
|
|
291
|
+
metadata.update(
|
|
292
|
+
session_id=result.session_id,
|
|
293
|
+
num_turns=result.num_turns,
|
|
294
|
+
duration_ms=result.duration_ms,
|
|
295
|
+
is_error=result.is_error,
|
|
296
|
+
stop_reason=result.stop_reason,
|
|
297
|
+
total_cost_usd=result.total_cost_usd,
|
|
298
|
+
)
|
|
299
|
+
if result.structured_output is not None:
|
|
300
|
+
extra["structured_output"] = result.structured_output
|
|
301
|
+
|
|
302
|
+
return AIMessage(
|
|
303
|
+
content=content,
|
|
304
|
+
tool_calls=self.tool_calls,
|
|
305
|
+
response_metadata={k: v for k, v in metadata.items() if v is not None},
|
|
306
|
+
additional_kwargs=extra,
|
|
307
|
+
usage_metadata=_usage_metadata(self.usage),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _run_sync(factory: Callable[[], Any]) -> Any:
|
|
312
|
+
"""Run a coroutine from sync code, even inside a running event loop."""
|
|
313
|
+
result: list[Any] = [None]
|
|
314
|
+
error: list[BaseException | None] = [None]
|
|
315
|
+
|
|
316
|
+
def target() -> None:
|
|
317
|
+
try:
|
|
318
|
+
result[0] = asyncio.run(factory())
|
|
319
|
+
except BaseException as exc: # noqa: BLE001 - re-raised below
|
|
320
|
+
error[0] = exc
|
|
321
|
+
|
|
322
|
+
thread = threading.Thread(target=target, daemon=True)
|
|
323
|
+
thread.start()
|
|
324
|
+
thread.join()
|
|
325
|
+
if error[0] is not None:
|
|
326
|
+
raise error[0]
|
|
327
|
+
return result[0]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _iter_sync(factory: Callable[[], AsyncIterator[Any]]) -> Iterator[Any]:
|
|
331
|
+
"""Drain an async iterator from sync code via a worker thread."""
|
|
332
|
+
done = object()
|
|
333
|
+
items: queue.Queue[Any] = queue.Queue()
|
|
334
|
+
|
|
335
|
+
async def pump() -> None:
|
|
336
|
+
try:
|
|
337
|
+
async for item in factory():
|
|
338
|
+
items.put(item)
|
|
339
|
+
except BaseException as exc: # noqa: BLE001 - re-raised below
|
|
340
|
+
items.put(exc)
|
|
341
|
+
finally:
|
|
342
|
+
items.put(done)
|
|
343
|
+
|
|
344
|
+
thread = threading.Thread(target=lambda: asyncio.run(pump()), daemon=True)
|
|
345
|
+
thread.start()
|
|
346
|
+
try:
|
|
347
|
+
while True:
|
|
348
|
+
item = items.get()
|
|
349
|
+
if item is done:
|
|
350
|
+
break
|
|
351
|
+
if isinstance(item, BaseException):
|
|
352
|
+
raise item
|
|
353
|
+
yield item
|
|
354
|
+
finally:
|
|
355
|
+
thread.join()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class ChatClaudeCode(BaseChatModel):
|
|
359
|
+
"""Claude models served through the Claude Code CLI (Claude Agent SDK).
|
|
360
|
+
|
|
361
|
+
Requires the ``claude`` CLI to be installed and authenticated. Authentication is
|
|
362
|
+
delegated to the CLI.
|
|
363
|
+
|
|
364
|
+
Setup:
|
|
365
|
+
.. code-block:: bash
|
|
366
|
+
|
|
367
|
+
npm install -g @anthropic-ai/claude-code
|
|
368
|
+
claude # sign in once
|
|
369
|
+
pip install claude-code-langchain
|
|
370
|
+
|
|
371
|
+
Basic use:
|
|
372
|
+
.. code-block:: python
|
|
373
|
+
|
|
374
|
+
from claude_code_langchain import ChatClaudeCode
|
|
375
|
+
|
|
376
|
+
llm = ChatClaudeCode(model="claude-opus-5")
|
|
377
|
+
llm.invoke("What is the capital of France?")
|
|
378
|
+
|
|
379
|
+
Tool calling:
|
|
380
|
+
.. code-block:: python
|
|
381
|
+
|
|
382
|
+
llm.bind_tools([get_weather]).invoke("Weather in Paris?").tool_calls
|
|
383
|
+
|
|
384
|
+
Structured output:
|
|
385
|
+
.. code-block:: python
|
|
386
|
+
|
|
387
|
+
llm.with_structured_output(Person).invoke("Bob is 42.")
|
|
388
|
+
|
|
389
|
+
Agentic mode (Claude Code's own tools):
|
|
390
|
+
.. code-block:: python
|
|
391
|
+
|
|
392
|
+
from claude_code_langchain import READ_ONLY_TOOLS, ClaudeTool
|
|
393
|
+
|
|
394
|
+
agent = ChatClaudeCode(
|
|
395
|
+
builtin_tools=READ_ONLY_TOOLS,
|
|
396
|
+
allowed_tools=READ_ONLY_TOOLS,
|
|
397
|
+
max_turns=10,
|
|
398
|
+
cwd="/path/to/project",
|
|
399
|
+
)
|
|
400
|
+
agent.invoke("Find every TODO comment in this project")
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
model: str | None = None
|
|
404
|
+
"""Model id or alias (e.g. ``"claude-opus-5"``, ``"sonnet"``).
|
|
405
|
+
|
|
406
|
+
``None`` uses whatever model the CLI is configured to use.
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
system_prompt: str | dict[str, Any] | None = None
|
|
410
|
+
"""System prompt used when the messages contain no ``SystemMessage``.
|
|
411
|
+
|
|
412
|
+
Also accepts the SDK preset form,
|
|
413
|
+
``{"type": "preset", "preset": "claude_code"}``.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
thinking: dict[str, Any] | None = None
|
|
417
|
+
"""Extended thinking config, e.g. ``{"type": "adaptive"}``."""
|
|
418
|
+
|
|
419
|
+
effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None
|
|
420
|
+
"""How much effort Claude spends on the response."""
|
|
421
|
+
|
|
422
|
+
max_turns: int | None = None
|
|
423
|
+
"""Maximum agent turns per call. Defaults to 1 (a single model response)."""
|
|
424
|
+
|
|
425
|
+
max_budget_usd: float | None = None
|
|
426
|
+
"""Stop the query once this much has been spent."""
|
|
427
|
+
|
|
428
|
+
builtin_tools: list[str | ClaudeTool] | Literal["claude_code"] | None = None
|
|
429
|
+
"""Claude Code tools made available to the model.
|
|
430
|
+
|
|
431
|
+
``None`` (default) disables all built-in tools, giving plain chat behaviour.
|
|
432
|
+
``"claude_code"`` enables the full Claude Code preset.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
allowed_tools: list[str | ClaudeTool] | None = None
|
|
436
|
+
"""Tools that may run without asking for permission."""
|
|
437
|
+
|
|
438
|
+
disallowed_tools: list[str | ClaudeTool] | None = None
|
|
439
|
+
"""Tools that are removed from the model's context entirely."""
|
|
440
|
+
|
|
441
|
+
permission_mode: (
|
|
442
|
+
Literal["default", "acceptEdits", "plan", "bypassPermissions", "dontAsk"] | None
|
|
443
|
+
) = None
|
|
444
|
+
"""Permission mode for tool execution."""
|
|
445
|
+
|
|
446
|
+
mcp_servers: dict[str, Any] = Field(default_factory=dict)
|
|
447
|
+
"""Extra MCP servers to expose to the session."""
|
|
448
|
+
|
|
449
|
+
cwd: str | None = None
|
|
450
|
+
"""Working directory of the CLI session."""
|
|
451
|
+
|
|
452
|
+
add_dirs: list[str] = Field(default_factory=list)
|
|
453
|
+
"""Additional directories the session may access."""
|
|
454
|
+
|
|
455
|
+
cli_path: str | None = None
|
|
456
|
+
"""Path to the ``claude`` executable. Defaults to the bundled CLI."""
|
|
457
|
+
|
|
458
|
+
settings: str | None = None
|
|
459
|
+
"""Path to an extra settings JSON file."""
|
|
460
|
+
|
|
461
|
+
setting_sources: list[Literal["user", "project", "local"]] | None = Field(
|
|
462
|
+
default_factory=list
|
|
463
|
+
)
|
|
464
|
+
"""Filesystem settings to load. Defaults to ``[]`` (isolated); ``None``
|
|
465
|
+
loads user, project and local settings like the interactive CLI."""
|
|
466
|
+
|
|
467
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
468
|
+
"""Environment variables for the CLI subprocess."""
|
|
469
|
+
|
|
470
|
+
extra_args: dict[str, str | None] = Field(default_factory=dict)
|
|
471
|
+
"""Extra CLI flags, e.g. ``{"verbose": None}``."""
|
|
472
|
+
|
|
473
|
+
session_id: str | None = None
|
|
474
|
+
"""Resume a previous CLI session by id."""
|
|
475
|
+
|
|
476
|
+
fork_session: bool = False
|
|
477
|
+
"""When resuming, branch into a new session instead of continuing."""
|
|
478
|
+
|
|
479
|
+
streaming: bool = False
|
|
480
|
+
"""Stream by default when the caller does not ask for it explicitly."""
|
|
481
|
+
|
|
482
|
+
max_tokens: int | None = None
|
|
483
|
+
"""Accepted for ``ChatAnthropic`` compatibility; the CLI manages this."""
|
|
484
|
+
|
|
485
|
+
temperature: float | None = None
|
|
486
|
+
"""Accepted for ``ChatAnthropic`` compatibility; not supported by the CLI."""
|
|
487
|
+
|
|
488
|
+
@property
|
|
489
|
+
def _llm_type(self) -> str:
|
|
490
|
+
return "claude-code"
|
|
491
|
+
|
|
492
|
+
@property
|
|
493
|
+
def _identifying_params(self) -> dict[str, Any]:
|
|
494
|
+
return {
|
|
495
|
+
"model": self.model,
|
|
496
|
+
"effort": self.effort,
|
|
497
|
+
"thinking": self.thinking,
|
|
498
|
+
"max_turns": self.max_turns,
|
|
499
|
+
"permission_mode": self.permission_mode,
|
|
500
|
+
"builtin_tools": self.builtin_tools,
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
def _build_options(
|
|
504
|
+
self,
|
|
505
|
+
*,
|
|
506
|
+
system_prompt: str | None,
|
|
507
|
+
partial_messages: bool = False,
|
|
508
|
+
**kwargs: Any,
|
|
509
|
+
) -> ClaudeAgentOptions:
|
|
510
|
+
schemas: list[dict[str, Any]] = kwargs.get("tools") or []
|
|
511
|
+
allowed = normalize_tools(list(self.allowed_tools or []))
|
|
512
|
+
mcp_servers = dict(self.mcp_servers)
|
|
513
|
+
max_turns = self.max_turns or 1
|
|
514
|
+
if schemas:
|
|
515
|
+
mcp_servers[MCP_SERVER_NAME] = _mcp_server(schemas)
|
|
516
|
+
allowed += [f"{TOOL_PREFIX}{schema['name']}" for schema in schemas]
|
|
517
|
+
# The caller executes tool calls, so the CLI must not keep going
|
|
518
|
+
# after it has requested one.
|
|
519
|
+
max_turns = 1
|
|
520
|
+
|
|
521
|
+
if self.builtin_tools == "claude_code":
|
|
522
|
+
builtin: Any = {"type": "preset", "preset": "claude_code"}
|
|
523
|
+
elif self.builtin_tools:
|
|
524
|
+
builtin = normalize_tools(list(self.builtin_tools))
|
|
525
|
+
else:
|
|
526
|
+
builtin = []
|
|
527
|
+
|
|
528
|
+
options = ClaudeAgentOptions(
|
|
529
|
+
model=kwargs.get("model", self.model),
|
|
530
|
+
system_prompt=cast(Any, system_prompt or self.system_prompt),
|
|
531
|
+
tools=builtin,
|
|
532
|
+
allowed_tools=allowed,
|
|
533
|
+
disallowed_tools=normalize_tools(list(self.disallowed_tools or [])),
|
|
534
|
+
mcp_servers=mcp_servers,
|
|
535
|
+
permission_mode=self.permission_mode,
|
|
536
|
+
max_turns=max_turns,
|
|
537
|
+
max_budget_usd=self.max_budget_usd,
|
|
538
|
+
thinking=cast(Any, self.thinking),
|
|
539
|
+
effort=self.effort,
|
|
540
|
+
output_format=kwargs.get("output_format"),
|
|
541
|
+
cwd=self.cwd,
|
|
542
|
+
add_dirs=list(self.add_dirs),
|
|
543
|
+
cli_path=self.cli_path,
|
|
544
|
+
settings=self.settings,
|
|
545
|
+
setting_sources=self.setting_sources,
|
|
546
|
+
env=dict(self.env),
|
|
547
|
+
extra_args=dict(self.extra_args),
|
|
548
|
+
include_partial_messages=partial_messages,
|
|
549
|
+
fork_session=self.fork_session,
|
|
550
|
+
)
|
|
551
|
+
session_id = kwargs.get("session_id", self.session_id)
|
|
552
|
+
if session_id:
|
|
553
|
+
options.resume = session_id
|
|
554
|
+
return options
|
|
555
|
+
|
|
556
|
+
def _prompt_and_options(
|
|
557
|
+
self,
|
|
558
|
+
messages: list[BaseMessage],
|
|
559
|
+
*,
|
|
560
|
+
partial_messages: bool = False,
|
|
561
|
+
**kwargs: Any,
|
|
562
|
+
) -> tuple[Any, ClaudeAgentOptions]:
|
|
563
|
+
system_prompt, content = convert_messages(messages)
|
|
564
|
+
options = self._build_options(
|
|
565
|
+
system_prompt=system_prompt,
|
|
566
|
+
partial_messages=partial_messages,
|
|
567
|
+
**kwargs,
|
|
568
|
+
)
|
|
569
|
+
if len(content) == 1: # text only
|
|
570
|
+
return content[0]["text"], options
|
|
571
|
+
|
|
572
|
+
async def stream() -> AsyncIterator[dict[str, Any]]:
|
|
573
|
+
yield {"type": "user", "message": {"role": "user", "content": content}}
|
|
574
|
+
|
|
575
|
+
return stream(), options
|
|
576
|
+
|
|
577
|
+
async def _acollect(self, messages: list[BaseMessage], **kwargs: Any) -> _Turn:
|
|
578
|
+
prompt, options = self._prompt_and_options(messages, **kwargs)
|
|
579
|
+
turn = _Turn()
|
|
580
|
+
stream: Any = query(prompt=prompt, options=options)
|
|
581
|
+
try:
|
|
582
|
+
async for message in stream:
|
|
583
|
+
if isinstance(message, AssistantMessage):
|
|
584
|
+
turn.add_assistant(message)
|
|
585
|
+
if turn.tool_calls:
|
|
586
|
+
# Executing the tool is the caller's job, so end the CLI
|
|
587
|
+
# turn here instead of letting it answer itself.
|
|
588
|
+
break
|
|
589
|
+
elif isinstance(message, ResultMessage):
|
|
590
|
+
turn.add_result(message)
|
|
591
|
+
finally:
|
|
592
|
+
await stream.aclose()
|
|
593
|
+
return turn
|
|
594
|
+
|
|
595
|
+
def _generate(
|
|
596
|
+
self,
|
|
597
|
+
messages: list[BaseMessage],
|
|
598
|
+
stop: list[str] | None = None,
|
|
599
|
+
run_manager: CallbackManagerForLLMRun | None = None,
|
|
600
|
+
**kwargs: Any,
|
|
601
|
+
) -> ChatResult:
|
|
602
|
+
turn = _run_sync(lambda: self._acollect(messages, **kwargs))
|
|
603
|
+
return ChatResult(generations=[ChatGeneration(message=turn.to_message())])
|
|
604
|
+
|
|
605
|
+
async def _agenerate(
|
|
606
|
+
self,
|
|
607
|
+
messages: list[BaseMessage],
|
|
608
|
+
stop: list[str] | None = None,
|
|
609
|
+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
610
|
+
**kwargs: Any,
|
|
611
|
+
) -> ChatResult:
|
|
612
|
+
turn = await self._acollect(messages, **kwargs)
|
|
613
|
+
return ChatResult(generations=[ChatGeneration(message=turn.to_message())])
|
|
614
|
+
|
|
615
|
+
async def _achunks(
|
|
616
|
+
self, messages: list[BaseMessage], **kwargs: Any
|
|
617
|
+
) -> AsyncIterator[ChatGenerationChunk]:
|
|
618
|
+
if kwargs.get("tools"):
|
|
619
|
+
# Tool calls only materialize as whole blocks; stream them as one chunk.
|
|
620
|
+
turn = await self._acollect(messages, **kwargs)
|
|
621
|
+
message = turn.to_message()
|
|
622
|
+
yield ChatGenerationChunk(
|
|
623
|
+
message=AIMessageChunk(
|
|
624
|
+
content=message.content,
|
|
625
|
+
tool_call_chunks=[
|
|
626
|
+
create_tool_call_chunk(
|
|
627
|
+
name=call["name"],
|
|
628
|
+
args=json.dumps(call["args"]),
|
|
629
|
+
id=call["id"],
|
|
630
|
+
index=index,
|
|
631
|
+
)
|
|
632
|
+
for index, call in enumerate(message.tool_calls)
|
|
633
|
+
],
|
|
634
|
+
response_metadata=message.response_metadata,
|
|
635
|
+
additional_kwargs=message.additional_kwargs,
|
|
636
|
+
usage_metadata=message.usage_metadata,
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
prompt, options = self._prompt_and_options(
|
|
642
|
+
messages, partial_messages=True, **kwargs
|
|
643
|
+
)
|
|
644
|
+
async for sdk_message in query(prompt=prompt, options=options):
|
|
645
|
+
if isinstance(sdk_message, StreamEvent):
|
|
646
|
+
event = sdk_message.event
|
|
647
|
+
if event.get("type") != "content_block_delta":
|
|
648
|
+
continue
|
|
649
|
+
text = (event.get("delta") or {}).get("text", "")
|
|
650
|
+
if text:
|
|
651
|
+
yield ChatGenerationChunk(message=AIMessageChunk(content=text))
|
|
652
|
+
elif isinstance(sdk_message, ResultMessage):
|
|
653
|
+
turn = _Turn()
|
|
654
|
+
turn.add_result(sdk_message)
|
|
655
|
+
final = turn.to_message()
|
|
656
|
+
yield ChatGenerationChunk(
|
|
657
|
+
message=AIMessageChunk(
|
|
658
|
+
content="",
|
|
659
|
+
response_metadata=final.response_metadata,
|
|
660
|
+
additional_kwargs=final.additional_kwargs,
|
|
661
|
+
usage_metadata=final.usage_metadata,
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
def _stream(
|
|
666
|
+
self,
|
|
667
|
+
messages: list[BaseMessage],
|
|
668
|
+
stop: list[str] | None = None,
|
|
669
|
+
run_manager: CallbackManagerForLLMRun | None = None,
|
|
670
|
+
**kwargs: Any,
|
|
671
|
+
) -> Iterator[ChatGenerationChunk]:
|
|
672
|
+
for chunk in _iter_sync(lambda: self._achunks(messages, **kwargs)):
|
|
673
|
+
if run_manager and chunk.text:
|
|
674
|
+
run_manager.on_llm_new_token(chunk.text, chunk=chunk)
|
|
675
|
+
yield chunk
|
|
676
|
+
|
|
677
|
+
async def _astream(
|
|
678
|
+
self,
|
|
679
|
+
messages: list[BaseMessage],
|
|
680
|
+
stop: list[str] | None = None,
|
|
681
|
+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
682
|
+
**kwargs: Any,
|
|
683
|
+
) -> AsyncIterator[ChatGenerationChunk]:
|
|
684
|
+
async for chunk in self._achunks(messages, **kwargs):
|
|
685
|
+
if run_manager and chunk.text:
|
|
686
|
+
await run_manager.on_llm_new_token(chunk.text, chunk=chunk)
|
|
687
|
+
yield chunk
|
|
688
|
+
|
|
689
|
+
def bind_tools(
|
|
690
|
+
self,
|
|
691
|
+
tools: Sequence[dict[str, Any] | type | Callable[..., Any] | BaseTool],
|
|
692
|
+
*,
|
|
693
|
+
tool_choice: str | Literal["auto", "any"] | None = None,
|
|
694
|
+
**kwargs: Any,
|
|
695
|
+
) -> Runnable[LanguageModelInput, AIMessage]:
|
|
696
|
+
"""Bind tools so the model can request them via ``AIMessage.tool_calls``.
|
|
697
|
+
|
|
698
|
+
The tools are exposed to the CLI through an in-process MCP server. The
|
|
699
|
+
model's call is captured and returned to the caller — executing it stays
|
|
700
|
+
the caller's responsibility, as with any other LangChain chat model.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
tools: Tools as ``BaseTool``, Pydantic models, functions or schemas.
|
|
704
|
+
tool_choice: ``"any"`` or a tool name to require a tool call.
|
|
705
|
+
**kwargs: Extra arguments bound to every call.
|
|
706
|
+
"""
|
|
707
|
+
schemas = [convert_to_claude_tool(tool) for tool in tools]
|
|
708
|
+
|
|
709
|
+
hint: str | None = None
|
|
710
|
+
if tool_choice in ("any", "required"):
|
|
711
|
+
hint = "You must call one of the available tools."
|
|
712
|
+
elif isinstance(tool_choice, str) and tool_choice != "auto":
|
|
713
|
+
hint = f"You must call the tool `{tool_choice}`."
|
|
714
|
+
|
|
715
|
+
bound = self
|
|
716
|
+
if hint and isinstance(self.system_prompt, (str, type(None))):
|
|
717
|
+
prompt = f"{self.system_prompt}\n\n{hint}" if self.system_prompt else hint
|
|
718
|
+
bound = self.model_copy(update={"system_prompt": prompt})
|
|
719
|
+
return bound.bind(tools=schemas, **kwargs)
|
|
720
|
+
|
|
721
|
+
def with_structured_output(
|
|
722
|
+
self,
|
|
723
|
+
schema: dict[str, Any] | type,
|
|
724
|
+
*,
|
|
725
|
+
include_raw: bool = False,
|
|
726
|
+
**kwargs: Any,
|
|
727
|
+
) -> Runnable[Any, dict[str, Any] | BaseModel]:
|
|
728
|
+
"""Return a runnable whose output matches ``schema``.
|
|
729
|
+
|
|
730
|
+
Uses the CLI's native JSON-schema output format, so the response is
|
|
731
|
+
constrained rather than merely requested.
|
|
732
|
+
"""
|
|
733
|
+
json_schema = _json_schema(schema)
|
|
734
|
+
llm = self.bind(output_format={"type": "json_schema", "schema": json_schema})
|
|
735
|
+
|
|
736
|
+
model: type[BaseModel] | None = (
|
|
737
|
+
schema
|
|
738
|
+
if isinstance(schema, type) and issubclass(schema, BaseModel)
|
|
739
|
+
else None
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
def parse(message: BaseMessage) -> Any:
|
|
743
|
+
data = message.additional_kwargs.get("structured_output")
|
|
744
|
+
if data is None:
|
|
745
|
+
data = json.loads(str(message.text))
|
|
746
|
+
return model.model_validate(data) if model is not None else data
|
|
747
|
+
|
|
748
|
+
parser = RunnableLambda(parse)
|
|
749
|
+
if not include_raw:
|
|
750
|
+
return llm | parser
|
|
751
|
+
|
|
752
|
+
parse_raw = RunnablePassthrough.assign(
|
|
753
|
+
parsed=itemgetter("raw") | parser,
|
|
754
|
+
parsing_error=lambda _: None,
|
|
755
|
+
)
|
|
756
|
+
parse_failed = RunnablePassthrough.assign(parsed=lambda _: None)
|
|
757
|
+
return {"raw": llm} | parse_raw.with_fallbacks(
|
|
758
|
+
[parse_failed], exception_key="parsing_error"
|
|
759
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ClaudeTool(str, Enum):
|
|
5
|
+
"""Built-in tools exposed by the Claude Code CLI."""
|
|
6
|
+
|
|
7
|
+
ASK_USER_QUESTION = "AskUserQuestion"
|
|
8
|
+
BASH = "Bash"
|
|
9
|
+
BASH_OUTPUT = "BashOutput"
|
|
10
|
+
EDIT = "Edit"
|
|
11
|
+
EXIT_PLAN_MODE = "ExitPlanMode"
|
|
12
|
+
GLOB = "Glob"
|
|
13
|
+
GREP = "Grep"
|
|
14
|
+
KILL_SHELL = "KillShell"
|
|
15
|
+
NOTEBOOK_EDIT = "NotebookEdit"
|
|
16
|
+
READ = "Read"
|
|
17
|
+
SKILL = "Skill"
|
|
18
|
+
SLASH_COMMAND = "SlashCommand"
|
|
19
|
+
TASK = "Task"
|
|
20
|
+
TODO_WRITE = "TodoWrite"
|
|
21
|
+
WEB_FETCH = "WebFetch"
|
|
22
|
+
WEB_SEARCH = "WebSearch"
|
|
23
|
+
WRITE = "Write"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
READ_ONLY_TOOLS = [ClaudeTool.READ, ClaudeTool.GLOB, ClaudeTool.GREP]
|
|
27
|
+
WRITE_TOOLS = [ClaudeTool.EDIT, ClaudeTool.WRITE]
|
|
28
|
+
NETWORK_TOOLS = [ClaudeTool.WEB_FETCH, ClaudeTool.WEB_SEARCH]
|
|
29
|
+
SHELL_TOOLS = [ClaudeTool.BASH, ClaudeTool.BASH_OUTPUT, ClaudeTool.KILL_SHELL]
|
|
30
|
+
ALL_TOOLS = list(ClaudeTool)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_tools(tools: list[str | ClaudeTool]) -> list[str]:
|
|
34
|
+
"""Normalize tool names/enums to a de-duplicated list of strings."""
|
|
35
|
+
seen: set[str] = set()
|
|
36
|
+
result: list[str] = []
|
|
37
|
+
for tool in tools:
|
|
38
|
+
name = tool.value if isinstance(tool, ClaudeTool) else str(tool)
|
|
39
|
+
if name not in seen:
|
|
40
|
+
seen.add(name)
|
|
41
|
+
result.append(name)
|
|
42
|
+
return result
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-code-langchain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain chat model for Claude Code, built on the Claude Agent SDK
|
|
5
|
+
Keywords: langchain,claude,anthropic,claude-code,claude-agent-sdk
|
|
6
|
+
Author: Moésio Filho
|
|
7
|
+
Author-email: Moésio Filho <moesio.f@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Dist: claude-agent-sdk>=0.2.0
|
|
19
|
+
Requires-Dist: langchain-core>=1.0.0,<2.0.0
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Project-URL: Homepage, https://github.com/moesio-f/claude-code-langchain
|
|
22
|
+
Project-URL: Repository, https://github.com/moesio-f/claude-code-langchain
|
|
23
|
+
Project-URL: Issues, https://github.com/moesio-f/claude-code-langchain/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# claude-code-langchain
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/claude-code-langchain/)
|
|
29
|
+
[](https://pypi.org/project/claude-code-langchain/)
|
|
30
|
+
[](https://github.com/moesio-f/claude-code-langchain/blob/main/LICENSE)
|
|
31
|
+
|
|
32
|
+
A LangChain chat model that runs Claude through the **Claude Code CLI**, using the
|
|
33
|
+
[Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-python).
|
|
34
|
+
|
|
35
|
+
> **Not affiliated with Anthropic.** This is an independent community project.
|
|
36
|
+
> "Anthropic", "Claude", and "Claude Code" are trademarks of Anthropic PBC.
|
|
37
|
+
|
|
38
|
+
Authentication is delegated entirely to the `claude` CLI, so the model runs with whatever
|
|
39
|
+
the CLI is already configured with. For products or services that serve other users,
|
|
40
|
+
Anthropic requires API-key authentication — see the
|
|
41
|
+
[Claude Code legal and compliance page](https://code.claude.com/docs/en/legal-and-compliance).
|
|
42
|
+
|
|
43
|
+
> Built on the ideas of [ChamaruAmasara/langchain-claude-code](https://github.com/ChamaruAmasara/langchain-claude-code),
|
|
44
|
+
> rewritten for the current stack: the new `claude-agent-sdk` (replacing the deprecated
|
|
45
|
+
> `claude-code-sdk`) and LangChain 1.0+.
|
|
46
|
+
|
|
47
|
+
## What the rewrite changes
|
|
48
|
+
|
|
49
|
+
| Area | Original (`claude-code-sdk`) | Here (`claude-agent-sdk`) |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| SDK options | `ClaudeCodeOptions` | `ClaudeAgentOptions` |
|
|
52
|
+
| Tool calling | Tool schemas injected into the system prompt, JSON parsed out of the reply | Real Anthropic tool use: tools are served over an in-process MCP server, so `AIMessage.tool_calls` comes from actual `tool_use` blocks |
|
|
53
|
+
| Structured output | Prompted, best-effort | The CLI's `--json-schema` output format, validated against your schema |
|
|
54
|
+
| Extended thinking | A "think step by step" hint appended to the prompt | The SDK's native `thinking` config, returned as a `reasoning` content block |
|
|
55
|
+
| Effort | `--effort` via `extra_args` | First-class `effort` option, `low` → `max` |
|
|
56
|
+
| Token usage | Not available | `usage_metadata`, including cache reads/writes, plus cost in `response_metadata` |
|
|
57
|
+
| Built-in tools | Implicitly on, gated by `max_turns` | Explicit `builtin_tools`; off by default, so plain chat stays plain |
|
|
58
|
+
| LangChain | 0.3 | 1.0+ (v1 content blocks, `usage_metadata`, standard `bind_tools` / `with_structured_output`) |
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
- Python 3.10+
|
|
63
|
+
- Node.js 18+ and the Claude Code CLI: `npm install -g @anthropic-ai/claude-code && claude`
|
|
64
|
+
- The CLI authenticated with the method you intend to use (API key, Claude subscription, or
|
|
65
|
+
a supported cloud provider)
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install claude-code-langchain
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quick start
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from claude_code_langchain import ChatClaudeCode
|
|
75
|
+
|
|
76
|
+
llm = ChatClaudeCode(model="claude-opus-5")
|
|
77
|
+
print(llm.invoke("What is the capital of France?").text)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`model` accepts a model id (`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5`) or a CLI
|
|
81
|
+
alias (`opus`, `sonnet`, `haiku`). Leave it unset to use whatever the CLI is configured with.
|
|
82
|
+
|
|
83
|
+
### Messages, streaming, async
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from langchain_core.messages import HumanMessage, SystemMessage
|
|
87
|
+
|
|
88
|
+
llm.invoke([SystemMessage("Be concise."), HumanMessage("Explain generators.")])
|
|
89
|
+
|
|
90
|
+
for chunk in llm.stream("Count from 1 to 5"):
|
|
91
|
+
print(chunk.text, end="", flush=True)
|
|
92
|
+
|
|
93
|
+
await llm.ainvoke("Hello") # also: astream, batch, abatch
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Tool calling
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from langchain_core.tools import tool
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@tool
|
|
103
|
+
def get_weather(city: str) -> str:
|
|
104
|
+
"""Get the weather for a city."""
|
|
105
|
+
return "12C and sunny"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
response = llm.bind_tools([get_weather]).invoke("What is the weather in Paris?")
|
|
109
|
+
response.tool_calls
|
|
110
|
+
# [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': 'toolu_…', 'type': 'tool_call'}]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Your tools are exposed to the CLI over an in-process MCP server, but they are never executed
|
|
114
|
+
there — the call is handed back to you, exactly like any other LangChain chat model, so it
|
|
115
|
+
works with LangGraph's `create_agent` and the usual `ToolNode` loop.
|
|
116
|
+
|
|
117
|
+
### Structured output
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from pydantic import BaseModel
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Person(BaseModel):
|
|
124
|
+
"""A person."""
|
|
125
|
+
|
|
126
|
+
name: str
|
|
127
|
+
age: int
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
llm.with_structured_output(Person).invoke("Bob is 42.")
|
|
131
|
+
# Person(name='Bob', age=42)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
This uses the CLI's native JSON-schema output mode, so the response is constrained rather
|
|
135
|
+
than merely requested. Pass `include_raw=True` to get `{"raw", "parsed", "parsing_error"}`.
|
|
136
|
+
|
|
137
|
+
### Thinking and effort
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
llm = ChatClaudeCode(
|
|
141
|
+
model="claude-opus-5",
|
|
142
|
+
thinking={"type": "adaptive", "display": "summarized"},
|
|
143
|
+
effort="high",
|
|
144
|
+
)
|
|
145
|
+
message = llm.invoke("Prove that sqrt(2) is irrational.")
|
|
146
|
+
message.content # [{'type': 'reasoning', ...}, {'type': 'text', ...}]
|
|
147
|
+
message.text # just the answer
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Agentic mode
|
|
151
|
+
|
|
152
|
+
Claude Code's own tools are **off by default**, so the model behaves like a normal chat model.
|
|
153
|
+
Turn them on when you want an agent that can touch your filesystem:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from claude_code_langchain import READ_ONLY_TOOLS, ChatClaudeCode, ClaudeTool
|
|
157
|
+
|
|
158
|
+
reader = ChatClaudeCode(
|
|
159
|
+
builtin_tools=READ_ONLY_TOOLS, # Read, Glob, Grep
|
|
160
|
+
allowed_tools=READ_ONLY_TOOLS, # run without asking for permission
|
|
161
|
+
max_turns=10,
|
|
162
|
+
cwd="/path/to/project",
|
|
163
|
+
)
|
|
164
|
+
reader.invoke("Find every TODO comment in this project")
|
|
165
|
+
|
|
166
|
+
full = ChatClaudeCode(
|
|
167
|
+
builtin_tools="claude_code", # the complete Claude Code toolset
|
|
168
|
+
permission_mode="acceptEdits",
|
|
169
|
+
max_turns=20,
|
|
170
|
+
cwd="/path/to/project",
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Presets: `READ_ONLY_TOOLS`, `WRITE_TOOLS`, `SHELL_TOOLS`, `NETWORK_TOOLS`, `ALL_TOOLS`, plus the
|
|
175
|
+
`ClaudeTool` enum for individual names.
|
|
176
|
+
|
|
177
|
+
### Sessions
|
|
178
|
+
|
|
179
|
+
Every response carries the CLI session id, which you can resume later:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
first = llm.invoke("Remember the number 7.")
|
|
183
|
+
session = first.response_metadata["session_id"]
|
|
184
|
+
|
|
185
|
+
resumed = ChatClaudeCode(session_id=session).invoke("What number did I mention?")
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Configuration
|
|
189
|
+
|
|
190
|
+
| Option | Purpose |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `model`, `system_prompt` | Model id/alias; system prompt used when no `SystemMessage` is present |
|
|
193
|
+
| `thinking`, `effort` | Extended thinking config; `low` / `medium` / `high` / `xhigh` / `max` |
|
|
194
|
+
| `max_turns`, `max_budget_usd` | Agent loop limits (defaults to a single turn) |
|
|
195
|
+
| `builtin_tools`, `allowed_tools`, `disallowed_tools`, `permission_mode` | Which Claude Code tools exist, which run unattended, and how permissions are handled |
|
|
196
|
+
| `mcp_servers` | Additional MCP servers for the session |
|
|
197
|
+
| `cwd`, `add_dirs`, `env`, `cli_path`, `settings`, `setting_sources` | Where and how the CLI subprocess runs |
|
|
198
|
+
| `session_id`, `fork_session` | Resume or branch a previous session |
|
|
199
|
+
| `extra_args` | Any other CLI flag |
|
|
200
|
+
|
|
201
|
+
`setting_sources` defaults to `[]` so a run is not affected by your local Claude settings or
|
|
202
|
+
`CLAUDE.md`. Pass `None` to load user, project and local settings like the interactive CLI.
|
|
203
|
+
|
|
204
|
+
## Compared with `ChatAnthropic`
|
|
205
|
+
|
|
206
|
+
| Feature | `ChatAnthropic` | `ChatClaudeCode` |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| `invoke` / `stream` / `batch` / async | ✅ | ✅ |
|
|
209
|
+
| System messages, image input | ✅ | ✅ |
|
|
210
|
+
| Tool calling, structured output | ✅ | ✅ |
|
|
211
|
+
| Extended thinking, effort | ✅ | ✅ |
|
|
212
|
+
| Token usage and cost | ✅ | ✅ (plus USD cost per call) |
|
|
213
|
+
| Authentication | API key | Delegated to the `claude` CLI |
|
|
214
|
+
| Built-in filesystem/bash/web tools, MCP servers, subagents, skills | ❌ | ✅ |
|
|
215
|
+
| `temperature`, `top_p`, `top_k`, `stop` | ✅ | Accepted for compatibility, ignored by the CLI |
|
|
216
|
+
| Multi-turn history | Sent verbatim | Flattened into one prompt (see below) |
|
|
217
|
+
|
|
218
|
+
### Known limitations
|
|
219
|
+
|
|
220
|
+
- The CLI owns conversation state, so a message list is flattened into a single prompt with
|
|
221
|
+
labelled turns. Prior tool calls and results are rendered as text. Use `session_id` when you
|
|
222
|
+
want the CLI's own memory of a conversation instead.
|
|
223
|
+
- Sampling parameters and stop sequences are not exposed by the CLI.
|
|
224
|
+
- Streaming yields text only; thinking arrives on the final non-streamed message.
|
|
225
|
+
- Each call spawns a `claude` subprocess, so latency is higher than a direct API call.
|
|
226
|
+
|
|
227
|
+
## Development
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
uv sync --group dev
|
|
231
|
+
uv run pytest # unit tests, no CLI needed
|
|
232
|
+
uv run ruff check . && uv run mypy src
|
|
233
|
+
CLAUDE_CODE_LANGCHAIN_INTEGRATION=1 uv run pytest tests/integration # runs the CLI
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Related projects
|
|
237
|
+
|
|
238
|
+
Other LangChain adapters for Claude Code:
|
|
239
|
+
|
|
240
|
+
| Project | Notes |
|
|
241
|
+
|---|---|
|
|
242
|
+
| [ChamaruAmasara/langchain-claude-code](https://github.com/ChamaruAmasara/langchain-claude-code) | The original project this one was inspired by; on PyPI as `langchain-claude-code-cli` |
|
|
243
|
+
| [thehumanworks/langchain-claude-code](https://github.com/thehumanworks/langchain-claude-code) | Wrapper for the Claude Agent SDK; on PyPI as `langchain-claude-code` |
|
|
244
|
+
| [brhodes/langchain-claude-agent-sdk](https://codeberg.org/brhodes/langchain-claude-agent-sdk) | `langchain-claude-agent-sdk` on PyPI (hosted on Codeberg rather than GitHub) |
|
|
245
|
+
| [langchain-ai/langchain#40131](https://github.com/langchain-ai/langchain/issues/40131) | Proposal to add an Agent SDK-backed model to `langchain-anthropic` |
|
|
246
|
+
|
|
247
|
+
Each takes a different approach to authentication and conversation state. Some accept OAuth
|
|
248
|
+
tokens directly; review the auth method and Anthropic's terms before using one in production.
|
|
249
|
+
|
|
250
|
+
## Credits
|
|
251
|
+
|
|
252
|
+
Original project: [langchain-claude-code](https://github.com/ChamaruAmasara/langchain-claude-code)
|
|
253
|
+
by Chamaru Amasara. This package is an independent rewrite on the Claude Agent SDK and
|
|
254
|
+
LangChain 1.0+.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
claude_code_langchain/__init__.py,sha256=iBHkg5OFW3P-ynnSsMfQUVzSd_uRsSbdfcyAEMkcw38,831
|
|
2
|
+
claude_code_langchain/chat_models.py,sha256=jNdkT-RXajbWmlAUE-43HDs0-y5UyshHxH5xcriyN1U,27562
|
|
3
|
+
claude_code_langchain/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
claude_code_langchain/tools.py,sha256=8B4hzPnHWobiCBxxNUQAdKR3HDY5z_bzyPnQEOvOEyM,1240
|
|
5
|
+
claude_code_langchain-0.1.0.dist-info/licenses/LICENSE,sha256=ZCFa0XYkCA-uaDyfqwGFh5Fly5UH16s-23QWvzpr0cc,1070
|
|
6
|
+
claude_code_langchain-0.1.0.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
|
|
7
|
+
claude_code_langchain-0.1.0.dist-info/METADATA,sha256=Uu0yfjAFgwLae6-yiilASyQ-krIROWVt0N46K4tn1J0,10599
|
|
8
|
+
claude_code_langchain-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moésio Filho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|