Support Anthropic-routed Claude models

This commit is contained in:
武阳
2026-05-08 12:17:00 +08:00
parent 02a7ea23dd
commit cd84fcc69f
5 changed files with 622 additions and 18 deletions
+1
View File
@@ -78,6 +78,7 @@ class ModelListTests(unittest.TestCase):
[
'azure_openai/gpt-5',
'ppio/gemini-2.0-flash-20250609',
'ppio/pa/claude-opus-4-7',
'siliconflow/Pro/deepseek-ai/DeepSeek-V3',
'xiaomi/DeepSeek-R1-0528',
],
+220
View File
@@ -1,10 +1,13 @@
from __future__ import annotations
import json
import unittest
from unittest.mock import patch
from src.openai_compat import (
OpenAICompatClient,
OpenAICompatError,
_anthropic_base_url,
_build_response_format,
_join_url,
_normalize_content,
@@ -12,10 +15,45 @@ from src.openai_compat import (
_parse_tool_arguments,
_parse_usage,
_temperature_for_model,
_uses_anthropic_messages_api,
)
from src.agent_types import ModelConfig, OutputSchemaConfig, UsageStats
class FakeHTTPResponse:
def __init__(self, payload: dict[str, object]) -> None:
self.payload = payload
def read(self) -> bytes:
return json.dumps(self.payload).encode('utf-8')
def __enter__(self) -> 'FakeHTTPResponse':
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
class FakeStreamingHTTPResponse:
def __init__(self, payloads: list[dict[str, object]]) -> None:
self.lines: list[bytes] = []
for payload in payloads:
self.lines.append(b'event: message\n')
self.lines.append(f'data: {json.dumps(payload)}\n'.encode('utf-8'))
self.lines.append(b'\n')
def readline(self) -> bytes:
if not self.lines:
return b''
return self.lines.pop(0)
def __enter__(self) -> 'FakeStreamingHTTPResponse':
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
class TestJoinUrl(unittest.TestCase):
def test_base_with_trailing_slash(self):
self.assertEqual(_join_url('http://localhost:8000/', 'v1/chat'), 'http://localhost:8000/v1/chat')
@@ -210,5 +248,187 @@ class TestProviderTemperatureFloor(unittest.TestCase):
self.assertEqual(payload['tool_choice'], 'auto')
class TestAnthropicMessagesRouting(unittest.TestCase):
def test_ppio_pa_claude_uses_anthropic_messages_api(self):
self.assertTrue(
_uses_anthropic_messages_api(
'ppio/pa/claude-opus-4-7',
'http://model.mify.ai.srv/v1',
)
)
self.assertFalse(
_uses_anthropic_messages_api(
'ppio/gemini-2.5-pro',
'http://model.mify.ai.srv/v1',
)
)
def test_anthropic_base_url_is_derived_from_openai_base(self):
self.assertEqual(
_anthropic_base_url('http://model.mify.ai.srv/v1'),
'http://model.mify.ai.srv/anthropic',
)
self.assertEqual(
_anthropic_base_url('http://model.mify.ai.srv/anthropic'),
'http://model.mify.ai.srv/anthropic',
)
def test_anthropic_complete_converts_tools_and_parses_tool_use(self):
recorded: dict[str, object] = {}
def fake_urlopen(request_obj, timeout=None): # noqa: ANN001
recorded['url'] = request_obj.full_url
recorded['payload'] = json.loads(request_obj.data.decode('utf-8'))
return FakeHTTPResponse(
{
'id': 'msg_1',
'type': 'message',
'role': 'assistant',
'content': [
{'type': 'text', 'text': '我来读取文件。'},
{
'type': 'tool_use',
'id': 'toolu_1',
'name': 'read_file',
'input': {'path': 'hello.txt'},
},
],
'stop_reason': 'tool_use',
'usage': {'input_tokens': 10, 'output_tokens': 4},
}
)
client = OpenAICompatClient(
ModelConfig(
model='ppio/pa/claude-opus-4-7',
base_url='http://model.mify.ai.srv/v1',
api_key='token',
)
)
with patch('src.openai_compat.request.urlopen', side_effect=fake_urlopen):
turn = client.complete(
messages=[
{'role': 'system', 'content': '你是工具型助手。'},
{'role': 'user', 'content': '读取 hello.txt'},
],
tools=[
{
'type': 'function',
'function': {
'name': 'read_file',
'description': '读取文件',
'parameters': {
'type': 'object',
'properties': {'path': {'type': 'string'}},
'required': ['path'],
},
},
}
],
)
self.assertEqual(
recorded['url'],
'http://model.mify.ai.srv/anthropic/v1/messages',
)
payload = recorded['payload']
self.assertEqual(payload['model'], 'ppio/pa/claude-opus-4-7')
self.assertEqual(payload['system'], '你是工具型助手。')
self.assertEqual(payload['tools'][0]['name'], 'read_file')
self.assertEqual(payload['tools'][0]['input_schema']['required'], ['path'])
self.assertEqual(turn.content, '我来读取文件。')
self.assertEqual(turn.finish_reason, 'tool_use')
self.assertEqual(turn.tool_calls[0].id, 'toolu_1')
self.assertEqual(turn.tool_calls[0].arguments, {'path': 'hello.txt'})
self.assertEqual(turn.usage.input_tokens, 10)
def test_anthropic_stream_parses_text_usage_and_tool_use(self):
payloads = [
{
'type': 'message_start',
'message': {'usage': {'input_tokens': 7, 'output_tokens': 1}},
},
{
'type': 'content_block_start',
'index': 0,
'content_block': {'type': 'text', 'text': ''},
},
{
'type': 'content_block_delta',
'index': 0,
'delta': {'type': 'text_delta', 'text': '读取'},
},
{
'type': 'content_block_start',
'index': 1,
'content_block': {
'type': 'tool_use',
'id': 'toolu_1',
'name': 'read_file',
'input': {},
},
},
{
'type': 'content_block_delta',
'index': 1,
'delta': {'type': 'input_json_delta', 'partial_json': '{"path":'},
},
{
'type': 'content_block_delta',
'index': 1,
'delta': {'type': 'input_json_delta', 'partial_json': '"hello.txt"}'},
},
{
'type': 'message_delta',
'delta': {'stop_reason': 'tool_use'},
'usage': {'output_tokens': 5},
},
{'type': 'message_stop'},
]
def fake_urlopen(request_obj, timeout=None): # noqa: ANN001
return FakeStreamingHTTPResponse(payloads)
client = OpenAICompatClient(
ModelConfig(
model='ppio/pa/claude-opus-4-7',
base_url='http://model.mify.ai.srv/v1',
api_key='token',
)
)
with patch('src.openai_compat.request.urlopen', side_effect=fake_urlopen):
events = list(
client.stream(
messages=[{'role': 'user', 'content': '读取 hello.txt'}],
tools=[
{
'type': 'function',
'function': {
'name': 'read_file',
'parameters': {'type': 'object'},
},
}
],
)
)
self.assertEqual(events[0].type, 'message_start')
self.assertEqual(
''.join(event.delta for event in events if event.type == 'content_delta'),
'读取',
)
tool_events = [event for event in events if event.type == 'tool_call_delta']
self.assertEqual(tool_events[0].tool_call_index, 0)
self.assertEqual(tool_events[0].tool_call_id, 'toolu_1')
self.assertEqual(tool_events[0].tool_name, 'read_file')
self.assertEqual(
''.join(event.arguments_delta for event in tool_events),
'{"path":"hello.txt"}',
)
self.assertTrue(any(event.type == 'usage' for event in events))
self.assertEqual(events[-1].type, 'message_stop')
self.assertEqual(events[-1].finish_reason, 'tool_use')
if __name__ == '__main__':
unittest.main()