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

@@ -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,

View File

@@ -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] <prompt>",
usage=".ai / ..ai [messages] [.glm|.ds|.cl|.or] [.th] <prompt>",
category="shared",
).shared()
async def ai(ctx: "CommandContext") -> None:

View File

@@ -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,

View File

@@ -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