diff --git a/.env.example b/.env.example index 6174eec..a835de7 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,12 @@ GLM_MODEL=glm-5.3 DEEPSEEK_API_URL=https://api.deepseek.com DEEPSEEK_API_KEY=your_deepseek_api_key_here DEEPSEEK_MODEL=deepseek-v4-flash +CLAUDE_API_URL=https://api.anthropic.com +CLAUDE_API_KEY=your_anthropic_api_key_here +CLAUDE_MODEL=claude-haiku-5-5 +OPENROUTER_API_URL=https://openrouter.ai/api/v1 +OPENROUTER_API_KEY=your_openrouter_api_key_here +OPENROUTER_MODEL=anthropic/claude-haiku-5.5 # AI_MAX_CONTEXT_MESSAGES=100 # AI_PROMPT_DIR=prompts # AI_MAX_MEDIA_BYTES=5242880 diff --git a/AGENTS.md b/AGENTS.md index af525eb..53de511 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ Set `use_qr = true` on an account to authorize a new user session by scanning th The `.txt` transcription command uses `[elevenlabs]` settings or `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL_ID`, `ELEVENLABS_LANGUAGE_CODE`, and `ELEVENLABS_REQUEST_TIMEOUT_SECONDS`. -The `.ai` agent command uses `[ai.glm]` by default and `[ai.deepseek]` with the `.ds` flag. Provider environment variables are `GLM_API_URL`, `GLM_API_KEY`, `GLM_MODEL`, `DEEPSEEK_API_URL`, `DEEPSEEK_API_KEY`, and `DEEPSEEK_MODEL`. Z.AI GLM uses the Chat Completions protocol; DeepSeek uses the Responses protocol. `.th` enables reasoning. Brave Search is available in every AI session and requires `BRAVE_SEARCH_API_KEY` or `ai.brave_api_key`. Crawl4AI's `read_web_page` tool is also always available and uses headless Playwright Chromium to render public pages into Markdown before analysis. Configure it with `ai.web_page_timeout_seconds` / `AI_WEB_PAGE_TIMEOUT_SECONDS` and `ai.web_page_max_chars` / `AI_WEB_PAGE_MAX_CHARS`. +The `.ai` agent command uses `[ai.openrouter]` when no provider flag is given. `[ai.glm]` is selected with `.glm`, `[ai.deepseek]` with `.ds`, `[ai.claude]` with `.cl` (or `.claude`), and `[ai.openrouter]` explicitly with `.or` (or `.openrouter`). If several provider flags are present, the last one wins. Provider environment variables are `GLM_API_URL`, `GLM_API_KEY`, `GLM_MODEL`, `DEEPSEEK_API_URL`, `DEEPSEEK_API_KEY`, `DEEPSEEK_MODEL`, `CLAUDE_API_URL`, `CLAUDE_API_KEY`, `CLAUDE_MODEL`, `OPENROUTER_API_URL`, `OPENROUTER_API_KEY`, and `OPENROUTER_MODEL`. Z.AI GLM uses the Chat Completions protocol; DeepSeek uses the Responses protocol; Claude uses the Anthropic Messages API; OpenRouter uses its OpenAI-compatible Chat Completions API. The default Claude model is `claude-haiku-5-5`. The default OpenRouter model is `anthropic/claude-haiku-5.5`. `.th` enables reasoning. For Claude, `.th` sends adaptive thinking with summarized display; without it, thinking is disabled. For OpenRouter, `.th` sends `reasoning.effort=medium` and later tool turns echo `reasoning_details`. Brave Search is available in every AI session and requires `BRAVE_SEARCH_API_KEY` or `ai.brave_api_key`. Crawl4AI's `read_web_page` tool is also always available and uses headless Playwright Chromium to render public pages into Markdown before analysis. Configure it with `ai.web_page_timeout_seconds` / `AI_WEB_PAGE_TIMEOUT_SECONDS` and `ai.web_page_max_chars` / `AI_WEB_PAGE_MAX_CHARS`. AI prompt templates are always loaded from `MASTER.txt` and `USER.txt` in `ai.prompt_dir` (or `AI_PROMPT_DIR`) for every invocation. The directory defaults to `prompts`; relative paths are resolved from the repository root. C-style `/* ... */` blocks and `//` comments (at line start or following whitespace) are removed before rendering. Both `{name}` and `{{name}}` placeholders are supported, including dotted values such as `caller_user.id`, `caller_user.name`, `owner.id`, `owner.name`, `is_owner`, `text`, `chat.*`, `message.*`, `reply.*`, `account.*`, `provider`, `thinking`, and `context_messages`. Unknown placeholders fail the command with 👎. @@ -55,7 +55,7 @@ On supported platforms, `main.py` runs the application with `uvloop`; Windows au ### Agent loop (`app/agentic.py`) -`.ai [count] [.ds] [.th] ` runs `micro-agents` with GLM by default. `.ds` selects DeepSeek and `.th` enables reasoning. Every session exposes the `micro-agents-tools` Brave Search tool. Unknown leading dot-arguments are ignored. The optional count adds preceding Telegram messages; a replied message is always injected once. Commands are accepted from either message text or a media caption. Media at or below 5 MiB, including media attached directly to the command message, is attached. Invalid syntax and runtime/API failures only react with 👎. Guests need an ACL grant for `ai` and invoke it as `..ai`. +`.ai [count] [.glm|.ds|.cl|.or] [.th] ` runs `micro-agents` with OpenRouter by default. `.glm` selects GLM, `.ds` selects DeepSeek, `.cl` selects Claude, `.or` selects OpenRouter explicitly, and `.th` enables reasoning. Every session exposes the `micro-agents-tools` Brave Search tool. Unknown leading dot-arguments are ignored. The optional count adds preceding Telegram messages; a replied message is always injected once. Commands are accepted from either message text or a media caption. Media at or below 5 MiB, including media attached directly to the command message, is attached. Invalid syntax and runtime/API failures only react with 👎. Guests need an ACL grant for `ai` and invoke it as `..ai`. While an `.ai` command is running, its Telegram message has a 🤔 reaction. The reaction is removed after a successful answer and replaced with 👎 on failure. diff --git a/app/agentic.py b/app/agentic.py index 3443e98..d22cf11 100644 --- a/app/agentic.py +++ b/app/agentic.py @@ -15,7 +15,7 @@ from micro_agents_tools import BraveSearchTools, FilesystemTools, PythonTools from app.agent_tools import AgentToolManager, TelegramTools from app.config import AiConfig, AiProviderConfig -from app.llm import ChatCompletionsLLM +from app.llm import AnthropicMessagesLLM, ChatCompletionsLLM, OpenRouterChatLLM from app.web_tools import WebPageTools @@ -32,6 +32,8 @@ class AgentInvocation: prompt: str context_messages: int = 0 use_deepseek: bool = False + use_claude: bool = False + use_openrouter: bool = False thinking: bool = False @@ -176,12 +178,18 @@ def parse_agent_invocation(value: str, config: AiConfig) -> AgentInvocation: raise AgentSyntaxError("Context message count is outside the configured range") index = 1 - use_deepseek = False + provider = "openrouter" thinking = False while index < len(tokens) and tokens[index].startswith("."): flag = tokens[index].lower() - if flag == ".ds": - use_deepseek = True + if flag == ".glm": + provider = "glm" + elif flag == ".ds": + provider = "deepseek" + elif flag in {".cl", ".claude"}: + provider = "claude" + elif flag in {".or", ".openrouter"}: + provider = "openrouter" elif flag == ".th": thinking = True index += 1 @@ -193,7 +201,9 @@ def parse_agent_invocation(value: str, config: AiConfig) -> AgentInvocation: return AgentInvocation( prompt=prompt, context_messages=context_messages, - use_deepseek=use_deepseek, + use_deepseek=provider == "deepseek", + use_claude=provider == "claude", + use_openrouter=provider == "openrouter", thinking=thinking, ) @@ -255,7 +265,7 @@ def _agent_context(command_message: Any, account: Any, invocation: AgentInvocati "name": getattr(account_config, "name", None), "session": getattr(account_config, "session", None), }, - "provider": "deepseek" if invocation.use_deepseek else "glm", + "provider": _provider_name(invocation), "thinking": invocation.thinking, "context_messages": invocation.context_messages, } @@ -401,12 +411,35 @@ async def _build_context_messages( return messages +def _provider_name(invocation: AgentInvocation) -> str: + if invocation.use_openrouter: + return "openrouter" + if invocation.use_claude: + return "claude" + if invocation.use_deepseek: + return "deepseek" + return "glm" + + def _provider(config: AiConfig, invocation: AgentInvocation) -> AiProviderConfig: - return config.deepseek if invocation.use_deepseek else config.glm + if invocation.use_openrouter: + return config.openrouter + if invocation.use_claude: + return config.claude + if invocation.use_deepseek: + return config.deepseek + return config.glm def _create_llm(provider: AiProviderConfig, invocation: AgentInvocation) -> Any: - llm_class = OpenAIBasedLLM if invocation.use_deepseek else ChatCompletionsLLM + if invocation.use_openrouter: + llm_class: type[Any] = OpenRouterChatLLM + elif invocation.use_claude: + llm_class = AnthropicMessagesLLM + elif invocation.use_deepseek: + llm_class = OpenAIBasedLLM + else: + llm_class = ChatCompletionsLLM return llm_class( api_url=provider.api_url, api_key=provider.api_key, diff --git a/app/commands/builtins.py b/app/commands/builtins.py index 6d34262..0241375 100644 --- a/app/commands/builtins.py +++ b/app/commands/builtins.py @@ -420,7 +420,7 @@ async def echo(ctx: "CommandContext") -> None: @command_registry.command( "ai", "Run an agent with optional Telegram history and media", - usage=".ai / ..ai [messages] [.ds] [.th] ", + usage=".ai / ..ai [messages] [.glm|.ds|.cl|.or] [.th] ", category="shared", ).shared() async def ai(ctx: "CommandContext") -> None: diff --git a/app/config.py b/app/config.py index b9f0502..71bba75 100644 --- a/app/config.py +++ b/app/config.py @@ -63,6 +63,18 @@ class AiConfig: model="deepseek-v4-flash", ) ) + claude: AiProviderConfig = field( + default_factory=lambda: AiProviderConfig( + api_url="https://api.anthropic.com", + model="claude-haiku-5-5", + ) + ) + openrouter: AiProviderConfig = field( + default_factory=lambda: AiProviderConfig( + api_url="https://openrouter.ai/api/v1", + model="anthropic/claude-haiku-5.5", + ) + ) prompt_dir: Path = Path("prompts") base_dir: Path = Path(".") max_context_messages: int = 100 @@ -215,6 +227,18 @@ def _parse_ai_config(raw: Any) -> AiConfig: default_model="deepseek-v4-flash", env_prefix="DEEPSEEK", ) + claude = parse_provider( + "claude", + default_url="https://api.anthropic.com", + default_model="claude-haiku-5-5", + env_prefix="CLAUDE", + ) + openrouter = parse_provider( + "openrouter", + default_url="https://openrouter.ai/api/v1", + default_model="anthropic/claude-haiku-5.5", + env_prefix="OPENROUTER", + ) max_context_messages = _parse_int( raw_config.get("max_context_messages", 100), field_name="ai.max_context_messages", @@ -294,6 +318,8 @@ def _parse_ai_config(raw: Any) -> AiConfig: return AiConfig( glm=glm, deepseek=deepseek, + claude=claude, + openrouter=openrouter, prompt_dir=prompt_dir, max_context_messages=max_context_messages, max_media_bytes=max_media_bytes, diff --git a/app/llm.py b/app/llm.py index 6d98b31..e93c6c8 100644 --- a/app/llm.py +++ b/app/llm.py @@ -4,6 +4,8 @@ import base64 import json from typing import Any, cast +import aiohttp + from micro_agents import ( Conversation, LlmMessage, @@ -34,9 +36,7 @@ class ChatCompletionsLLM(OpenAIBasedLLM): "model": self._model, "messages": messages, "stream": False, - "extra_body": { - "thinking": {"type": "enabled" if thinking else "disabled"}, - }, + "extra_body": self._extra_body(thinking), } if serialized_tools: request["tools"] = serialized_tools @@ -64,6 +64,14 @@ class ChatCompletionsLLM(OpenAIBasedLLM): return result + @staticmethod + def _extra_body(thinking: bool | None) -> dict[str, object]: + return {"thinking": {"type": "enabled" if thinking else "disabled"}} + + @staticmethod + def _reasoning_fields(message: ReasoningMessage) -> dict[str, object]: + return {"reasoning_content": message.text()} + def _serialize_tools(self, tools: list[Tool]) -> list[dict[str, object]]: response_tools = super()._serialize_tools(tools) result: list[dict[str, object]] = [] @@ -138,10 +146,7 @@ class ChatCompletionsLLM(OpenAIBasedLLM): ) elif isinstance(message, ReasoningMessage): flush_assistant() - pending_assistant = { - "role": "assistant", - "reasoning_content": message.text(), - } + pending_assistant = {"role": "assistant", **cls._reasoning_fields(message)} elif isinstance(message, LlmMessage): if pending_assistant is None: pending_assistant = {"role": "assistant"} @@ -226,3 +231,357 @@ class ChatCompletionsLLM(OpenAIBasedLLM): file["file_data"] = media.url file["filename"] = media.filename or "file" return {"type": "file", "file": file} + + +class OpenRouterChatLLM(ChatCompletionsLLM): + """Chat Completions adapter for OpenRouter. + + OpenRouter rejects Z.AI's thinking body. Reasoning is requested with + OpenRouter's reasoning.effort parameter, and reasoning_details are echoed + back on later tool turns. + """ + + async def ask( + self, + conversation: Conversation, + tools: list[Tool], + *, + instructions: str | None = None, + thinking: bool | None = None, + ) -> list[Message]: + messages = self._serialize_conversation(conversation, instructions) + serialized_tools = self._serialize_tools(tools) + request: dict[str, Any] = { + "model": self._model, + "messages": messages, + "stream": False, + } + extra_body = self._extra_body(thinking) + if extra_body: + request["extra_body"] = extra_body + if serialized_tools: + request["tools"] = serialized_tools + + response = await self._ai.chat.completions.create(**cast(Any, request)) + if not response.choices: + raise RuntimeError("Chat Completions API returned no choices") + return self._parse_message(response.choices[0].message) + + @staticmethod + def _extra_body(thinking: bool | None) -> dict[str, object]: + if not thinking: + return {} + return {"reasoning": {"effort": "medium"}} + + @staticmethod + def _reasoning_fields(message: ReasoningMessage) -> dict[str, object]: + fields: dict[str, object] = {} + item = message.provider_item() + if isinstance(item, dict): + details = item.get("reasoning_details") + if isinstance(details, list) and details: + fields["reasoning_details"] = details + text = message.text() + if text: + fields["reasoning"] = text + return fields + + @classmethod + def _parse_message(cls, message: Any) -> list[Message]: + result: list[Message] = [] + extra = getattr(message, "model_extra", None) + if not isinstance(extra, dict): + extra = {} + reasoning = ( + getattr(message, "reasoning", None) + or getattr(message, "reasoning_content", None) + or extra.get("reasoning") + or extra.get("reasoning_content") + ) + details = getattr(message, "reasoning_details", None) + if details is None: + details = extra.get("reasoning_details") + provider_item = None + if details: + provider_item = {"reasoning_details": cls._jsonable(details)} + if reasoning or provider_item: + result.append(ReasoningMessage(str(reasoning or ""), provider_item=provider_item)) + if message.content: + result.append(LlmMessage(message.content)) + + for call in message.tool_calls or (): + try: + arguments = json.loads(call.function.arguments) + except (TypeError, ValueError) as exc: + raise ValueError("Model returned invalid tool-call arguments") from exc + if not isinstance(arguments, dict): + raise ValueError("Model tool-call arguments must be an object") + result.append(ToolCallMessage(call.id, call.function.name, arguments)) + return result + + @classmethod + def _jsonable(cls, value: Any) -> Any: + if hasattr(value, "model_dump"): + return value.model_dump(mode="json") + if isinstance(value, list): + return [cls._jsonable(item) for item in value] + if isinstance(value, dict): + return {str(key): cls._jsonable(item) for key, item in value.items()} + return value + + +class AnthropicMessagesLLM(OpenAIBasedLLM): + """micro-agents adapter for the Anthropic Messages API.""" + + _MAX_TOKENS = 16000 + + def __init__(self, api_url: str, api_key: str, model: str): + super().__init__(api_url, api_key, model) + self._api_key = api_key + self._api_url = api_url.rstrip("/") + + async def ask( + self, + conversation: Conversation, + tools: list[Tool], + *, + instructions: str | None = None, + thinking: bool | None = None, + ) -> list[Message]: + payload = self._build_payload(conversation, tools, instructions, thinking) + body = await self._post(payload) + return self._parse_response(body) + + def _messages_url(self) -> str: + if self._api_url.endswith("/messages"): + return self._api_url + if self._api_url.endswith("/v1"): + return f"{self._api_url}/messages" + return f"{self._api_url}/v1/messages" + + def _build_payload( + self, + conversation: Conversation, + tools: list[Tool], + instructions: str | None, + thinking: bool | None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "model": self._model, + "max_tokens": self._MAX_TOKENS, + "messages": self._serialize_messages(conversation), + "thinking": ( + {"type": "adaptive", "display": "summarized"} + if thinking + else {"type": "disabled"} + ), + } + if instructions: + payload["system"] = instructions + serialized_tools = self._serialize_anthropic_tools(tools) + if serialized_tools: + payload["tools"] = serialized_tools + return payload + + def _serialize_anthropic_tools(self, tools: list[Tool]) -> list[dict[str, object]]: + result: list[dict[str, object]] = [] + for tool in self._serialize_tools(tools): + item: dict[str, object] = { + "name": tool["name"], + "input_schema": tool.get("parameters") or {"type": "object", "properties": {}}, + } + description = tool.get("description") + if description: + item["description"] = description + result.append(item) + return result + + def _serialize_messages(self, conversation: Conversation) -> list[dict[str, object]]: + messages: list[dict[str, object]] = [] + assistant_blocks: list[dict[str, object]] = [] + + def flush_assistant() -> None: + if not assistant_blocks: + return + messages.append({"role": "assistant", "content": list(assistant_blocks)}) + assistant_blocks.clear() + + def append_user_blocks(blocks: list[dict[str, object]]) -> None: + flush_assistant() + if not blocks: + return + if messages and messages[-1]["role"] == "user": + content = messages[-1]["content"] + if isinstance(content, str): + content = [{"type": "text", "text": content}] + messages[-1]["content"] = content + if isinstance(content, list): + content.extend(blocks) + return + messages.append({"role": "user", "content": blocks}) + + for message in conversation.messages: + if isinstance(message, ToolCallResponseMessage): + append_user_blocks([self._serialize_tool_result(message)]) + continue + if isinstance(message, UserMessage): + append_user_blocks(self._serialize_user_blocks(message)) + continue + if isinstance(message, ReasoningMessage): + block = self._serialize_reasoning_block(message) + if block is not None: + assistant_blocks.append(block) + continue + if isinstance(message, LlmMessage): + text = message.message() + if text: + assistant_blocks.append({"type": "text", "text": text}) + continue + if isinstance(message, ToolCallMessage): + assistant_blocks.append( + { + "type": "tool_use", + "id": message.unique_id(), + "name": message.tool_name(), + "input": message.arguments(), + } + ) + continue + raise ValueError(f"Unknown conversation message: {type(message).__name__}") + + flush_assistant() + return messages + + @staticmethod + def _serialize_reasoning_block(message: ReasoningMessage) -> dict[str, object] | None: + item = message.provider_item() + if isinstance(item, dict) and item.get("type") in {"thinking", "redacted_thinking"}: + return dict(item) + text = message.text() + if not text: + return None + return {"type": "text", "text": text} + + def _serialize_user_blocks(self, message: UserMessage) -> list[dict[str, object]]: + blocks: list[dict[str, object]] = [] + if message.message(): + blocks.append({"type": "text", "text": message.message()}) + blocks.extend(self._serialize_media_block(media) for media in message.media()) + return blocks + + def _serialize_tool_result(self, message: ToolCallResponseMessage) -> dict[str, object]: + attachments = message.attachments() + if attachments: + content: object = [] + if message.text(): + cast(list[dict[str, object]], content).append( + {"type": "text", "text": message.text()} + ) + cast(list[dict[str, object]], content).extend( + self._serialize_media_block(media) for media in attachments + ) + else: + content = message.text() + block: dict[str, object] = { + "type": "tool_result", + "tool_use_id": message.unique_id(), + "content": content, + } + if message.is_error(): + block["is_error"] = True + return block + + @staticmethod + def _serialize_media_block(media: Media) -> dict[str, object]: + if media.file_id is not None: + raise ValueError("Claude media input does not support file ids") + if media.kind == "audio": + raise ValueError("Claude Messages API does not support audio input") + if media.kind == "image": + return { + "type": "image", + "source": AnthropicMessagesLLM._media_source(media, "image/jpeg"), + } + mime_type = media.mime_type or "application/octet-stream" + if mime_type.startswith("text/") and media.data is not None: + return { + "type": "text", + "text": media.data.decode("utf-8", errors="replace"), + } + block: dict[str, object] = { + "type": "document", + "source": AnthropicMessagesLLM._media_source(media, "application/pdf"), + } + if media.filename: + block["title"] = media.filename + return block + + @staticmethod + def _media_source(media: Media, default_mime: str) -> dict[str, object]: + if media.url is not None: + return {"type": "url", "url": media.url} + if media.data is None: + raise ValueError("Claude media input requires data or URL") + return { + "type": "base64", + "media_type": media.mime_type or default_mime, + "data": base64.b64encode(media.data).decode("ascii"), + } + + async def _post(self, payload: dict[str, Any]) -> dict[str, Any]: + timeout = aiohttp.ClientTimeout(total=300) + headers = { + "x-api-key": self._api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(self._messages_url(), headers=headers, json=payload) as response: + raw = await response.text() + try: + body = json.loads(raw) if raw else {} + except json.JSONDecodeError as exc: + raise RuntimeError( + f"Claude API returned invalid JSON (HTTP {response.status})" + ) from exc + if response.status >= 400: + error = body.get("error") if isinstance(body, dict) else None + message = error.get("message") if isinstance(error, dict) else None + raise RuntimeError(message or f"Claude API returned HTTP {response.status}") + if not isinstance(body, dict): + raise RuntimeError("Claude API returned an unexpected response") + return body + + @staticmethod + def _parse_response(body: dict[str, Any]) -> list[Message]: + content = body.get("content") + if not isinstance(content, list): + raise RuntimeError("Claude API returned no content") + + result: list[Message] = [] + for block in content: + if not isinstance(block, dict): + raise RuntimeError("Claude API returned an invalid content block") + block_type = block.get("type") + if block_type in {"thinking", "redacted_thinking"}: + text = block.get("thinking") if block_type == "thinking" else "" + result.append(ReasoningMessage(str(text or ""), provider_item=block)) + elif block_type == "text": + text = block.get("text") + if isinstance(text, str) and text: + result.append(LlmMessage(text)) + elif block_type == "tool_use": + call_id = block.get("id") + name = block.get("name") + arguments = block.get("input") + if not isinstance(call_id, str) or not isinstance(name, str): + raise ValueError("Claude returned an invalid tool call") + if not isinstance(arguments, dict): + raise ValueError("Claude tool-call arguments must be an object") + result.append(ToolCallMessage(call_id, name, arguments)) + else: + continue + + if not result: + raise RuntimeError("Claude API returned no message or tool call") + return result diff --git a/config.example.toml b/config.example.toml index f04627b..0baefd2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -37,3 +37,13 @@ model = "glm-5.3" api_url = "https://api.deepseek.com" api_key = "your_deepseek_api_key_here" model = "deepseek-v4-flash" + +[ai.claude] +api_url = "https://api.anthropic.com" +api_key = "your_anthropic_api_key_here" +model = "claude-haiku-5-5" + +[ai.openrouter] +api_url = "https://openrouter.ai/api/v1" +api_key = "your_openrouter_api_key_here" +model = "anthropic/claude-haiku-5.5" diff --git a/tests/test_ai_command.py b/tests/test_ai_command.py index 42c1e6d..371ad40 100644 --- a/tests/test_ai_command.py +++ b/tests/test_ai_command.py @@ -13,6 +13,7 @@ from app.agentic import ( _build_context_messages, _create_llm, _download_message_media, + _provider, render_prompt_template, parse_agent_invocation, run_agent_turn, @@ -28,9 +29,13 @@ class AgentInvocationTests(unittest.TestCase): with ( patch("app.agentic.ChatCompletionsLLM") as chat_llm, patch("app.agentic.OpenAIBasedLLM") as responses_llm, + patch("app.agentic.AnthropicMessagesLLM") as claude_llm, + patch("app.agentic.OpenRouterChatLLM") as openrouter_llm, ): - _create_llm(provider, parse_agent_invocation("prompt", AiConfig())) + _create_llm(provider, parse_agent_invocation(".glm prompt", AiConfig())) _create_llm(provider, parse_agent_invocation(".ds prompt", AiConfig())) + _create_llm(provider, parse_agent_invocation(".cl prompt", AiConfig())) + _create_llm(provider, parse_agent_invocation("prompt", AiConfig())) chat_llm.assert_called_once_with( api_url="https://example.test", @@ -42,6 +47,16 @@ class AgentInvocationTests(unittest.TestCase): api_key="key", model="model", ) + claude_llm.assert_called_once_with( + api_url="https://example.test", + api_key="key", + model="model", + ) + openrouter_llm.assert_called_once_with( + api_url="https://example.test", + api_key="key", + model="model", + ) def test_prompt_comments_are_removed_without_breaking_urls(self) -> None: value = strip_prompt_comments( @@ -72,9 +87,50 @@ class AgentInvocationTests(unittest.TestCase): self.assertEqual(invocation.context_messages, 12) self.assertTrue(invocation.use_deepseek) + self.assertFalse(invocation.use_claude) + self.assertFalse(invocation.use_openrouter) self.assertTrue(invocation.thinking) self.assertEqual(invocation.prompt, "explain this") + claude = parse_agent_invocation("3 .ds .cl .th summarize", AiConfig()) + self.assertFalse(claude.use_deepseek) + self.assertTrue(claude.use_claude) + self.assertFalse(claude.use_openrouter) + self.assertTrue(claude.thinking) + self.assertEqual(claude.prompt, "summarize") + self.assertEqual(claude.context_messages, 3) + selected = _provider( + AiConfig(claude=AiProviderConfig("https://api.anthropic.com", "key", "claude-haiku-5-5")), + claude, + ) + self.assertEqual(selected.model, "claude-haiku-5-5") + + plain = parse_agent_invocation("just ask", AiConfig()) + self.assertTrue(plain.use_openrouter) + self.assertFalse(plain.use_deepseek) + self.assertFalse(plain.use_claude) + self.assertEqual( + _provider(AiConfig(), plain).api_url, + "https://openrouter.ai/api/v1", + ) + + routed = parse_agent_invocation(".cl .or .th route this", AiConfig()) + self.assertFalse(routed.use_claude) + self.assertTrue(routed.use_openrouter) + self.assertEqual(routed.prompt, "route this") + selected_router = _provider( + AiConfig( + openrouter=AiProviderConfig( + "https://openrouter.ai/api/v1", + "key", + "anthropic/claude-haiku-5.5", + ) + ), + routed, + ) + self.assertEqual(selected_router.api_url, "https://openrouter.ai/api/v1") + self.assertEqual(selected_router.model, "anthropic/claude-haiku-5.5") + def test_ignores_unknown_leading_dot_arguments(self) -> None: invocation = parse_agent_invocation(".unknown .ds prompt", AiConfig()) diff --git a/tests/test_config_services.py b/tests/test_config_services.py index 6884641..6104f50 100644 --- a/tests/test_config_services.py +++ b/tests/test_config_services.py @@ -46,6 +46,8 @@ class ConfigAiTests(unittest.TestCase): { "glm": {"api_key": "glm-key", "model": "glm-model"}, "deepseek": {"api_key": "ds-key", "model": "ds-model"}, + "claude": {"api_key": "claude-key", "model": "claude-haiku-5-5"}, + "openrouter": {"api_key": "or-key", "model": "anthropic/claude-haiku-5.5"}, "web_page_timeout_seconds": 20, "web_page_max_chars": 12345, } @@ -55,6 +57,12 @@ class ConfigAiTests(unittest.TestCase): self.assertEqual(config.glm.model, "glm-model") self.assertEqual(config.deepseek.api_key, "ds-key") self.assertEqual(config.deepseek.model, "ds-model") + self.assertEqual(config.claude.api_url, "https://api.anthropic.com") + self.assertEqual(config.claude.api_key, "claude-key") + self.assertEqual(config.claude.model, "claude-haiku-5-5") + self.assertEqual(config.openrouter.api_url, "https://openrouter.ai/api/v1") + self.assertEqual(config.openrouter.api_key, "or-key") + self.assertEqual(config.openrouter.model, "anthropic/claude-haiku-5.5") self.assertEqual(config.web_page_timeout_seconds, 20) self.assertEqual(config.web_page_max_chars, 12345) @@ -64,6 +72,10 @@ class ConfigAiTests(unittest.TestCase): "GLM_MODEL": "env-glm-model", "DEEPSEEK_API_KEY": "env-ds", "DEEPSEEK_MODEL": "env-ds-model", + "CLAUDE_API_KEY": "env-claude", + "CLAUDE_MODEL": "claude-haiku-5-5", + "OPENROUTER_API_KEY": "env-or", + "OPENROUTER_MODEL": "anthropic/claude-haiku-5.5", "AI_WEB_PAGE_TIMEOUT_SECONDS": "30", "AI_WEB_PAGE_MAX_CHARS": "54321", "AI_PROMPT_DIR": "alternate-prompts", @@ -75,6 +87,12 @@ class ConfigAiTests(unittest.TestCase): self.assertEqual(config.glm.model, "env-glm-model") self.assertEqual(config.deepseek.api_key, "env-ds") self.assertEqual(config.deepseek.model, "env-ds-model") + self.assertEqual(config.claude.api_key, "env-claude") + self.assertEqual(config.claude.model, "claude-haiku-5-5") + self.assertEqual(config.claude.api_url, "https://api.anthropic.com") + self.assertEqual(config.openrouter.api_key, "env-or") + self.assertEqual(config.openrouter.model, "anthropic/claude-haiku-5.5") + self.assertEqual(config.openrouter.api_url, "https://openrouter.ai/api/v1") self.assertEqual(config.web_page_timeout_seconds, 30) self.assertEqual(config.web_page_max_chars, 54321) self.assertEqual(config.prompt_dir, Path("alternate-prompts")) diff --git a/tests/test_llm.py b/tests/test_llm.py index b766b15..af0b399 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock from micro_agents import Conversation, LlmMessage, Tool, ToolCallMessage, ToolCallResponseMessage, UserMessage from micro_agents.conversation import ReasoningMessage -from app.llm import ChatCompletionsLLM +from app.llm import AnthropicMessagesLLM, ChatCompletionsLLM, OpenRouterChatLLM class ChatCompletionsLlmTests(unittest.IsolatedAsyncioTestCase): @@ -97,5 +97,189 @@ class ChatCompletionsLlmTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(result[0].arguments(), {"query": "news"}) +class OpenRouterChatLlmTests(unittest.IsolatedAsyncioTestCase): + async def test_requests_reasoning_effort_and_echoes_details(self) -> None: + details = [{"type": "reasoning.text", "text": "step", "signature": "sig"}] + create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content="done", + reasoning="step", + reasoning_details=details, + tool_calls=None, + model_extra={}, + ) + ) + ] + ) + ) + llm = OpenRouterChatLLM("https://openrouter.ai/api/v1", "key", "anthropic/claude-haiku-5.5") + llm._ai = SimpleNamespace( # type: ignore[assignment] + chat=SimpleNamespace(completions=SimpleNamespace(create=create)) + ) + + conversation = Conversation( + [ + UserMessage("hello"), + ReasoningMessage("step", provider_item={"reasoning_details": details}), + LlmMessage("checking"), + ToolCallMessage("call-1", "lookup", {"query": "weather"}), + ToolCallResponseMessage("call-1", "sunny"), + ] + ) + result = await llm.ask(conversation, [], thinking=True) + + request = create.await_args.kwargs + self.assertEqual(request["model"], "anthropic/claude-haiku-5.5") + self.assertEqual(request["extra_body"], {"reasoning": {"effort": "medium"}}) + self.assertNotIn("thinking", request["extra_body"]) + assistant = request["messages"][1] + self.assertEqual(assistant["reasoning"], "step") + self.assertEqual(assistant["reasoning_details"], details) + self.assertNotIn("reasoning_content", assistant) + self.assertIsInstance(result[0], ReasoningMessage) + self.assertEqual(result[0].provider_item(), {"reasoning_details": details}) + self.assertEqual(result[1].message(), "done") + + async def test_omits_reasoning_body_without_flag(self) -> None: + create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content="ok", + reasoning=None, + reasoning_content=None, + reasoning_details=None, + tool_calls=None, + model_extra={}, + ) + ) + ] + ) + ) + llm = OpenRouterChatLLM("https://openrouter.ai/api/v1", "key", "anthropic/claude-haiku-5.5") + llm._ai = SimpleNamespace( # type: ignore[assignment] + chat=SimpleNamespace(completions=SimpleNamespace(create=create)) + ) + await llm.ask(Conversation([UserMessage("hello")]), [], thinking=False) + + self.assertNotIn("extra_body", create.await_args.kwargs) + + +class AnthropicMessagesLlmTests(unittest.IsolatedAsyncioTestCase): + async def test_sends_messages_api_request_and_parses_blocks(self) -> None: + captured: list[dict[str, object]] = [] + + async def fake_post(payload: dict[str, object]) -> dict[str, object]: + captured.append(payload) + return { + "content": [ + { + "type": "thinking", + "thinking": "look it up", + "signature": "sig", + }, + {"type": "text", "text": "done"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "lookup", + "input": {"query": "news"}, + }, + ] + } + + llm = AnthropicMessagesLLM("https://api.anthropic.com", "key", "claude-haiku-5-5") + llm._post = fake_post # type: ignore[method-assign] + + def lookup(query: str) -> str: + """Look up a query.""" + return query + + thinking = { + "type": "thinking", + "thinking": "previous", + "signature": "old-sig", + } + result = await llm.ask( + Conversation( + [ + UserMessage("hello", media=[]), + ReasoningMessage("previous", provider_item=thinking), + LlmMessage("checking"), + ToolCallMessage("toolu_0", "lookup", {"query": "weather"}), + ToolCallResponseMessage("toolu_0", "sunny"), + ] + ), + [Tool(lookup)], + instructions="system prompt", + thinking=True, + ) + + payload = captured[0] + self.assertEqual(llm._messages_url(), "https://api.anthropic.com/v1/messages") + self.assertEqual(payload["model"], "claude-haiku-5-5") + self.assertEqual(payload["system"], "system prompt") + self.assertEqual(payload["thinking"], {"type": "adaptive", "display": "summarized"}) + self.assertEqual(payload["tools"][0]["name"], "lookup") + self.assertEqual(payload["tools"][0]["input_schema"]["type"], "object") + self.assertEqual( + payload["messages"], + [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + { + "role": "assistant", + "content": [ + thinking, + {"type": "text", "text": "checking"}, + { + "type": "tool_use", + "id": "toolu_0", + "name": "lookup", + "input": {"query": "weather"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_0", + "content": "sunny", + } + ], + }, + ], + ) + self.assertIsInstance(result[0], ReasoningMessage) + self.assertEqual( + result[0].provider_item(), + {"type": "thinking", "thinking": "look it up", "signature": "sig"}, + ) + self.assertIsInstance(result[1], LlmMessage) + self.assertEqual(result[1].message(), "done") + self.assertIsInstance(result[2], ToolCallMessage) + self.assertEqual(result[2].arguments(), {"query": "news"}) + + async def test_disables_thinking_without_flag(self) -> None: + captured: list[dict[str, object]] = [] + + async def fake_post(payload: dict[str, object]) -> dict[str, object]: + captured.append(payload) + return {"content": [{"type": "text", "text": "ok"}]} + + llm = AnthropicMessagesLLM("https://api.anthropic.com/v1", "key", "claude-haiku-5-5") + llm._post = fake_post # type: ignore[method-assign] + await llm.ask(Conversation([UserMessage("hello")]), [], thinking=False) + + self.assertEqual(llm._messages_url(), "https://api.anthropic.com/v1/messages") + self.assertEqual(captured[0]["thinking"], {"type": "disabled"}) + self.assertNotIn("tools", captured[0]) + + if __name__ == "__main__": unittest.main()