Support Anthropic-routed Claude models
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user