feat: add Claude and OpenRouter AI providers

OpenRouter is used when no provider flag is set. Claude is selected with .cl, and GLM stays available via .glm.
This commit is contained in:
Azimkin
2026-10-13 08:56:16 +00:00
parent c6b93c9aa8
commit a84467c385
10 changed files with 712 additions and 20 deletions

View File

@@ -13,6 +13,12 @@ GLM_MODEL=glm-5.3
DEEPSEEK_API_URL=https://api.deepseek.com DEEPSEEK_API_URL=https://api.deepseek.com
DEEPSEEK_API_KEY=your_deepseek_api_key_here DEEPSEEK_API_KEY=your_deepseek_api_key_here
DEEPSEEK_MODEL=deepseek-v4-flash 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_MAX_CONTEXT_MESSAGES=100
# AI_PROMPT_DIR=prompts # AI_PROMPT_DIR=prompts
# AI_MAX_MEDIA_BYTES=5242880 # AI_MAX_MEDIA_BYTES=5242880

View File

@@ -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 `.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 👎. 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`) ### Agent loop (`app/agentic.py`)
`.ai [count] [.ds] [.th] <prompt>` 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] <prompt>` 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. 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.

View File

@@ -15,7 +15,7 @@ from micro_agents_tools import BraveSearchTools, FilesystemTools, PythonTools
from app.agent_tools import AgentToolManager, TelegramTools from app.agent_tools import AgentToolManager, TelegramTools
from app.config import AiConfig, AiProviderConfig from app.config import AiConfig, AiProviderConfig
from app.llm import ChatCompletionsLLM from app.llm import AnthropicMessagesLLM, ChatCompletionsLLM, OpenRouterChatLLM
from app.web_tools import WebPageTools from app.web_tools import WebPageTools
@@ -32,6 +32,8 @@ class AgentInvocation:
prompt: str prompt: str
context_messages: int = 0 context_messages: int = 0
use_deepseek: bool = False use_deepseek: bool = False
use_claude: bool = False
use_openrouter: bool = False
thinking: 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") raise AgentSyntaxError("Context message count is outside the configured range")
index = 1 index = 1
use_deepseek = False provider = "openrouter"
thinking = False thinking = False
while index < len(tokens) and tokens[index].startswith("."): while index < len(tokens) and tokens[index].startswith("."):
flag = tokens[index].lower() flag = tokens[index].lower()
if flag == ".ds": if flag == ".glm":
use_deepseek = True provider = "glm"
elif flag == ".ds":
provider = "deepseek"
elif flag in {".cl", ".claude"}:
provider = "claude"
elif flag in {".or", ".openrouter"}:
provider = "openrouter"
elif flag == ".th": elif flag == ".th":
thinking = True thinking = True
index += 1 index += 1
@@ -193,7 +201,9 @@ def parse_agent_invocation(value: str, config: AiConfig) -> AgentInvocation:
return AgentInvocation( return AgentInvocation(
prompt=prompt, prompt=prompt,
context_messages=context_messages, context_messages=context_messages,
use_deepseek=use_deepseek, use_deepseek=provider == "deepseek",
use_claude=provider == "claude",
use_openrouter=provider == "openrouter",
thinking=thinking, thinking=thinking,
) )
@@ -255,7 +265,7 @@ def _agent_context(command_message: Any, account: Any, invocation: AgentInvocati
"name": getattr(account_config, "name", None), "name": getattr(account_config, "name", None),
"session": getattr(account_config, "session", None), "session": getattr(account_config, "session", None),
}, },
"provider": "deepseek" if invocation.use_deepseek else "glm", "provider": _provider_name(invocation),
"thinking": invocation.thinking, "thinking": invocation.thinking,
"context_messages": invocation.context_messages, "context_messages": invocation.context_messages,
} }
@@ -401,12 +411,35 @@ async def _build_context_messages(
return 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: 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: 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( return llm_class(
api_url=provider.api_url, api_url=provider.api_url,
api_key=provider.api_key, api_key=provider.api_key,

View File

@@ -420,7 +420,7 @@ async def echo(ctx: "CommandContext") -> None:
@command_registry.command( @command_registry.command(
"ai", "ai",
"Run an agent with optional Telegram history and media", "Run an agent with optional Telegram history and media",
usage=".ai / ..ai [messages] [.ds] [.th] <prompt>", usage=".ai / ..ai [messages] [.glm|.ds|.cl|.or] [.th] <prompt>",
category="shared", category="shared",
).shared() ).shared()
async def ai(ctx: "CommandContext") -> None: async def ai(ctx: "CommandContext") -> None:

View File

@@ -63,6 +63,18 @@ class AiConfig:
model="deepseek-v4-flash", 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") prompt_dir: Path = Path("prompts")
base_dir: Path = Path(".") base_dir: Path = Path(".")
max_context_messages: int = 100 max_context_messages: int = 100
@@ -215,6 +227,18 @@ def _parse_ai_config(raw: Any) -> AiConfig:
default_model="deepseek-v4-flash", default_model="deepseek-v4-flash",
env_prefix="DEEPSEEK", 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( max_context_messages = _parse_int(
raw_config.get("max_context_messages", 100), raw_config.get("max_context_messages", 100),
field_name="ai.max_context_messages", field_name="ai.max_context_messages",
@@ -294,6 +318,8 @@ def _parse_ai_config(raw: Any) -> AiConfig:
return AiConfig( return AiConfig(
glm=glm, glm=glm,
deepseek=deepseek, deepseek=deepseek,
claude=claude,
openrouter=openrouter,
prompt_dir=prompt_dir, prompt_dir=prompt_dir,
max_context_messages=max_context_messages, max_context_messages=max_context_messages,
max_media_bytes=max_media_bytes, max_media_bytes=max_media_bytes,

View File

@@ -4,6 +4,8 @@ import base64
import json import json
from typing import Any, cast from typing import Any, cast
import aiohttp
from micro_agents import ( from micro_agents import (
Conversation, Conversation,
LlmMessage, LlmMessage,
@@ -34,9 +36,7 @@ class ChatCompletionsLLM(OpenAIBasedLLM):
"model": self._model, "model": self._model,
"messages": messages, "messages": messages,
"stream": False, "stream": False,
"extra_body": { "extra_body": self._extra_body(thinking),
"thinking": {"type": "enabled" if thinking else "disabled"},
},
} }
if serialized_tools: if serialized_tools:
request["tools"] = serialized_tools request["tools"] = serialized_tools
@@ -64,6 +64,14 @@ class ChatCompletionsLLM(OpenAIBasedLLM):
return result 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]]: def _serialize_tools(self, tools: list[Tool]) -> list[dict[str, object]]:
response_tools = super()._serialize_tools(tools) response_tools = super()._serialize_tools(tools)
result: list[dict[str, object]] = [] result: list[dict[str, object]] = []
@@ -138,10 +146,7 @@ class ChatCompletionsLLM(OpenAIBasedLLM):
) )
elif isinstance(message, ReasoningMessage): elif isinstance(message, ReasoningMessage):
flush_assistant() flush_assistant()
pending_assistant = { pending_assistant = {"role": "assistant", **cls._reasoning_fields(message)}
"role": "assistant",
"reasoning_content": message.text(),
}
elif isinstance(message, LlmMessage): elif isinstance(message, LlmMessage):
if pending_assistant is None: if pending_assistant is None:
pending_assistant = {"role": "assistant"} pending_assistant = {"role": "assistant"}
@@ -226,3 +231,357 @@ class ChatCompletionsLLM(OpenAIBasedLLM):
file["file_data"] = media.url file["file_data"] = media.url
file["filename"] = media.filename or "file" file["filename"] = media.filename or "file"
return {"type": "file", "file": 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

View File

@@ -37,3 +37,13 @@ model = "glm-5.3"
api_url = "https://api.deepseek.com" api_url = "https://api.deepseek.com"
api_key = "your_deepseek_api_key_here" api_key = "your_deepseek_api_key_here"
model = "deepseek-v4-flash" 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"

View File

@@ -13,6 +13,7 @@ from app.agentic import (
_build_context_messages, _build_context_messages,
_create_llm, _create_llm,
_download_message_media, _download_message_media,
_provider,
render_prompt_template, render_prompt_template,
parse_agent_invocation, parse_agent_invocation,
run_agent_turn, run_agent_turn,
@@ -28,9 +29,13 @@ class AgentInvocationTests(unittest.TestCase):
with ( with (
patch("app.agentic.ChatCompletionsLLM") as chat_llm, patch("app.agentic.ChatCompletionsLLM") as chat_llm,
patch("app.agentic.OpenAIBasedLLM") as responses_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(".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( chat_llm.assert_called_once_with(
api_url="https://example.test", api_url="https://example.test",
@@ -42,6 +47,16 @@ class AgentInvocationTests(unittest.TestCase):
api_key="key", api_key="key",
model="model", 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: def test_prompt_comments_are_removed_without_breaking_urls(self) -> None:
value = strip_prompt_comments( value = strip_prompt_comments(
@@ -72,9 +87,50 @@ class AgentInvocationTests(unittest.TestCase):
self.assertEqual(invocation.context_messages, 12) self.assertEqual(invocation.context_messages, 12)
self.assertTrue(invocation.use_deepseek) self.assertTrue(invocation.use_deepseek)
self.assertFalse(invocation.use_claude)
self.assertFalse(invocation.use_openrouter)
self.assertTrue(invocation.thinking) self.assertTrue(invocation.thinking)
self.assertEqual(invocation.prompt, "explain this") 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: def test_ignores_unknown_leading_dot_arguments(self) -> None:
invocation = parse_agent_invocation(".unknown .ds prompt", AiConfig()) invocation = parse_agent_invocation(".unknown .ds prompt", AiConfig())

View File

@@ -46,6 +46,8 @@ class ConfigAiTests(unittest.TestCase):
{ {
"glm": {"api_key": "glm-key", "model": "glm-model"}, "glm": {"api_key": "glm-key", "model": "glm-model"},
"deepseek": {"api_key": "ds-key", "model": "ds-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_timeout_seconds": 20,
"web_page_max_chars": 12345, "web_page_max_chars": 12345,
} }
@@ -55,6 +57,12 @@ class ConfigAiTests(unittest.TestCase):
self.assertEqual(config.glm.model, "glm-model") self.assertEqual(config.glm.model, "glm-model")
self.assertEqual(config.deepseek.api_key, "ds-key") self.assertEqual(config.deepseek.api_key, "ds-key")
self.assertEqual(config.deepseek.model, "ds-model") 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_timeout_seconds, 20)
self.assertEqual(config.web_page_max_chars, 12345) self.assertEqual(config.web_page_max_chars, 12345)
@@ -64,6 +72,10 @@ class ConfigAiTests(unittest.TestCase):
"GLM_MODEL": "env-glm-model", "GLM_MODEL": "env-glm-model",
"DEEPSEEK_API_KEY": "env-ds", "DEEPSEEK_API_KEY": "env-ds",
"DEEPSEEK_MODEL": "env-ds-model", "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_TIMEOUT_SECONDS": "30",
"AI_WEB_PAGE_MAX_CHARS": "54321", "AI_WEB_PAGE_MAX_CHARS": "54321",
"AI_PROMPT_DIR": "alternate-prompts", "AI_PROMPT_DIR": "alternate-prompts",
@@ -75,6 +87,12 @@ class ConfigAiTests(unittest.TestCase):
self.assertEqual(config.glm.model, "env-glm-model") self.assertEqual(config.glm.model, "env-glm-model")
self.assertEqual(config.deepseek.api_key, "env-ds") self.assertEqual(config.deepseek.api_key, "env-ds")
self.assertEqual(config.deepseek.model, "env-ds-model") 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_timeout_seconds, 30)
self.assertEqual(config.web_page_max_chars, 54321) self.assertEqual(config.web_page_max_chars, 54321)
self.assertEqual(config.prompt_dir, Path("alternate-prompts")) self.assertEqual(config.prompt_dir, Path("alternate-prompts"))

View File

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock
from micro_agents import Conversation, LlmMessage, Tool, ToolCallMessage, ToolCallResponseMessage, UserMessage from micro_agents import Conversation, LlmMessage, Tool, ToolCallMessage, ToolCallResponseMessage, UserMessage
from micro_agents.conversation import ReasoningMessage from micro_agents.conversation import ReasoningMessage
from app.llm import ChatCompletionsLLM from app.llm import AnthropicMessagesLLM, ChatCompletionsLLM, OpenRouterChatLLM
class ChatCompletionsLlmTests(unittest.IsolatedAsyncioTestCase): class ChatCompletionsLlmTests(unittest.IsolatedAsyncioTestCase):
@@ -97,5 +97,189 @@ class ChatCompletionsLlmTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result[0].arguments(), {"query": "news"}) 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__": if __name__ == "__main__":
unittest.main() unittest.main()