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