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:
@@ -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
|
||||
|
||||
@@ -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] <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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
373
app/llm.py
373
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user