- Core types: Rational time, EntityId, ActionBatch, PngBytes (no Pillow dep) - Config: ~/.quercus/config.toml with 0600 perms, env var override for CI - LLMProvider: Claude (anthropic SDK) and OpenAI-compatible endpoint (covers custom gateways and llama.cpp server mode); graceful degradation without API keys - Tool schemas: 26 curated tools with JSON Schema and read_only flags - AgentLoop: sync dialogue loop with confirmation gate (read-only batches bypass, rejected mutations are reported back to the LLM), PNG frame feedback for multimodal providers - Session log: JSONL recording with frame sha256 hashes, replayable - HostAdapter ABC (signatures only, aligned with OPP/1 method families) - Mock LLM provider (scripted/replay/record) for network-free CI Tests: 69 passed via uv run pytest (no network access)
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""Provider 层测试:httpx MockTransport,零网络。
|
||
|
||
覆盖:OpenAI 兼容请求/响应解析、图片回喂、错误处理;Claude(anthropic SDK)
|
||
的消息/图片/tool_result 转换与 tool_use 解析;注册表可用性与无 key 降级。
|
||
"""
|
||
import base64
|
||
import json
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from quercus_core.config import Config
|
||
from quercus_core.providers import create_provider, provider_status
|
||
from quercus_core.providers.base import (
|
||
LLMProvider,
|
||
Message,
|
||
ProviderError,
|
||
ProviderUnavailable,
|
||
Role,
|
||
ToolCall,
|
||
ToolSchema,
|
||
)
|
||
from quercus_core.providers.claude import ClaudeProvider
|
||
from quercus_core.providers.openai_compat import OpenAICompatProvider
|
||
from quercus_core.types import PngBytes
|
||
|
||
PNG = PngBytes(b"\x89PNG\r\n\x1a\n" + bytes(range(64)))
|
||
|
||
GET_FRAME_SCHEMA = ToolSchema(
|
||
name="get_frame",
|
||
description="取帧",
|
||
parameters={"type": "object", "properties": {"time": {"type": "object"}}, "required": ["time"]},
|
||
read_only=True,
|
||
)
|
||
|
||
CLAUDE_MESSAGE = {
|
||
"id": "msg_01",
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"model": "claude-sonnet-4-5",
|
||
"content": [],
|
||
"stop_reason": "end_turn",
|
||
"stop_sequence": None,
|
||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||
}
|
||
|
||
|
||
# ---- OpenAI 兼容 ----
|
||
|
||
def test_openai_compat_request_and_parse():
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["url"] = str(request.url)
|
||
captured["authorization"] = request.headers.get("authorization")
|
||
captured["body"] = json.loads(request.content)
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"id": "chatcmpl-1",
|
||
"object": "chat.completion",
|
||
"created": 1,
|
||
"model": "gpt-4o",
|
||
"choices": [
|
||
{
|
||
"index": 0,
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": "我来调用工具",
|
||
"tool_calls": [
|
||
{
|
||
"id": "call_1",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_frame",
|
||
"arguments": json.dumps({"time": {"num": 1, "den": 1}}),
|
||
},
|
||
}
|
||
],
|
||
},
|
||
"finish_reason": "tool_calls",
|
||
}
|
||
],
|
||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||
},
|
||
)
|
||
|
||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||
provider = OpenAICompatProvider(
|
||
api_key="sk-test",
|
||
base_url="https://gw.example.com/v1",
|
||
model="local-vl",
|
||
client=client,
|
||
)
|
||
turn = provider.generate(
|
||
[Message(role=Role.USER, text="hi")], tools=[GET_FRAME_SCHEMA]
|
||
)
|
||
assert captured["url"] == "https://gw.example.com/v1/chat/completions"
|
||
assert captured["authorization"] == "Bearer sk-test"
|
||
assert captured["body"]["model"] == "local-vl"
|
||
assert captured["body"]["tools"][0]["function"]["name"] == "get_frame"
|
||
assert turn.text == "我来调用工具"
|
||
assert turn.tool_calls[0].name == "get_frame"
|
||
assert turn.tool_calls[0].arguments == {"time": {"num": 1, "den": 1}}
|
||
|
||
|
||
def test_openai_compat_image_message():
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["body"] = json.loads(request.content)
|
||
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
|
||
|
||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||
provider = OpenAICompatProvider(api_key="k", base_url="https://x/v1", client=client)
|
||
provider.generate([Message(role=Role.USER, text="看图", images=(PNG,))])
|
||
content = captured["body"]["messages"][0]["content"]
|
||
assert content[0] == {"type": "text", "text": "看图"}
|
||
url = content[1]["image_url"]["url"]
|
||
assert url.startswith("data:image/png;base64,")
|
||
assert base64.b64decode(url.split(",", 1)[1]) == PNG
|
||
|
||
|
||
def test_openai_compat_tool_result_image_follow_up_user_message():
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["body"] = json.loads(request.content)
|
||
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
|
||
|
||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||
provider = OpenAICompatProvider(api_key="k", base_url="https://x/v1", client=client)
|
||
provider.generate(
|
||
[
|
||
Message(role=Role.TOOL, text="帧", images=(PNG,), tool_call_id="c1", name="get_frame"),
|
||
]
|
||
)
|
||
msgs = captured["body"]["messages"]
|
||
assert msgs[0]["role"] == "tool"
|
||
assert msgs[0]["tool_call_id"] == "c1"
|
||
assert msgs[1]["role"] == "user" # 帧图以随后的 user 消息回喂
|
||
assert msgs[1]["content"][0]["type"] == "text"
|
||
assert msgs[1]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||
|
||
|
||
def test_openai_compat_http_error():
|
||
client = httpx.Client(
|
||
transport=httpx.MockTransport(lambda req: httpx.Response(500, text="boom"))
|
||
)
|
||
provider = OpenAICompatProvider(api_key="k", base_url="https://x/v1", client=client)
|
||
with pytest.raises(ProviderError):
|
||
provider.generate([Message(role=Role.USER, text="hi")])
|
||
|
||
|
||
# ---- Claude ----
|
||
|
||
def test_claude_tool_use_parse():
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["body"] = json.loads(request.content)
|
||
return httpx.Response(
|
||
200,
|
||
headers={"content-type": "application/json"},
|
||
json={
|
||
"id": "msg_01",
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"model": "claude-sonnet-4-5",
|
||
"content": [
|
||
{"type": "text", "text": "我来取帧"},
|
||
{
|
||
"type": "tool_use",
|
||
"id": "toolu_01",
|
||
"name": "get_frame",
|
||
"input": {"time": {"num": 1, "den": 1}, "max_size": {"width": 320, "height": 180}},
|
||
},
|
||
],
|
||
"stop_reason": "tool_use",
|
||
"stop_sequence": None,
|
||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||
},
|
||
)
|
||
|
||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||
provider = ClaudeProvider(api_key="sk-test", http_client=client)
|
||
turn = provider.generate([Message(role=Role.USER, text="hi")])
|
||
assert turn.text == "我来取帧"
|
||
assert len(turn.tool_calls) == 1
|
||
assert turn.tool_calls[0].id == "toolu_01"
|
||
assert turn.tool_calls[0].name == "get_frame"
|
||
assert turn.tool_calls[0].arguments["max_size"]["width"] == 320
|
||
|
||
|
||
def test_claude_image_message_becomes_image_block():
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["body"] = json.loads(request.content)
|
||
return httpx.Response(
|
||
200, headers={"content-type": "application/json"}, json=CLAUDE_MESSAGE
|
||
)
|
||
|
||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||
provider = ClaudeProvider(api_key="sk-test", http_client=client)
|
||
provider.generate([Message(role=Role.USER, text="看图", images=(PNG,))])
|
||
content = captured["body"]["messages"][0]["content"]
|
||
assert content[0]["type"] == "text" # 文本块在前
|
||
img_block = content[1]
|
||
assert img_block["type"] == "image"
|
||
assert img_block["source"]["media_type"] == "image/png"
|
||
assert base64.b64decode(img_block["source"]["data"]) == PNG
|
||
|
||
|
||
def test_claude_tool_result_with_image():
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["body"] = json.loads(request.content)
|
||
return httpx.Response(
|
||
200, headers={"content-type": "application/json"}, json=CLAUDE_MESSAGE
|
||
)
|
||
|
||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||
provider = ClaudeProvider(api_key="sk-test", http_client=client)
|
||
provider.generate(
|
||
[
|
||
Message(
|
||
role=Role.ASSISTANT,
|
||
tool_calls=(ToolCall(id="toolu_01", name="get_frame", arguments={}),),
|
||
),
|
||
Message(
|
||
role=Role.TOOL,
|
||
text="帧",
|
||
images=(PNG,),
|
||
tool_call_id="toolu_01",
|
||
name="get_frame",
|
||
),
|
||
]
|
||
)
|
||
msgs = captured["body"]["messages"]
|
||
assert msgs[0]["role"] == "assistant"
|
||
assert msgs[0]["content"][0]["type"] == "tool_use"
|
||
assert msgs[1]["role"] == "user" # 连续 tool 消息合并为一条带 tool_result 的 user
|
||
tool_result = msgs[1]["content"][0]
|
||
assert tool_result["type"] == "tool_result"
|
||
assert tool_result["tool_use_id"] == "toolu_01"
|
||
assert tool_result["content"][0]["type"] == "text"
|
||
assert tool_result["content"][1]["type"] == "image"
|
||
|
||
|
||
def test_claude_multiple_tool_results_merged_into_one_user_message():
|
||
"""agent loop 的典型回喂形态:多条 tool 消息合并成一条 user(tool_result...)。"""
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["body"] = json.loads(request.content)
|
||
return httpx.Response(
|
||
200, headers={"content-type": "application/json"}, json=CLAUDE_MESSAGE
|
||
)
|
||
|
||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||
provider = ClaudeProvider(api_key="sk-test", http_client=client)
|
||
provider.generate(
|
||
[
|
||
Message(
|
||
role=Role.ASSISTANT,
|
||
tool_calls=(
|
||
ToolCall(id="t1", name="place_clip", arguments={"x": 1}),
|
||
ToolCall(id="t2", name="get_frame", arguments={"t": 1}),
|
||
),
|
||
),
|
||
Message(role=Role.TOOL, text="placed", tool_call_id="t1", name="place_clip"),
|
||
Message(role=Role.TOOL, text="frame", images=(PNG,), tool_call_id="t2", name="get_frame"),
|
||
]
|
||
)
|
||
msgs = captured["body"]["messages"]
|
||
assert len(msgs) == 2
|
||
assert msgs[0]["role"] == "assistant"
|
||
assert [b["type"] for b in msgs[0]["content"]] == ["tool_use", "tool_use"]
|
||
# 两条 tool_result 合并进同一条 user 消息(保持 assistant 之后紧跟 user)
|
||
user_content = msgs[1]["content"]
|
||
assert [b["type"] for b in user_content] == ["tool_result", "tool_result"]
|
||
assert [b["tool_use_id"] for b in user_content] == ["t1", "t2"]
|
||
|
||
|
||
# ---- 注册表 / 无 key 降级 ----
|
||
|
||
def test_registry_unavailable_without_keys(tmp_path, monkeypatch):
|
||
monkeypatch.delenv("QUERCUS_ANTHROPIC_API_KEY", raising=False)
|
||
monkeypatch.delenv("QUERCUS_OPENAI_API_KEY", raising=False)
|
||
cfg = Config()
|
||
status = provider_status(cfg)
|
||
assert status["claude"].available is False
|
||
assert status["openai_compat"].available is False
|
||
assert status["claude"].reason
|
||
with pytest.raises(ProviderUnavailable):
|
||
create_provider("claude", cfg)
|
||
with pytest.raises(ProviderUnavailable):
|
||
create_provider("openai_compat", cfg)
|
||
|
||
|
||
def test_registry_create_with_keys():
|
||
cfg = Config(anthropic_api_key="sk-ant", openai_api_key="sk-o")
|
||
status = provider_status(cfg)
|
||
assert status["claude"].available is True
|
||
assert status["claude"].model
|
||
assert status["openai_compat"].available is True
|
||
mock_client = httpx.Client(
|
||
transport=httpx.MockTransport(lambda req: httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]}))
|
||
)
|
||
claude = create_provider("claude", cfg, http_client=mock_client)
|
||
assert isinstance(claude, ClaudeProvider)
|
||
openai = create_provider("openai_compat", cfg, http_client=mock_client)
|
||
assert isinstance(openai, OpenAICompatProvider)
|
||
assert isinstance(claude, LLMProvider)
|
||
|
||
|
||
def test_registry_unknown_provider():
|
||
with pytest.raises(ValueError):
|
||
create_provider("llamacpp", Config())
|