diff --git a/.github/workflows/_compile_integration_test.yml b/.github/workflows/_compile_integration_test.yml index b57989f..ae89c86 100644 --- a/.github/workflows/_compile_integration_test.yml +++ b/.github/workflows/_compile_integration_test.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: python-version: - - "3.9" + - "3.10" - "3.12" name: "uv run pytest -m compile tests/integration_tests" steps: diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 5d79dbb..9ae78b7 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -29,7 +29,7 @@ jobs: # Starting new jobs is also relatively slow, # so linting on fewer versions makes CI faster. python-version: - - "3.9" + - "3.10" - "3.12" steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 869fd4e..c547114 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -21,7 +21,7 @@ jobs: strategy: matrix: python-version: - - "3.9" + - "3.10" - "3.12" name: "make test #${{ matrix.python-version }}" steps: diff --git a/libs/claude-code/README.md b/libs/claude-code/README.md index e020983..903e066 100644 --- a/libs/claude-code/README.md +++ b/libs/claude-code/README.md @@ -441,6 +441,66 @@ llm.with_structured_output(MySchema) prompt | llm | parser # chains work identically ``` +## ⚖️ Legal & Terms of Service + +> **Disclaimer:** This is a community project and is **not affiliated with, endorsed by, or sponsored by Anthropic**. Users are responsible for ensuring their usage complies with all applicable Anthropic terms and policies. + +### How This Package Works + +This package uses the official [`claude-code-sdk`](https://pypi.org/project/claude-code-sdk/) (MIT licensed, published by Anthropic) to interface with the Claude Code CLI. It does **not** reverse-engineer, decompile, or bypass any Anthropic systems. It uses the documented, officially supported SDK interface. + +### Applicable Terms + +Your use of Claude Code through this package is governed by Anthropic's terms: + +| Subscription | Applicable Terms | +|-------------|-----------------| +| Pro / Max (consumer) | [Consumer Terms of Service](https://www.anthropic.com/legal/consumer-terms) | +| API key users | [Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms) | +| All users | [Acceptable Use Policy](https://www.anthropic.com/legal/aup) | + +See also: [Claude Code Legal & Compliance](https://code.claude.com/docs/en/legal-and-compliance) + +### Key Terms to Be Aware Of + +**Consumer Terms (Pro/Max subscribers):** + +- **Automated access:** The Consumer Terms generally prohibit accessing Services "through automated or non-human means, whether through a bot, script, or otherwise" — **except** "via an Anthropic API Key or where we otherwise explicitly permit it." Anthropic publishes and maintains `claude-code-sdk` specifically for programmatic access, which we believe constitutes explicit permission for SDK-based usage. +- **Non-compete:** You may not use the Services "to develop any products or services that compete with our Services, including to develop or train any artificial intelligence or machine learning algorithms or models or **resell the Services**." +- **Personal use:** Consumer subscriptions are intended for individual use. You may not share your account credentials or make your account available to others. +- **Model training:** Using Inputs/Outputs to train AI models ("model scraping" or "model distillation") is prohibited without prior Anthropic authorization. + +**Commercial Terms (API key users):** + +- More permissive — explicitly allows powering products and services for your own customers and end users. +- Anthropic "may not train models on Customer Content from Services." + +### ⚠️ Gray Areas & Recommendations + +| Use Case | Risk Level | Notes | +|----------|-----------|-------| +| Personal development with Pro/Max | ✅ Low | Standard intended use of Claude Code | +| Building internal tools with Pro/Max | ⚠️ Medium | Consumer terms are ambiguous on commercial use | +| Powering a product for end users with Pro/Max | ⚠️ High | Consumer terms prohibit reselling; consider using an API key instead | +| Using with an Anthropic API key | ✅ Low | Commercial terms explicitly allow this | +| Building a competing AI service | 🚫 Prohibited | Explicitly prohibited under both Consumer and Commercial terms | +| Training models on outputs | 🚫 Prohibited | Prohibited without Anthropic authorization | + +**Our recommendation:** If you're building anything beyond personal/internal use, use an Anthropic API key with the [Commercial Terms](https://www.anthropic.com/legal/commercial-terms) rather than relying on a consumer Pro/Max subscription. The Commercial Terms are designed for this purpose. + +### Rate Limits & Fair Use + +Claude Pro/Max subscriptions have usage limits that are subject to change. Heavy automated usage through this package counts against your subscription limits and may trigger rate limiting. Anthropic may throttle, suspend, or terminate access for usage that violates their terms. + +### This Package's License vs. Anthropic's Terms + +- **This package** (`langchain-claude-code`): MIT licensed — you can freely use, modify, and distribute the package code itself. +- **Claude Code CLI**: Proprietary (`© Anthropic PBC. All rights reserved.`) — subject to Anthropic's terms. +- **`claude-code-sdk`**: MIT licensed — open source, published by Anthropic. +- **Model outputs**: Subject to Anthropic's terms regarding Inputs/Outputs/Materials. + +The MIT license of this package does **not** override or modify Anthropic's terms for the underlying service. + ## License MIT diff --git a/libs/claude-code/langchain_claude_code/__init__.py b/libs/claude-code/langchain_claude_code/__init__.py index 947750f..043022a 100644 --- a/libs/claude-code/langchain_claude_code/__init__.py +++ b/libs/claude-code/langchain_claude_code/__init__.py @@ -1,6 +1,27 @@ -"""LangChain integration for Claude Code — use Claude Pro/Max subscription as a LangChain ChatModel.""" +"""LangChain integration for Claude Code. + +Use Claude Pro/Max subscription as a LangChain ChatModel. +""" from langchain_claude_code.chat_models import ChatClaudeCode +from langchain_claude_code.tools import ( + ALL_TOOLS, + NETWORK_TOOLS, + READ_ONLY_TOOLS, + SHELL_TOOLS, + WRITE_TOOLS, + ClaudeTool, + normalize_tools, +) -__all__ = ["ChatClaudeCode"] +__all__ = [ + "ALL_TOOLS", + "NETWORK_TOOLS", + "READ_ONLY_TOOLS", + "SHELL_TOOLS", + "WRITE_TOOLS", + "ChatClaudeCode", + "ClaudeTool", + "normalize_tools", +] __version__ = "0.1.0" diff --git a/libs/claude-code/langchain_claude_code/chat_models.py b/libs/claude-code/langchain_claude_code/chat_models.py index 00d2d09..9538c44 100644 --- a/libs/claude-code/langchain_claude_code/chat_models.py +++ b/libs/claude-code/langchain_claude_code/chat_models.py @@ -11,12 +11,14 @@ from __future__ import annotations import asyncio -import base64 import json import queue import threading -from pathlib import Path -from typing import Any, AsyncIterator, Iterator, List, Literal, Optional, Sequence, Union +from collections.abc import AsyncIterator, Callable, Iterator, Sequence +from typing import ( + Any, + Literal, +) from langchain_core.callbacks import ( CallbackManagerForLLMRun, @@ -31,13 +33,19 @@ ToolMessage, ) from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langchain_core.runnables import Runnable, RunnableConfig from langchain_core.tools import BaseTool +from langchain_claude_code.tools import ClaudeTool, normalize_tools + +_SDK_IMPORT_ERR = ( + "claude-code-sdk is required. Install with: pip install claude-code-sdk" +) # ── Message Conversion ─────────────────────────────────────── -def _content_to_anthropic_blocks(content: Union[str, list]) -> Union[str, list[dict]]: +def _content_to_anthropic_blocks(content: str | list) -> str | list[dict]: """Convert LangChain message content to Anthropic content blocks. Handles text, image_url (base64 + URL), and direct Anthropic image blocks. @@ -61,12 +69,20 @@ def _content_to_anthropic_blocks(content: Union[str, list]) -> Union[str, list[d if url.startswith("data:"): header, b64data = url.split(",", 1) media_type = header.split(":")[1].split(";")[0] - blocks.append({ - "type": "image", - "source": {"type": "base64", "media_type": media_type, "data": b64data}, - }) + blocks.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": b64data, + }, + } + ) else: - blocks.append({"type": "image", "source": {"type": "url", "url": url}}) + blocks.append( + {"type": "image", "source": {"type": "url", "url": url}} + ) elif t == "image": blocks.append(item) else: @@ -77,13 +93,13 @@ def _content_to_anthropic_blocks(content: Union[str, list]) -> Union[str, list[d def _convert_messages( - messages: List[BaseMessage], -) -> tuple[Optional[str], list[dict], bool]: + messages: list[BaseMessage], +) -> tuple[str | None, list[dict], bool]: """Convert LangChain messages → Anthropic API format. Returns (system_prompt, messages, has_multimodal). """ - system: Optional[str] = None + system: str | None = None api_msgs: list[dict] = [] has_multimodal = False @@ -96,31 +112,36 @@ def _convert_messages( has_multimodal = True api_msgs.append({"role": "user", "content": content}) elif isinstance(msg, AIMessage): - # Handle tool calls in AIMessage if msg.tool_calls: content_blocks: list[dict] = [] if msg.content: content_blocks.append({"type": "text", "text": str(msg.content)}) - for tc in msg.tool_calls: - content_blocks.append({ + content_blocks.extend( + { "type": "tool_use", "id": tc["id"], "name": tc["name"], "input": tc["args"], - }) + } + for tc in msg.tool_calls + ) api_msgs.append({"role": "assistant", "content": content_blocks}) has_multimodal = True else: api_msgs.append({"role": "assistant", "content": str(msg.content)}) elif isinstance(msg, ToolMessage): - api_msgs.append({ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": msg.tool_call_id, - "content": str(msg.content), - }], - }) + api_msgs.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": msg.tool_call_id, + "content": str(msg.content), + } + ], + } + ) has_multimodal = True else: api_msgs.append({"role": "user", "content": str(msg.content)}) @@ -146,24 +167,58 @@ def _build_prompt_string(api_messages: list[dict]) -> str: return "\n\n".join(parts) -def _tool_to_anthropic_schema(tool: Union[BaseTool, dict, type]) -> dict: +def _tool_to_anthropic_schema( + tool: BaseTool | dict[str, Any] | type | Callable[..., Any], +) -> dict[str, Any]: """Convert a LangChain tool to Anthropic tool schema.""" if isinstance(tool, dict): return tool - if isinstance(tool, type): - # Pydantic model - schema = tool.model_json_schema() if hasattr(tool, "model_json_schema") else {} + if isinstance(tool, BaseTool): + input_schema: dict[str, Any] = {"type": "object", "properties": {}} + if tool.args_schema and hasattr(tool.args_schema, "model_json_schema"): + input_schema = tool.args_schema.model_json_schema() return { - "name": tool.__name__, - "description": tool.__doc__ or "", - "input_schema": schema, + "name": tool.name, + "description": tool.description or "", + "input_schema": input_schema, } - # BaseTool instance - return { - "name": tool.name, - "description": tool.description or "", - "input_schema": tool.args_schema.model_json_schema() if tool.args_schema else {"type": "object", "properties": {}}, - } + # type (Pydantic model) or Callable + name = getattr(tool, "__name__", str(tool)) + desc = getattr(tool, "__doc__", "") or "" + schema: dict[str, Any] = {} + if hasattr(tool, "model_json_schema"): + schema = tool.model_json_schema() + return {"name": name, "description": desc, "input_schema": schema} + + +# ── Async runner ───────────────────────────────────────────── + + +def _run_sync(coro: Any) -> Any: + """Run an async coroutine from sync context, handling event loop conflicts.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + # We're inside an existing event loop — run in a separate thread + result: list[Any] = [None] + exc: list[BaseException | None] = [None] + + def _thread_target() -> None: + try: + result[0] = asyncio.run(coro) + except BaseException as e: + exc[0] = e + + t = threading.Thread(target=_thread_target, daemon=True) + t.start() + t.join() + if exc[0] is not None: + raise exc[0] + return result[0] + return asyncio.run(coro) # ── Main ChatModel ─────────────────────────────────────────── @@ -186,6 +241,7 @@ class ChatClaudeCode(BaseChatModel): - Streaming (real token-by-token) - stop_sequences - Agentic mode (filesystem, bash, etc. via Claude Code's built-in tools) + - Session resume via session_id Requirements: - ``claude`` CLI installed & authenticated @@ -209,29 +265,21 @@ class ChatClaudeCode(BaseChatModel): ) agent.invoke("Read main.py and fix the bug on line 42") - # Controlled agentic mode (read-only) + # Using ClaudeTool enum for type-safe tool config + from langchain_claude_code import ClaudeTool, READ_ONLY_TOOLS + reader = ChatClaudeCode( model="claude-sonnet-4-20250514", max_turns=5, - allowed_tools=["Read", "Glob", "Grep"], + allowed_tools=READ_ONLY_TOOLS, ) reader.invoke("Find all TODO comments in this project") - # Extended thinking - llm = ChatClaudeCode( - model="claude-sonnet-4-20250514", - thinking={"type": "enabled", "budget_tokens": 5000}, + # Session resume + result = llm.invoke( + "Start a project", + config={"configurable": {"session_id": "abc123"}}, ) - - # Tool calling via bind_tools - from langchain_core.tools import tool - - @tool - def add(a: int, b: int) -> int: - \"\"\"Add two numbers.\"\"\" - return a + b - - llm_with_tools = llm.bind_tools([add]) """ # ── Core params (ChatAnthropic-compatible) ─────────────── @@ -242,59 +290,79 @@ def add(a: int, b: int) -> int: max_tokens: int = 4096 """Maximum tokens to generate.""" - temperature: Optional[float] = None + temperature: float | None = None """Sampling temperature (0.0-1.0).""" - top_k: Optional[int] = None + top_k: int | None = None """Top-K sampling.""" - top_p: Optional[float] = None + top_p: float | None = None """Nucleus sampling.""" - stop_sequences: Optional[List[str]] = None + stop_sequences: list[str] | None = None """Stop sequences.""" streaming: bool = False """Whether to stream by default.""" + # ── ChatAnthropic compat (accepted, limited or no-op) ──── + + max_retries: int = 0 + """Accepted for ChatAnthropic compat. CLI doesn't retry; this is a no-op.""" + + default_request_timeout: float | None = None + """Accepted for ChatAnthropic compat. Not fully supported via CLI.""" + + api_key: str | None = None + """Accepted for ChatAnthropic compat. Ignored (CLI auth).""" + + anthropic_api_key: str | None = None + """Accepted for ChatAnthropic compat. Ignored (CLI auth).""" + # ── Extended thinking ──────────────────────────────────── - thinking: Optional[dict[str, Any]] = None + thinking: dict[str, Any] | None = None """Extended thinking config. E.g. {"type": "enabled", "budget_tokens": 5000}.""" - effort: Optional[Literal["high", "medium", "low"]] = None + effort: Literal["high", "medium", "low"] | None = None """Effort level for the session (maps to Claude Code --effort flag).""" # ── Claude Code specific ───────────────────────────────── - system_prompt: Optional[str] = None + system_prompt: str | None = None """System prompt override.""" - permission_mode: Optional[Literal["default", "acceptEdits", "plan", "bypassPermissions"]] = None + permission_mode: ( + Literal["default", "acceptEdits", "plan", "bypassPermissions"] | None + ) = None """Permission mode for the CLI.""" - cli_path: Optional[str] = None + cli_path: str | None = None """Path to claude CLI binary.""" - max_turns: Optional[int] = None + max_turns: int | None = None """Maximum conversation turns. Defaults to 1 (text-only, no tool execution). Set higher (e.g. 5-10) to enable agentic mode where Claude Code can use its built-in tools (Read, Write, Edit, Bash, Glob, Grep, etc.).""" - cwd: Optional[str] = None + cwd: str | None = None """Working directory for the CLI. Controls where file operations happen.""" - allowed_tools: Optional[List[str]] = None - """Whitelist of Claude Code tools the agent can use. E.g. ["Read", "Glob", "Grep"] + allowed_tools: list[str | ClaudeTool] | None = None + """Whitelist of Claude Code tools the agent can use. + Accepts strings or ClaudeTool enum values. E.g. [ClaudeTool.READ, ClaudeTool.GLOB] for read-only access. When None, all tools are available (if max_turns > 1).""" - disallowed_tools: Optional[List[str]] = None - """Blacklist of Claude Code tools. E.g. ["Bash", "Write"] to prevent - shell access and file writes while allowing other tools.""" + disallowed_tools: list[str | ClaudeTool] | None = None + """Blacklist of Claude Code tools. Accepts strings or ClaudeTool enum values.""" + + session_id: str | None = None + """Session ID for resuming a previous conversation.""" # ── Internal state ─────────────────────────────────────── - _bound_tools: Optional[list[dict]] = None + _bound_tools: list[dict] | None = None + _last_result: Any | None = None # Stores last ResultMessage model_config = {"arbitrary_types_allowed": True} @@ -314,21 +382,30 @@ def _identifying_params(self) -> dict[str, Any]: "permission_mode": self.permission_mode, } + @property + def last_result(self) -> Any: + """The last ResultMessage from the SDK, containing cost/usage/session info.""" + return self._last_result + # ── Tool binding (ChatAnthropic-compatible) ────────────── def bind_tools( self, - tools: Sequence[Union[dict, type, BaseTool]], + tools: Sequence[dict[str, Any] | type | Callable[..., Any] | BaseTool], *, - tool_choice: Optional[Union[str, dict]] = None, - strict: Optional[bool] = None, + tool_choice: str | None = None, **kwargs: Any, - ) -> "ChatClaudeCode": + ) -> Runnable: """Bind tools to the model (like ChatAnthropic.bind_tools). + Note: Tool calling is implemented by injecting tool schemas into the + system prompt. Proper MCP-based tool binding is planned for a future + version using claude-agent-sdk. + Args: tools: List of tools (BaseTool, dict, or Pydantic model). tool_choice: Not directly supported via CLI, included for API compat. + parallel_tool_calls: Not supported via CLI, included for API compat. strict: Not directly supported via CLI, included for API compat. Returns: @@ -339,9 +416,38 @@ def bind_tools( new._bound_tools = schemas return new + # ── enable_tools helper ────────────────────────────────── + + def enable_tools(self, tools: list[str | ClaudeTool]) -> ChatClaudeCode: + """Return a copy with additional allowed tools. + + Args: + tools: Tools to add to the allowed list. + + Returns: + New ChatClaudeCode with the combined allowed_tools. + """ + existing = list(self.allowed_tools or []) + combined = existing + list(tools) + return self.model_copy(update={"allowed_tools": combined}) + # ── Build SDK options ──────────────────────────────────── - def _build_options(self, *, partial_messages: bool = False) -> Any: + def _get_session_id(self, config: RunnableConfig | None = None) -> str | None: + """Extract session_id from config or fall back to instance field.""" + if config: + configurable = config.get("configurable", {}) + sid = configurable.get("session_id") + if sid: + return sid + return self.session_id + + def _build_options( + self, + *, + partial_messages: bool = False, + session_id: str | None = None, + ) -> Any: """Build ClaudeCodeOptions from model params.""" from claude_code_sdk import ClaudeCodeOptions @@ -359,22 +465,45 @@ def _build_options(self, *, partial_messages: bool = False) -> Any: ) if self.permission_mode: - options.permission_mode = self.permission_mode # type: ignore + options.permission_mode = self.permission_mode if self.cwd: options.cwd = self.cwd if self.allowed_tools: - options.allowed_tools = self.allowed_tools + options.allowed_tools = normalize_tools(self.allowed_tools) if self.disallowed_tools: - options.disallowed_tools = self.disallowed_tools + options.disallowed_tools = normalize_tools(self.disallowed_tools) + + if session_id: + options.resume = session_id return options # ── Prompt building ────────────────────────────────────── - def _build_prompt(self, messages: List[BaseMessage]) -> tuple[Any, Any, bool]: + def _inject_tool_system_prompt(self, options: Any) -> None: + """Inject bound tool schemas into system prompt if tools are bound. + + NOTE: This is a temporary approach. Proper MCP-based tool binding + is planned for a future version using claude-agent-sdk. + """ + if not self._bound_tools: + return + + tool_desc = json.dumps(self._bound_tools, indent=2) + tool_instruction = ( + f"\n\nYou have access to the following tools:\n{tool_desc}\n\n" + "When you need to use a tool, respond with a JSON object containing " + '"tool_calls" with "name" and "args" fields.' + ) + if options.system_prompt: + options.system_prompt += tool_instruction + else: + options.system_prompt = tool_instruction + + def _build_prompt(self, messages: list[BaseMessage]) -> tuple[Any, Any, bool]: """Build prompt and options from messages. Returns (prompt_arg, options, is_streaming_input). @@ -389,191 +518,347 @@ def _build_prompt(self, messages: List[BaseMessage]) -> tuple[Any, Any, bool]: thinking_instruction = "" if self.thinking and self.thinking.get("type") == "enabled": budget = self.thinking.get("budget_tokens", 5000) - thinking_instruction = f"\n\n[Think step by step. Budget: {budget} tokens for thinking.]" + thinking_instruction = ( + f"\n\n[Think step by step. Budget: {budget} tokens for thinking.]" + ) if has_multimodal: return api_messages, options, True - else: - prompt = _build_prompt_string(api_messages) + thinking_instruction - return prompt, options, False + prompt = _build_prompt_string(api_messages) + thinking_instruction + return prompt, options, False + + # ── Process SDK messages ───────────────────────────────── + + def _process_sdk_messages( + self, messages: list[Any] + ) -> tuple[str, list[str], dict[str, Any]]: + """Extract text, thinking, and generation_info from SDK messages. + + Returns (text, thinking_parts, generation_info). + """ + from claude_code_sdk import AssistantMessage, ResultMessage + + text_parts: list[str] = [] + thinking_parts: list[str] = [] + gen_info: dict[str, Any] = {"model": self.model, "backend": "cli"} + + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if hasattr(block, "text"): + text_parts.append(block.text) + elif hasattr(block, "thinking"): + thinking_parts.append(block.thinking) + elif isinstance(msg, ResultMessage): + self._last_result = msg + gen_info["session_id"] = msg.session_id + gen_info["duration_ms"] = msg.duration_ms + gen_info["num_turns"] = msg.num_turns + gen_info["is_error"] = msg.is_error + if msg.total_cost_usd is not None: + gen_info["total_cost_usd"] = msg.total_cost_usd + if msg.usage: + gen_info["usage"] = msg.usage + + if thinking_parts: + gen_info["thinking"] = "".join(thinking_parts) + + return "".join(text_parts), thinking_parts, gen_info # ── Generate ───────────────────────────────────────────── def _generate( self, - messages: List[BaseMessage], - stop: Optional[List[str]] = None, - run_manager: Optional[CallbackManagerForLLMRun] = None, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: """Generate a response via Claude Code CLI.""" try: from claude_code_sdk import query as claude_query except ImportError: - raise ImportError( - "claude-code-sdk is required. Install with: pip install claude-code-sdk" - ) + raise ImportError(_SDK_IMPORT_ERR) + + system, api_messages, has_multimodal = _convert_messages(messages) + session_id = self._get_session_id(kwargs.get("config")) + options = self._build_options(partial_messages=False, session_id=session_id) - prompt_arg, options, is_streaming_input = self._build_prompt(messages) + if system: + options.system_prompt = system + + self._inject_tool_system_prompt(options) - # If tools are bound, include them in system prompt - if self._bound_tools: - tool_desc = json.dumps(self._bound_tools, indent=2) - tool_instruction = ( - f"\n\nYou have access to the following tools:\n{tool_desc}\n\n" - "When you need to use a tool, respond with a JSON object containing " - '"tool_calls" with "name" and "args" fields.' + # Build thinking instruction + thinking_instruction = "" + if self.thinking and self.thinking.get("type") == "enabled": + budget = self.thinking.get("budget_tokens", 5000) + thinking_instruction = ( + f"\n\n[Think step by step. Budget: {budget} tokens for thinking.]" ) - if options.system_prompt: - options.system_prompt += tool_instruction - else: - options.system_prompt = tool_instruction - text_parts: list[str] = [] - thinking_parts: list[str] = [] + collected: list[Any] = [] async def _run() -> None: - if is_streaming_input: + if has_multimodal: + async def _input_stream() -> AsyncIterator[dict[str, Any]]: - for msg in prompt_arg: + for msg in api_messages: yield { "type": "user", - "message": {"role": msg["role"], "content": msg["content"]}, + "message": { + "role": msg["role"], + "content": msg["content"], + }, } + stream = claude_query(prompt=_input_stream(), options=options) else: - stream = claude_query(prompt=prompt_arg, options=options) - - async for msg in stream: - if hasattr(msg, "content"): - for block in msg.content: - if hasattr(block, "text"): - text_parts.append(block.text) - elif hasattr(block, "thinking"): - thinking_parts.append(block.thinking) + prompt = _build_prompt_string(api_messages) + thinking_instruction + stream = claude_query(prompt=prompt, options=options) - self._run_async(_run()) + collected.extend([msg async for msg in stream]) - text = "".join(text_parts) + _run_sync(_run()) - # Build generation info - gen_info: dict[str, Any] = {"model": self.model, "backend": "cli"} - if thinking_parts: - gen_info["thinking"] = "".join(thinking_parts) + text, _thinking_parts, gen_info = self._process_sdk_messages(collected) # Parse tool calls from response if tools are bound ai_msg = AIMessage(content=text) if self._bound_tools and text: try: - # Try to parse tool calls from the response parsed = json.loads(text) if isinstance(parsed, dict) and "tool_calls" in parsed: - tool_calls = [] - for tc in parsed["tool_calls"]: - tool_calls.append({ + tool_calls = [ + { "name": tc["name"], "args": tc.get("args", {}), "id": tc.get("id", f"call_{hash(tc['name'])}"), - }) + } + for tc in parsed["tool_calls"] + ] ai_msg = AIMessage(content=text, tool_calls=tool_calls) except (json.JSONDecodeError, KeyError): pass return ChatResult( - generations=[ - ChatGeneration(message=ai_msg, generation_info=gen_info) - ] + generations=[ChatGeneration(message=ai_msg, generation_info=gen_info)] + ) + + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: Any | None = None, + **kwargs: Any, + ) -> ChatResult: + """Async generate a response via Claude Code CLI.""" + try: + from claude_code_sdk import query as claude_query + except ImportError: + raise ImportError(_SDK_IMPORT_ERR) + + system, api_messages, has_multimodal = _convert_messages(messages) + session_id = self._get_session_id(kwargs.get("config")) + options = self._build_options(partial_messages=False, session_id=session_id) + + if system: + options.system_prompt = system + + self._inject_tool_system_prompt(options) + + thinking_instruction = "" + if self.thinking and self.thinking.get("type") == "enabled": + budget = self.thinking.get("budget_tokens", 5000) + thinking_instruction = ( + f"\n\n[Think step by step. Budget: {budget} tokens for thinking.]" + ) + + collected: list[Any] = [] + + if has_multimodal: + + async def _input_stream() -> AsyncIterator[dict[str, Any]]: + for msg in api_messages: + yield { + "type": "user", + "message": { + "role": msg["role"], + "content": msg["content"], + }, + } + + stream = claude_query(prompt=_input_stream(), options=options) + else: + prompt = _build_prompt_string(api_messages) + thinking_instruction + stream = claude_query(prompt=prompt, options=options) + + collected = [msg async for msg in stream] + + text, _thinking_parts, gen_info = self._process_sdk_messages(collected) + + ai_msg = AIMessage(content=text) + if self._bound_tools and text: + try: + parsed = json.loads(text) + if isinstance(parsed, dict) and "tool_calls" in parsed: + tool_calls = [ + { + "name": tc["name"], + "args": tc.get("args", {}), + "id": tc.get("id", f"call_{hash(tc['name'])}"), + } + for tc in parsed["tool_calls"] + ] + ai_msg = AIMessage(content=text, tool_calls=tool_calls) + except (json.JSONDecodeError, KeyError): + pass + + return ChatResult( + generations=[ChatGeneration(message=ai_msg, generation_info=gen_info)] ) # ── Stream ─────────────────────────────────────────────── def _stream( self, - messages: List[BaseMessage], - stop: Optional[List[str]] = None, - run_manager: Optional[CallbackManagerForLLMRun] = None, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> Iterator[ChatGenerationChunk]: - """Stream response tokens as they arrive.""" + """Stream response tokens as they arrive. + + Uses a background thread + queue pattern for thread-safe sync streaming. + """ try: from claude_code_sdk import query as claude_query from claude_code_sdk.types import StreamEvent except ImportError: - raise ImportError( - "claude-code-sdk is required. Install with: pip install claude-code-sdk" - ) + raise ImportError(_SDK_IMPORT_ERR) system, api_messages, has_multimodal = _convert_messages(messages) - options = self._build_options(partial_messages=True) + session_id = self._get_session_id(kwargs.get("config")) + options = self._build_options(partial_messages=True, session_id=session_id) if system: options.system_prompt = system - if self._bound_tools: - tool_desc = json.dumps(self._bound_tools, indent=2) - tool_instruction = ( - f"\n\nYou have access to the following tools:\n{tool_desc}\n\n" - "When you need to use a tool, respond with a JSON object." - ) - if options.system_prompt: - options.system_prompt += tool_instruction - else: - options.system_prompt = tool_instruction + self._inject_tool_system_prompt(options) - chunk_queue: queue.Queue[Optional[str]] = queue.Queue() + # Sentinel for end of stream + _DONE = object() + chunk_queue: queue.Queue = queue.Queue() async def _run() -> None: - if has_multimodal: - async def _input_stream() -> AsyncIterator[dict[str, Any]]: - for msg in api_messages: - yield { - "type": "user", - "message": {"role": msg["role"], "content": msg["content"]}, - } - prompt_arg: Any = _input_stream() - else: - prompt_arg = _build_prompt_string(api_messages) - - async for msg in claude_query(prompt=prompt_arg, options=options): - if isinstance(msg, StreamEvent): - event = msg.event - if isinstance(event, dict): - evt_type = event.get("type", "") - if evt_type == "content_block_delta": - delta = event.get("delta", {}) - text = delta.get("text", "") - if text: - chunk_queue.put(text) - - chunk_queue.put(None) + try: + if has_multimodal: + + async def _input_stream() -> AsyncIterator[dict[str, Any]]: + for msg in api_messages: + yield { + "type": "user", + "message": { + "role": msg["role"], + "content": msg["content"], + }, + } + + prompt_arg: Any = _input_stream() + else: + prompt_arg = _build_prompt_string(api_messages) + + async for msg in claude_query(prompt=prompt_arg, options=options): + if isinstance(msg, StreamEvent): + event = msg.event + if isinstance(event, dict): + evt_type = event.get("type", "") + if evt_type == "content_block_delta": + delta = event.get("delta", {}) + text = delta.get("text", "") + if text: + chunk_queue.put(text) + else: + # AssistantMessage or ResultMessage + from claude_code_sdk import ResultMessage + + if isinstance(msg, ResultMessage): + self._last_result = msg + except Exception as e: + chunk_queue.put(e) + finally: + chunk_queue.put(_DONE) thread = threading.Thread(target=lambda: asyncio.run(_run()), daemon=True) thread.start() while True: - text = chunk_queue.get() - if text is None: + item = chunk_queue.get() + if item is _DONE: break - chunk = ChatGenerationChunk(message=AIMessageChunk(content=text)) + if isinstance(item, Exception): + raise item + chunk = ChatGenerationChunk(message=AIMessageChunk(content=item)) if run_manager: - run_manager.on_llm_new_token(text) + run_manager.on_llm_new_token(item) yield chunk thread.join() - # ── Async helper ───────────────────────────────────────── - - @staticmethod - def _run_async(coro: Any) -> None: - """Run an async coroutine synchronously.""" + async def _astream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: Any | None = None, + **kwargs: Any, + ) -> AsyncIterator[ChatGenerationChunk]: + """Async stream response tokens.""" try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - - if loop and loop.is_running(): - import concurrent.futures - with concurrent.futures.ThreadPoolExecutor() as pool: - pool.submit(lambda: asyncio.run(coro)).result() + from claude_code_sdk import ResultMessage + from claude_code_sdk import query as claude_query + from claude_code_sdk.types import StreamEvent + except ImportError: + raise ImportError(_SDK_IMPORT_ERR) + + system, api_messages, has_multimodal = _convert_messages(messages) + session_id = self._get_session_id(kwargs.get("config")) + options = self._build_options(partial_messages=True, session_id=session_id) + + if system: + options.system_prompt = system + + self._inject_tool_system_prompt(options) + + if has_multimodal: + + async def _input_stream() -> AsyncIterator[dict[str, Any]]: + for msg in api_messages: + yield { + "type": "user", + "message": { + "role": msg["role"], + "content": msg["content"], + }, + } + + prompt_arg: Any = _input_stream() else: - asyncio.run(coro) + prompt_arg = _build_prompt_string(api_messages) + + async for msg in claude_query(prompt=prompt_arg, options=options): + if isinstance(msg, StreamEvent): + event = msg.event + if isinstance(event, dict): + evt_type = event.get("type", "") + if evt_type == "content_block_delta": + delta = event.get("delta", {}) + text = delta.get("text", "") + if text: + chunk = ChatGenerationChunk( + message=AIMessageChunk(content=text) + ) + if run_manager: + await run_manager.on_llm_new_token(text) + yield chunk + elif isinstance(msg, ResultMessage): + self._last_result = msg diff --git a/libs/claude-code/langchain_claude_code/tools.py b/libs/claude-code/langchain_claude_code/tools.py new file mode 100644 index 0000000..0066c11 --- /dev/null +++ b/libs/claude-code/langchain_claude_code/tools.py @@ -0,0 +1,47 @@ +"""Claude Code built-in tool definitions and presets.""" + +from __future__ import annotations + +from enum import Enum + + +class ClaudeTool(str, Enum): + """Claude Code built-in tools.""" + + ASK_USER_QUESTION = "AskUserQuestion" + BASH = "Bash" + BASH_OUTPUT = "BashOutput" + EDIT = "Edit" + EXIT_PLAN_MODE = "ExitPlanMode" + GLOB = "Glob" + GREP = "Grep" + KILL_SHELL = "KillShell" + NOTEBOOK_EDIT = "NotebookEdit" + READ = "Read" + SKILL = "Skill" + SLASH_COMMAND = "SlashCommand" + TASK = "Task" + TODO_WRITE = "TodoWrite" + WEB_FETCH = "WebFetch" + WEB_SEARCH = "WebSearch" + WRITE = "Write" + + +# Preset groups +READ_ONLY_TOOLS = [ClaudeTool.READ, ClaudeTool.GLOB, ClaudeTool.GREP] +WRITE_TOOLS = [ClaudeTool.EDIT, ClaudeTool.WRITE] +NETWORK_TOOLS = [ClaudeTool.WEB_FETCH, ClaudeTool.WEB_SEARCH] +SHELL_TOOLS = [ClaudeTool.BASH, ClaudeTool.BASH_OUTPUT, ClaudeTool.KILL_SHELL] +ALL_TOOLS = list(ClaudeTool) + + +def normalize_tools(tools: list[str | ClaudeTool]) -> list[str]: + """Normalize a list of tool names/enums to unique string names.""" + seen: set[str] = set() + result: list[str] = [] + for t in tools: + name = t.value if isinstance(t, ClaudeTool) else str(t) + if name not in seen: + seen.add(name) + result.append(name) + return result diff --git a/libs/claude-code/pyproject.toml b/libs/claude-code/pyproject.toml index 2a8e4be..cd15fe0 100644 --- a/libs/claude-code/pyproject.toml +++ b/libs/claude-code/pyproject.toml @@ -48,10 +48,14 @@ test = [ "pytest-socket>=0.7.0,<1.0.0", "syrupy>=4.9.0,<6.0.0", "langchain-tests>=1.1.2,<2.0.0", + "langgraph>=1.0.8", ] test_integration = [ "pytest>=8.4.0,<10.0.0", ] +dev = [ + "langchain-anthropic>=1.3.2", +] [tool.ruff] fix = true @@ -105,7 +109,7 @@ pydocstyle.ignore-var-parameters = true [tool.ruff.lint.per-file-ignores] "tests/**" = ["D1", "S", "SLF", "ARG001", "PGH003", "PT011"] -"scripts/**" = ["INP", "S"] +"scripts/**" = ["INP", "S", "T201"] [tool.mypy] plugins = ["pydantic.mypy"] @@ -114,6 +118,11 @@ disallow_untyped_defs = true disallow_any_generics = false warn_return_any = false +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disable_error_code = ["arg-type", "index", "comparison-overlap", "union-attr", "attr-defined"] + [tool.coverage.run] omit = [ "tests/*", diff --git a/libs/claude-code/tests/unit_tests/test_chat_models.py b/libs/claude-code/tests/unit_tests/test_chat_models.py index a31580b..e0bb875 100644 --- a/libs/claude-code/tests/unit_tests/test_chat_models.py +++ b/libs/claude-code/tests/unit_tests/test_chat_models.py @@ -1,18 +1,17 @@ """Unit tests for ChatClaudeCode — covers all public functionality.""" -from unittest.mock import AsyncMock, MagicMock, patch +import json +from unittest.mock import MagicMock, patch -import pytest from langchain_core.messages import ( AIMessage, - AIMessageChunk, BaseMessage, HumanMessage, SystemMessage, ToolMessage, ) -from langchain_core.outputs import ChatGeneration, ChatResult -from langchain_core.tools import BaseTool +from langchain_core.outputs import ChatResult +from langchain_core.runnables import RunnableConfig from pydantic import BaseModel, Field from langchain_claude_code.chat_models import ( @@ -22,6 +21,64 @@ _convert_messages, _tool_to_anthropic_schema, ) +from langchain_claude_code.tools import ( + ALL_TOOLS, + NETWORK_TOOLS, + READ_ONLY_TOOLS, + SHELL_TOOLS, + WRITE_TOOLS, + ClaudeTool, + normalize_tools, +) + +# ── ClaudeTool enum ────────────────────────────────────────── + + +class TestClaudeTool: + def test_enum_values(self) -> None: + assert ClaudeTool.BASH.value == "Bash" + assert ClaudeTool.READ.value == "Read" + assert ClaudeTool.WEB_FETCH.value == "WebFetch" + + def test_string_subclass(self) -> None: + assert isinstance(ClaudeTool.BASH, str) + assert ClaudeTool.BASH == "Bash" + + def test_preset_groups(self) -> None: + assert ClaudeTool.READ in READ_ONLY_TOOLS + assert ClaudeTool.GLOB in READ_ONLY_TOOLS + assert ClaudeTool.GREP in READ_ONLY_TOOLS + assert ClaudeTool.EDIT in WRITE_TOOLS + assert ClaudeTool.WRITE in WRITE_TOOLS + assert ClaudeTool.WEB_FETCH in NETWORK_TOOLS + assert ClaudeTool.WEB_SEARCH in NETWORK_TOOLS + assert ClaudeTool.BASH in SHELL_TOOLS + assert len(ALL_TOOLS) == len(ClaudeTool) + + def test_all_tools_contains_all_enum_members(self) -> None: + for member in ClaudeTool: + assert member in ALL_TOOLS + + +class TestNormalizeTools: + def test_strings(self) -> None: + result = normalize_tools(["Read", "Write", "Read"]) + assert result == ["Read", "Write"] + + def test_enum_values(self) -> None: + result = normalize_tools([ClaudeTool.READ, ClaudeTool.WRITE]) + assert result == ["Read", "Write"] + + def test_mixed(self) -> None: + result = normalize_tools([ClaudeTool.READ, "Write", ClaudeTool.READ]) + assert result == ["Read", "Write"] + + def test_empty(self) -> None: + assert normalize_tools([]) == [] + + def test_preserves_order(self) -> None: + result = normalize_tools([ClaudeTool.WRITE, ClaudeTool.READ, ClaudeTool.BASH]) + assert result == ["Write", "Read", "Bash"] # ── _content_to_anthropic_blocks ───────────────────────────── @@ -32,7 +89,7 @@ def test_string_passthrough(self) -> None: assert _content_to_anthropic_blocks("hello") == "hello" def test_non_list_non_string(self) -> None: - assert _content_to_anthropic_blocks(42) == "42" # type: ignore[arg-type] + assert _content_to_anthropic_blocks(42) == "42" def test_list_with_string_items(self) -> None: result = _content_to_anthropic_blocks(["hello", "world"]) @@ -46,38 +103,65 @@ def test_text_block(self) -> None: assert result == [{"type": "text", "text": "hi"}] def test_image_url_base64(self) -> None: - result = _content_to_anthropic_blocks([ - {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}}, - ]) - assert result == [{ - "type": "image", - "source": {"type": "base64", "media_type": "image/png", "data": "abc123"}, - }] + result = _content_to_anthropic_blocks( + [ + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,abc123"}, + }, + ] + ) + assert result == [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc123", + }, + } + ] def test_image_url_base64_jpeg(self) -> None: - result = _content_to_anthropic_blocks([ - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,xyz"}}, - ]) + result = _content_to_anthropic_blocks( + [ + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,xyz"}, + }, + ] + ) assert result[0]["source"]["media_type"] == "image/jpeg" def test_image_url_http(self) -> None: - result = _content_to_anthropic_blocks([ - {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}}, - ]) - assert result == [{ - "type": "image", - "source": {"type": "url", "url": "https://example.com/img.jpg"}, - }] + result = _content_to_anthropic_blocks( + [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.jpg"}, + }, + ] + ) + assert result == [ + { + "type": "image", + "source": {"type": "url", "url": "https://example.com/img.jpg"}, + } + ] def test_image_url_as_string(self) -> None: - """image_url value can be a plain string instead of a dict.""" - result = _content_to_anthropic_blocks([ - {"type": "image_url", "image_url": "https://example.com/photo.png"}, - ]) + result = _content_to_anthropic_blocks( + [ + {"type": "image_url", "image_url": "https://example.com/photo.png"}, + ] + ) assert result[0]["source"]["url"] == "https://example.com/photo.png" def test_image_block_passthrough(self) -> None: - block = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "x"}} + block = { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "x"}, + } result = _content_to_anthropic_blocks([block]) assert result == [block] @@ -90,11 +174,16 @@ def test_non_dict_item_becomes_text(self) -> None: assert result == [{"type": "text", "text": "123"}] def test_mixed_content(self) -> None: - result = _content_to_anthropic_blocks([ - {"type": "text", "text": "Look at this:"}, - {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}}, - "and this text", - ]) + result = _content_to_anthropic_blocks( + [ + {"type": "text", "text": "Look at this:"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.jpg"}, + }, + "and this text", + ] + ) assert len(result) == 3 assert result[0]["type"] == "text" assert result[1]["type"] == "image" @@ -129,12 +218,17 @@ def test_no_system(self) -> None: def test_multimodal_image(self) -> None: msgs = [ - HumanMessage(content=[ - {"type": "text", "text": "What is this?"}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}}, - ]) + HumanMessage( + content=[ + {"type": "text", "text": "What is this?"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,abc123"}, + }, + ] + ) ] - system, api_msgs, has_multimodal = _convert_messages(msgs) + _system, api_msgs, has_multimodal = _convert_messages(msgs) assert has_multimodal is True content = api_msgs[0]["content"] assert isinstance(content, list) @@ -144,10 +238,12 @@ def test_multimodal_image(self) -> None: def test_image_url_string(self) -> None: msgs = [ - HumanMessage(content=[ - {"type": "text", "text": "Describe"}, - {"type": "image_url", "image_url": "https://example.com/img.jpg"}, - ]) + HumanMessage( + content=[ + {"type": "text", "text": "Describe"}, + {"type": "image_url", "image_url": "https://example.com/img.jpg"}, + ] + ) ] _, api_msgs, has_multimodal = _convert_messages(msgs) assert has_multimodal is True @@ -157,11 +253,13 @@ def test_ai_message_with_tool_calls(self) -> None: msgs = [ AIMessage( content="I'll check the weather.", - tool_calls=[{ - "id": "call_123", - "name": "get_weather", - "args": {"city": "Tokyo"}, - }], + tool_calls=[ + { + "id": "call_123", + "name": "get_weather", + "args": {"city": "Tokyo"}, + } + ], ) ] _, api_msgs, has_multimodal = _convert_messages(msgs) @@ -183,7 +281,6 @@ def test_ai_message_with_tool_calls_no_text(self) -> None: ) ] _, api_msgs, _ = _convert_messages(msgs) - # Empty content should not add a text block assert len(api_msgs[0]["content"]) == 1 assert api_msgs[0]["content"][0]["type"] == "tool_use" @@ -199,8 +296,6 @@ def test_tool_message(self) -> None: assert api_msgs[0]["content"][0]["content"] == "25°C, sunny" def test_unknown_message_type(self) -> None: - """Unknown message types should be treated as user messages.""" - class CustomMessage(BaseMessage): type: str = "custom" @@ -218,13 +313,14 @@ def test_multiple_system_messages_last_wins(self) -> None: assert system == "Second" def test_full_tool_calling_conversation(self) -> None: - """Test a complete tool-calling conversation flow.""" msgs = [ SystemMessage(content="You are helpful."), HumanMessage(content="What's the weather in Tokyo?"), AIMessage( content="Let me check.", - tool_calls=[{"id": "call_1", "name": "get_weather", "args": {"city": "Tokyo"}}], + tool_calls=[ + {"id": "call_1", "name": "get_weather", "args": {"city": "Tokyo"}} + ], ), ToolMessage(content="25°C, sunny", tool_call_id="call_1"), AIMessage(content="It's 25°C and sunny in Tokyo!"), @@ -244,33 +340,44 @@ def test_single_message(self) -> None: assert result == "Hello" def test_single_message_non_string(self) -> None: - result = _build_prompt_string([{"role": "user", "content": [{"type": "text", "text": "hi"}]}]) - assert "text" in result # str() of the list + result = _build_prompt_string( + [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + ) + assert "text" in result def test_multi_turn(self) -> None: - result = _build_prompt_string([ - {"role": "user", "content": "Hi"}, - {"role": "assistant", "content": "Hello!"}, - {"role": "user", "content": "How are you?"}, - ]) + result = _build_prompt_string( + [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello!"}, + {"role": "user", "content": "How are you?"}, + ] + ) assert "User: Hi" in result assert "Assistant: Hello!" in result assert "User: How are you?" in result def test_multi_turn_with_content_blocks(self) -> None: - # With multiple messages, content blocks get text extracted - result = _build_prompt_string([ - {"role": "user", "content": [{"type": "text", "text": "hello"}, {"type": "text", "text": "world"}]}, - {"role": "assistant", "content": "ok"}, - ]) + result = _build_prompt_string( + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + {"type": "text", "text": "world"}, + ], + }, + {"role": "assistant", "content": "ok"}, + ] + ) assert "User: hello world" in result def test_single_message_content_blocks_uses_str(self) -> None: - # Single message with non-string content uses str() - result = _build_prompt_string([ - {"role": "user", "content": [{"type": "text", "text": "hello"}]}, - ]) - # str() of the list — this is the current behavior + result = _build_prompt_string( + [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + ] + ) assert "hello" in result @@ -285,6 +392,7 @@ def test_dict_passthrough(self) -> None: def test_pydantic_model(self) -> None: class MyTool(BaseModel): """A helpful tool.""" + x: int = Field(description="A number") result = _tool_to_anthropic_schema(MyTool) @@ -320,6 +428,11 @@ def test_default_params(self) -> None: assert llm.streaming is False assert llm.effort is None assert llm.thinking is None + assert llm.max_retries == 0 + assert llm.default_request_timeout is None + assert llm.api_key is None + assert llm.anthropic_api_key is None + assert llm.session_id is None def test_identifying_params(self) -> None: llm = ChatClaudeCode( @@ -374,6 +487,29 @@ def test_custom_params(self) -> None: assert llm.allowed_tools == ["Read", "Glob"] assert llm.disallowed_tools == ["Bash"] + def test_chatanthropic_compat_fields(self) -> None: + """ChatAnthropic compat fields are accepted without error.""" + llm = ChatClaudeCode( + max_retries=3, + default_request_timeout=30.0, + api_key="sk-fake", + anthropic_api_key="sk-also-fake", + ) + assert llm.max_retries == 3 + assert llm.default_request_timeout == 30.0 + assert llm.api_key == "sk-fake" + assert llm.anthropic_api_key == "sk-also-fake" + + def test_allowed_tools_with_enum(self) -> None: + llm = ChatClaudeCode( + allowed_tools=[ClaudeTool.READ, ClaudeTool.GLOB, "Grep"], + ) + assert len(llm.allowed_tools) == 3 + + def test_last_result_initially_none(self) -> None: + llm = ChatClaudeCode() + assert llm.last_result is None + # ── bind_tools ─────────────────────────────────────────────── @@ -381,15 +517,19 @@ def test_custom_params(self) -> None: class TestBindTools: def test_bind_dict_tools(self) -> None: llm = ChatClaudeCode() - tool = {"name": "test", "description": "test tool", "input_schema": {"type": "object"}} + tool = { + "name": "test", + "description": "test tool", + "input_schema": {"type": "object"}, + } bound = llm.bind_tools([tool]) assert bound._bound_tools == [tool] - # Original should be unmodified assert llm._bound_tools is None def test_bind_pydantic_tools(self) -> None: class WeatherInput(BaseModel): """Get weather for a city.""" + city: str llm = ChatClaudeCode() @@ -413,6 +553,85 @@ def test_bind_multiple_tools(self) -> None: bound = llm.bind_tools(tools) assert len(bound._bound_tools) == 2 + def test_bind_tools_accepts_extra_kwargs(self) -> None: + """Signature matches ChatAnthropic — extra kwargs accepted.""" + llm = ChatClaudeCode() + bound = llm.bind_tools( + [{"name": "t", "description": "", "input_schema": {}}], + tool_choice="auto", + parallel_tool_calls=True, + strict=True, + ) + assert bound._bound_tools is not None + + +# ── enable_tools ───────────────────────────────────────────── + + +class TestEnableTools: + def test_enable_tools_adds_to_empty(self) -> None: + llm = ChatClaudeCode() + new = llm.enable_tools([ClaudeTool.READ, ClaudeTool.GLOB]) + assert new.allowed_tools == [ClaudeTool.READ, ClaudeTool.GLOB] + assert llm.allowed_tools is None # original unchanged + + def test_enable_tools_adds_to_existing(self) -> None: + llm = ChatClaudeCode(allowed_tools=["Read"]) + new = llm.enable_tools([ClaudeTool.WRITE]) + assert len(new.allowed_tools) == 2 + + def test_enable_tools_with_strings(self) -> None: + llm = ChatClaudeCode() + new = llm.enable_tools(["Bash", "Write"]) + assert new.allowed_tools == ["Bash", "Write"] + + def test_enable_tools_with_preset_group(self) -> None: + llm = ChatClaudeCode() + new = llm.enable_tools(READ_ONLY_TOOLS) + assert len(new.allowed_tools) == 3 + + +# ── session_id ─────────────────────────────────────────────── + + +class TestSessionId: + def test_session_id_field(self) -> None: + llm = ChatClaudeCode(session_id="test-session-123") + assert llm.session_id == "test-session-123" + + def test_get_session_id_from_field(self) -> None: + llm = ChatClaudeCode(session_id="field-session") + assert llm._get_session_id() == "field-session" + + def test_get_session_id_from_config(self) -> None: + llm = ChatClaudeCode(session_id="field-session") + config: RunnableConfig = {"configurable": {"session_id": "config-session"}} + assert llm._get_session_id(config) == "config-session" + + def test_get_session_id_config_overrides_field(self) -> None: + llm = ChatClaudeCode(session_id="field") + config: RunnableConfig = {"configurable": {"session_id": "config"}} + assert llm._get_session_id(config) == "config" + + def test_get_session_id_empty_config(self) -> None: + llm = ChatClaudeCode(session_id="field") + config: RunnableConfig = {"configurable": {}} + assert llm._get_session_id(config) == "field" + + def test_get_session_id_no_config_no_field(self) -> None: + llm = ChatClaudeCode() + assert llm._get_session_id() is None + + def test_session_id_in_build_options(self) -> None: + llm = ChatClaudeCode() + options = llm._build_options(session_id="resume-123") + assert options.resume == "resume-123" + + def test_no_session_id_no_resume(self) -> None: + llm = ChatClaudeCode() + options = llm._build_options() + assert not hasattr(options, "resume") or options.resume is None + # ── _build_options ─────────────────────────────────────────── @@ -450,6 +669,11 @@ def test_allowed_tools(self) -> None: options = llm._build_options() assert options.allowed_tools == ["Read", "Glob"] + def test_allowed_tools_with_enum(self) -> None: + llm = ChatClaudeCode(allowed_tools=[ClaudeTool.READ, ClaudeTool.GLOB]) + options = llm._build_options() + assert options.allowed_tools == ["Read", "Glob"] + def test_disallowed_tools(self) -> None: llm = ChatClaudeCode(disallowed_tools=["Bash", "Write"]) options = llm._build_options() @@ -477,27 +701,38 @@ def test_no_effort_means_empty_extra_args(self) -> None: class TestBuildPrompt: def test_text_only_prompt(self) -> None: llm = ChatClaudeCode() - prompt, options, is_streaming = llm._build_prompt([HumanMessage(content="Hello")]) + prompt, _options, is_streaming = llm._build_prompt( + [HumanMessage(content="Hello")] + ) assert isinstance(prompt, str) assert prompt == "Hello" assert is_streaming is False def test_system_message_sets_option(self) -> None: llm = ChatClaudeCode() - _, options, _ = llm._build_prompt([ - SystemMessage(content="Be helpful."), - HumanMessage(content="Hi"), - ]) + _, options, _ = llm._build_prompt( + [ + SystemMessage(content="Be helpful."), + HumanMessage(content="Hi"), + ] + ) assert options.system_prompt == "Be helpful." def test_multimodal_returns_list(self) -> None: llm = ChatClaudeCode() - prompt, _, is_streaming = llm._build_prompt([ - HumanMessage(content=[ - {"type": "text", "text": "What's this?"}, - {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}}, - ]) - ]) + prompt, _, is_streaming = llm._build_prompt( + [ + HumanMessage( + content=[ + {"type": "text", "text": "What's this?"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.jpg"}, + }, + ] + ) + ] + ) assert isinstance(prompt, list) assert is_streaming is True @@ -514,11 +749,13 @@ def test_thinking_disabled_no_instruction(self) -> None: def test_multi_turn_conversation_prompt(self) -> None: llm = ChatClaudeCode() - prompt, _, _ = llm._build_prompt([ - HumanMessage(content="Hi"), - AIMessage(content="Hello!"), - HumanMessage(content="How are you?"), - ]) + prompt, _, _ = llm._build_prompt( + [ + HumanMessage(content="Hi"), + AIMessage(content="Hello!"), + HumanMessage(content="How are you?"), + ] + ) assert "User: Hi" in prompt assert "Assistant: Hello!" in prompt assert "User: How are you?" in prompt @@ -532,53 +769,94 @@ def _make_mock_message(self, text: str) -> MagicMock: block = MagicMock() block.text = text block.thinking = None - del block.thinking # so hasattr returns False + del block.thinking msg = MagicMock() msg.content = [block] return msg - @patch("langchain_claude_code.chat_models.ChatClaudeCode._run_async") - def test_generate_basic(self, mock_run_async: MagicMock) -> None: + @patch("langchain_claude_code.chat_models._run_sync") + def test_generate_basic(self, mock_run_sync: MagicMock) -> None: """Test _generate returns ChatResult with AIMessage.""" llm = ChatClaudeCode() - - def side_effect(coro: object) -> None: - # Simulate what _run_async does — but we need to populate text_parts - pass - - mock_run_async.side_effect = side_effect - - # We can't easily test the full async flow without the SDK, - # but we can test the structure + mock_run_sync.side_effect = lambda coro: None result = llm._generate([HumanMessage(content="Hello")]) assert isinstance(result, ChatResult) assert len(result.generations) == 1 assert isinstance(result.generations[0].message, AIMessage) - @patch("langchain_claude_code.chat_models.ChatClaudeCode._run_async") - def test_generate_with_tools_injects_system_prompt(self, mock_run_async: MagicMock) -> None: + @patch("langchain_claude_code.chat_models._run_sync") + def test_generate_with_tools_injects_system_prompt( + self, mock_run_sync: MagicMock + ) -> None: llm = ChatClaudeCode() - bound = llm.bind_tools([{"name": "test_tool", "description": "A test", "input_schema": {}}]) - - captured_options = {} - - def capture_run(coro: object) -> None: - pass - - mock_run_async.side_effect = capture_run + bound = llm.bind_tools( + [{"name": "test_tool", "description": "A test", "input_schema": {}}] + ) + mock_run_sync.side_effect = lambda coro: None result = bound._generate([HumanMessage(content="Use the tool")]) assert isinstance(result, ChatResult) - @patch("langchain_claude_code.chat_models.ChatClaudeCode._run_async") - def test_generate_generation_info(self, mock_run_async: MagicMock) -> None: + @patch("langchain_claude_code.chat_models._run_sync") + def test_generate_generation_info(self, mock_run_sync: MagicMock) -> None: llm = ChatClaudeCode(model="claude-opus-4-20250514") - mock_run_async.side_effect = lambda c: None + mock_run_sync.side_effect = lambda c: None result = llm._generate([HumanMessage(content="Hi")]) gen_info = result.generations[0].generation_info assert gen_info["model"] == "claude-opus-4-20250514" assert gen_info["backend"] == "cli" +# ── last_result ────────────────────────────────────────────── + + +class TestLastResult: + def test_last_result_stored(self) -> None: + """Verify _last_result is set via _process_sdk_messages.""" + llm = ChatClaudeCode() + + # Create a mock ResultMessage + mock_result = MagicMock() + mock_result.__class__.__name__ = "ResultMessage" + mock_result.session_id = "sess-abc" + mock_result.duration_ms = 1234 + mock_result.num_turns = 2 + mock_result.is_error = False + mock_result.total_cost_usd = 0.05 + mock_result.usage = {"input_tokens": 100, "output_tokens": 50} + + # Patch isinstance check + from claude_code_sdk import AssistantMessage, ResultMessage + + mock_assistant = MagicMock(spec=AssistantMessage) + block = MagicMock() + block.text = "Hello!" + del_attrs = ["thinking"] + for attr in del_attrs: + if hasattr(block, attr): + delattr(block, attr) + mock_assistant.content = [block] + + real_result = ResultMessage( + subtype="result", + duration_ms=1234, + duration_api_ms=1000, + is_error=False, + num_turns=2, + session_id="sess-abc", + total_cost_usd=0.05, + usage={"input_tokens": 100, "output_tokens": 50}, + result="Hello!", + ) + + text, _, gen_info = llm._process_sdk_messages([mock_assistant, real_result]) + assert text == "Hello!" + assert llm.last_result is not None + assert llm.last_result.session_id == "sess-abc" + assert gen_info["session_id"] == "sess-abc" + assert gen_info["total_cost_usd"] == 0.05 + assert gen_info["usage"] == {"input_tokens": 100, "output_tokens": 50} + + # ── Serialization / model_copy ─────────────────────────────── @@ -625,6 +903,190 @@ def test_content_block_missing_text(self) -> None: def test_image_url_missing_url(self) -> None: result = _content_to_anthropic_blocks([{"type": "image_url", "image_url": {}}]) - # Empty URL should be treated as http (not data:), producing a URL source assert result[0]["source"]["type"] == "url" assert result[0]["source"]["url"] == "" + + +# ── LangGraph compatibility ────────────────────────────────── + + +class TestLangGraphCompat: + """Tests for compatibility with LangGraph create_react_agent.""" + + def test_bind_tools_returns_runnable(self) -> None: + """bind_tools returns a Runnable for LangGraph.""" + from langchain_core.runnables import Runnable + + llm = ChatClaudeCode() + tool_schema = { + "name": "get_weather", + "description": "Get weather", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + } + bound = llm.bind_tools([tool_schema]) + assert isinstance(bound, Runnable) + + def test_bind_tools_has_bound_tools_attr(self) -> None: + """Bound tools are accessible on the returned model.""" + llm = ChatClaudeCode() + tool_schema = { + "name": "search", + "description": "Search the web", + "input_schema": { + "type": "object", + "properties": {"q": {"type": "string"}}, + }, + } + bound = llm.bind_tools([tool_schema]) + assert bound._bound_tools is not None + assert len(bound._bound_tools) == 1 + assert bound._bound_tools[0]["name"] == "search" + + def test_tool_message_in_convert_messages(self) -> None: + """ToolMessage converts to tool_result format.""" + msgs = [ + HumanMessage(content="What's the weather?"), + AIMessage( + content="", + tool_calls=[ + { + "id": "call_abc", + "name": "get_weather", + "args": {"city": "London"}, + } + ], + ), + ToolMessage( + content='{"temp": 15, "condition": "cloudy"}', + tool_call_id="call_abc", + ), + ] + _sys, api_msgs, has_mm = _convert_messages(msgs) + assert has_mm is True + tool_result_msg = api_msgs[2] + assert tool_result_msg["role"] == "user" + block = tool_result_msg["content"][0] + assert block["type"] == "tool_result" + assert block["tool_use_id"] == "call_abc" + + def test_full_tool_calling_roundtrip(self) -> None: + """Human→AI(tool_call)→ToolMessage→AI converts OK.""" + msgs = [ + SystemMessage(content="You are a helpful assistant."), + HumanMessage(content="Search for LangGraph docs"), + AIMessage( + content="I'll search for that.", + tool_calls=[ + { + "id": "call_1", + "name": "search", + "args": {"q": "langgraph docs"}, + } + ], + ), + ToolMessage( + content="Found: https://langchain-ai.github.io/langgraph/", + tool_call_id="call_1", + ), + AIMessage(content="Here are the LangGraph docs."), + ] + system, api_msgs, has_mm = _convert_messages(msgs) + assert system == "You are a helpful assistant." + assert len(api_msgs) == 4 + assert has_mm is True + assert api_msgs[0]["role"] == "user" + assert api_msgs[1]["role"] == "assistant" + assert api_msgs[2]["role"] == "user" + assert api_msgs[3]["role"] == "assistant" + + @patch("langchain_claude_code.chat_models._run_sync") + def test_ai_message_tool_calls_populated(self, mock_run_sync: MagicMock) -> None: + """JSON tool_calls in response populates AIMessage.""" + from claude_code_sdk import ( + AssistantMessage, + ResultMessage, + ) + + tc_data = { + "name": "get_weather", + "args": {"city": "Paris"}, + "id": "call_42", + } + tool_response = json.dumps({"tool_calls": [tc_data]}) + + mock_assistant = MagicMock(spec=AssistantMessage) + block = MagicMock() + block.text = tool_response + if hasattr(block, "thinking"): + delattr(block, "thinking") + mock_assistant.content = [block] + + mock_result = ResultMessage( + subtype="result", + duration_ms=100, + duration_api_ms=80, + is_error=False, + num_turns=1, + session_id="sess-1", + total_cost_usd=0.01, + usage={"input_tokens": 10, "output_tokens": 20}, + result=tool_response, + ) + + collected_msgs = [mock_assistant, mock_result] + + llm = ChatClaudeCode() + tool_schema = { + "name": "get_weather", + "description": "Get weather", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + } + bound = llm.bind_tools([tool_schema]) + + mock_run_sync.side_effect = lambda coro: None + + text, _, _info = bound._process_sdk_messages(collected_msgs) + assert text == tool_response + + # Simulate parsing from _generate + ai_msg = AIMessage(content=text) + if bound._bound_tools and text: + parsed = json.loads(text) + if isinstance(parsed, dict) and "tool_calls" in parsed: + tool_calls = [ + { + "name": tc["name"], + "args": tc.get("args", {}), + "id": tc.get( + "id", + f"call_{hash(tc['name'])}", + ), + } + for tc in parsed["tool_calls"] + ] + ai_msg = AIMessage(content=text, tool_calls=tool_calls) + + assert len(ai_msg.tool_calls) == 1 + assert ai_msg.tool_calls[0]["name"] == "get_weather" + assert ai_msg.tool_calls[0]["args"] == {"city": "Paris"} + assert ai_msg.tool_calls[0]["id"] == "call_42" + + def test_create_react_agent_instantiation(self) -> None: + """create_react_agent accepts ChatClaudeCode.""" + from langchain_core.tools import tool as tool_decorator + from langgraph.prebuilt import create_react_agent + + @tool_decorator + def get_weather(city: str) -> str: + """Get the weather for a city.""" + return f"Weather in {city}: sunny, 25C" + + llm = ChatClaudeCode() + agent = create_react_agent(llm, [get_weather]) + assert agent is not None diff --git a/libs/claude-code/uv.lock b/libs/claude-code/uv.lock index 9248fa7..3d59d02 100644 --- a/libs/claude-code/uv.lock +++ b/libs/claude-code/uv.lock @@ -16,6 +16,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.79.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/b1/91aea3f8fd180d01d133d931a167a78a3737b3fd39ccef2ae8d6619c24fd/anthropic-0.79.0.tar.gz", hash = "sha256:8707aafb3b1176ed6c13e2b1c9fb3efddce90d17aee5d8b83a86c70dcdcca871", size = 509825, upload-time = "2026-02-07T18:06:18.388Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/b2/cc0b8e874a18d7da50b0fda8c99e4ac123f23bf47b471827c5f6f3e4a767/anthropic-0.79.0-py3-none-any.whl", hash = "sha256:04cbd473b6bbda4ca2e41dd670fe2f829a911530f01697d0a1e37321eb75f3cf", size = 405918, upload-time = "2026-02-07T18:06:20.246Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -323,12 +342,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -399,6 +436,103 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, + { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, + { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, + { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -447,6 +581,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "langchain-anthropic" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anthropic" }, + { name = "langchain-core" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/dd/c5e094079bdd748ca3f0bd0a09189ed2fa46bba56b5a8351198dc7c19e1f/langchain_anthropic-1.3.2.tar.gz", hash = "sha256:e551726a6ebf20229bde06022b5149d33bd48d28e34bd002a744953667b8ad48", size = 686239, upload-time = "2026-02-06T16:14:46.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/6b/2da16c32308f79bb4588cec7095edbc770722ae4b3c3a1c135e05b0bdc2e/langchain_anthropic-1.3.2-py3-none-any.whl", hash = "sha256:35bc30862696a493680b898eb76bd6c866841f8e48a57d5eca1420a4fd807ac0", size = 46751, upload-time = "2026-02-06T16:14:44.734Z" }, +] + [[package]] name = "langchain-claude-code" version = "0.1.0" @@ -457,11 +605,15 @@ dependencies = [ ] [package.dev-dependencies] +dev = [ + { name = "langchain-anthropic" }, +] lint = [ { name = "ruff" }, ] test = [ { name = "langchain-tests" }, + { name = "langgraph" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-socket" }, @@ -481,9 +633,11 @@ requires-dist = [ ] [package.metadata.requires-dev] +dev = [{ name = "langchain-anthropic", specifier = ">=1.3.2" }] lint = [{ name = "ruff", specifier = ">=0.13.1,<0.16.0" }] test = [ { name = "langchain-tests", specifier = ">=1.1.2,<2.0.0" }, + { name = "langgraph", specifier = ">=1.0.8" }, { name = "pytest", specifier = ">=8.4.0,<10.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0,<2.0.0" }, { name = "pytest-socket", specifier = ">=0.7.0,<1.0.0" }, @@ -534,6 +688,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/be/6823bf99e11ce7dc61d3bd295c1a6d2f19de0a36af2f20bedc5417a53ce6/langchain_tests-1.1.4-py3-none-any.whl", hash = "sha256:f7efff221ec99a1e7bb6a28c35b78309c71f312d9719982bdf36cc435e8f5b38", size = 55172, upload-time = "2026-02-06T18:46:20.7Z" }, ] +[[package]] +name = "langgraph" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/49/e9551965d8a44dd9afdc55cbcdc5a9bd18bee6918cc2395b225d40adb77c/langgraph-1.0.8.tar.gz", hash = "sha256:2630fc578846995114fd659f8b14df9eff5a4e78c49413f67718725e88ceb544", size = 498708, upload-time = "2026-02-06T12:31:13.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/72/b0d7fc1007821a08dfc03ce232f39f209aa4aa46414ea3d125b24e35093a/langgraph-1.0.8-py3-none-any.whl", hash = "sha256:da737177c024caad7e5262642bece4f54edf4cba2c905a1d1338963f41cf0904", size = 158144, upload-time = "2026-02-06T12:31:12.489Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/76/55a18c59dedf39688d72c4b06af73a5e3ea0d1a01bc867b88fbf0659f203/langgraph_checkpoint-4.0.0.tar.gz", hash = "sha256:814d1bd050fac029476558d8e68d87bce9009a0262d04a2c14b918255954a624", size = 137320, upload-time = "2026-01-12T20:30:26.38Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl", hash = "sha256:3fa9b2635a7c5ac28b338f631abf6a030c3b508b7b9ce17c22611513b589c784", size = 46329, upload-time = "2026-01-12T20:30:25.2Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/59/711aecd1a50999456850dc328f3cad72b4372d8218838d8d5326f80cb76f/langgraph_prebuilt-1.0.7.tar.gz", hash = "sha256:38e097e06de810de4d0e028ffc0e432bb56d1fb417620fb1dfdc76c5e03e4bf9", size = 163692, upload-time = "2026-01-22T16:45:22.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/49/5e37abb3f38a17a3487634abc2a5da87c208cc1d14577eb8d7184b25c886/langgraph_prebuilt-1.0.7-py3-none-any.whl", hash = "sha256:e14923516504405bb5edc3977085bc9622c35476b50c1808544490e13871fe7c", size = 35324, upload-time = "2026-01-22T16:45:21.784Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/37/1c18ebb9090a29cd360abce7ee0d3c639fa680e20a078b8c5e85044443d9/langgraph_sdk-0.3.4.tar.gz", hash = "sha256:a8055464027c70ff7b454c0d67caec9a91c6a2bc75c66d023d3ce48773a2a774", size = 132239, upload-time = "2026-02-06T00:44:14.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/e6/df257026e1370320b60d54492c0847631729ad80ca8d8571b55ece594281/langgraph_sdk-0.3.4-py3-none-any.whl", hash = "sha256:eb73a2fb57a4167aeb31efeaf0c4daecd2cf0c942e8a376670fd1cc636992f49", size = 67833, upload-time = "2026-02-06T00:44:12.795Z" }, +] + [[package]] name = "langsmith" version = "0.7.1" @@ -957,6 +1167,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, ] +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -1573,6 +1839,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sse-starlette" version = "3.2.0"