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