Files
quercus/tests/core/test_providers.py
T
Mike-Solar e0db5ab2cf P1: host-agnostic quercus-core with agent loop, providers, and tool schemas
- 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)
2026-08-25 02:06:10 +08:00

322 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())