mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
Use the chat provider to apply transport-specific metadata cleanup. Keep finish_reason removal in the common message conversion path. Verify streaming and non-streaming HTTP payloads through LiteLLM, metadata preservation for other providers, and source message immutability.
387 lines
14 KiB
Python
387 lines
14 KiB
Python
"""Unit tests for LiteLLMRequester message/tool conversion.
|
|
|
|
This includes provider_specific_fields round-trip coverage for GitHub issue
|
|
#1899 and token counting preflight behavior for Runner context budgeting.
|
|
"""
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|
|
|
from langbot.pkg.provider.modelmgr import requester as model_requester
|
|
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester, _LeasedHTTPHandler
|
|
|
|
|
|
def _make_requester() -> LiteLLMRequester:
|
|
# _convert_messages and _normalize_stream_tool_calls do not touch instance config.
|
|
return LiteLLMRequester.__new__(LiteLLMRequester)
|
|
|
|
|
|
def _make_configured_requester() -> LiteLLMRequester:
|
|
req = LiteLLMRequester.__new__(LiteLLMRequester)
|
|
req.requester_cfg = {
|
|
'base_url': '',
|
|
'timeout': 120,
|
|
'custom_llm_provider': 'openai',
|
|
'drop_params': False,
|
|
'num_retries': 0,
|
|
'api_version': '',
|
|
}
|
|
req.ap = SimpleNamespace(
|
|
tool_mgr=SimpleNamespace(
|
|
generate_tools_for_openai=AsyncMock(
|
|
return_value=[
|
|
{
|
|
'type': 'function',
|
|
'function': {
|
|
'name': 'search',
|
|
'description': 'Search',
|
|
'parameters': {'type': 'object'},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
)
|
|
)
|
|
return req
|
|
|
|
|
|
def _make_runtime_model() -> model_requester.RuntimeLLMModel:
|
|
provider = SimpleNamespace(token_mgr=SimpleNamespace(get_token=Mock(return_value='sk-test')))
|
|
return SimpleNamespace(
|
|
model_entity=SimpleNamespace(
|
|
name='gpt-4.1',
|
|
extra_args={'temperature': 0.2},
|
|
),
|
|
provider=provider,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_count_tokens_uses_litellm_counter_with_request_messages_and_tools():
|
|
"""Token preflight uses the same LiteLLM request shape as chat completion."""
|
|
req = _make_configured_requester()
|
|
model = _make_runtime_model()
|
|
tool = resource_tool.LLMTool(
|
|
name='search',
|
|
human_desc='Search',
|
|
description='Search',
|
|
parameters={'type': 'object'},
|
|
func=lambda **kwargs: None,
|
|
)
|
|
|
|
with patch(
|
|
'langbot.pkg.provider.modelmgr.requesters.litellmchat.litellm.token_counter', return_value=42
|
|
) as counter:
|
|
tokens = await req.count_tokens(
|
|
model=model,
|
|
messages=[
|
|
provider_message.Message(
|
|
role='user',
|
|
content=[
|
|
provider_message.ContentElement(type='text', text='hello'),
|
|
provider_message.ContentElement(type='file_url', file_url='https://example.test/a.pdf'),
|
|
],
|
|
)
|
|
],
|
|
funcs=[tool],
|
|
extra_args={'presence_penalty': 0.1},
|
|
)
|
|
|
|
assert tokens == 42
|
|
counter.assert_called_once()
|
|
kwargs = counter.call_args.kwargs
|
|
assert kwargs['model'] == 'openai/gpt-4.1'
|
|
assert kwargs['messages'] == [{'role': 'user', 'content': [{'type': 'text', 'text': 'hello'}]}]
|
|
assert kwargs['tools'][0]['function']['name'] == 'search'
|
|
assert kwargs['tool_choice'] == 'auto'
|
|
|
|
|
|
def test_convert_messages_preserves_tool_call_provider_specific_fields():
|
|
"""Tool calls should retain provider_specific_fields through _convert_messages."""
|
|
req = _make_requester()
|
|
msg = provider_message.Message(
|
|
role='assistant',
|
|
content=None,
|
|
tool_calls=[
|
|
provider_message.ToolCall(
|
|
id='call_123',
|
|
type='function',
|
|
function=provider_message.FunctionCall(
|
|
name='search',
|
|
arguments='{"query": "test"}',
|
|
),
|
|
provider_specific_fields={
|
|
'thought_signature': 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZQ==',
|
|
},
|
|
),
|
|
],
|
|
)
|
|
out = req._convert_messages([msg])
|
|
assert len(out) == 1
|
|
assert out[0]['tool_calls'] is not None
|
|
assert len(out[0]['tool_calls']) == 1
|
|
|
|
tc = out[0]['tool_calls'][0]
|
|
assert tc['id'] == 'call_123'
|
|
assert tc['function']['name'] == 'search'
|
|
assert 'provider_specific_fields' in tc
|
|
assert tc['provider_specific_fields']['thought_signature'] == 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZQ=='
|
|
|
|
|
|
def test_convert_messages_preserves_message_provider_specific_fields():
|
|
"""Messages should retain provider_specific_fields through _convert_messages."""
|
|
req = _make_requester()
|
|
msg = provider_message.Message(
|
|
role='assistant',
|
|
content='Hello',
|
|
provider_specific_fields={
|
|
'thought_signatures': ['sig1', 'sig2'],
|
|
},
|
|
)
|
|
out = req._convert_messages([msg])
|
|
assert len(out) == 1
|
|
assert 'provider_specific_fields' in out[0]
|
|
assert out[0]['provider_specific_fields']['thought_signatures'] == ['sig1', 'sig2']
|
|
|
|
|
|
@pytest.mark.parametrize('family', ['', 'openai', 'gemini', 'groq'])
|
|
def test_response_finish_reason_is_not_replayed(family):
|
|
req = _make_requester()
|
|
msg = provider_message.Message(
|
|
role='assistant', content='Hello', provider_specific_fields={'finish_reason': 'stop'}
|
|
)
|
|
|
|
assert req._convert_messages([msg], reasoning_family=family) == [{'role': 'assistant', 'content': 'Hello'}]
|
|
assert msg.provider_specific_fields == {'finish_reason': 'stop'}
|
|
|
|
|
|
def test_response_metadata_removal_preserves_gemini_signatures():
|
|
req = _make_requester()
|
|
msg = provider_message.Message(
|
|
role='assistant',
|
|
content='Hello',
|
|
provider_specific_fields={'finish_reason': 'stop', 'thought_signatures': ['signature']},
|
|
)
|
|
|
|
out = req._convert_messages([msg], reasoning_family='gemini')
|
|
|
|
assert out[0]['provider_specific_fields'] == {'thought_signatures': ['signature']}
|
|
|
|
|
|
@pytest.fixture
|
|
def replayed_tool_message():
|
|
return provider_message.Message(
|
|
role='assistant',
|
|
content='Hello',
|
|
provider_specific_fields={'finish_reason': 'tool_calls', 'other_response_metadata': True},
|
|
tool_calls=[
|
|
provider_message.ToolCall(
|
|
id='call-1',
|
|
type='function',
|
|
function=provider_message.FunctionCall(name='search', arguments='{}'),
|
|
provider_specific_fields={'thought_signature': 'signature'},
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('stream', [False, True])
|
|
@pytest.mark.parametrize('chat_provider,family', [('groq', 'openai'), ('gemini', 'groq'), ('openrouter', 'groq')])
|
|
async def test_completion_metadata_adaptation_uses_chat_provider(stream, chat_provider, family, replayed_tool_message):
|
|
req = _make_configured_requester()
|
|
req.requester_cfg['custom_llm_provider'] = chat_provider
|
|
model = _make_runtime_model()
|
|
model.model_entity.name = 'openai/gpt-oss-120b'
|
|
msg = replayed_tool_message
|
|
original = msg.model_dump()
|
|
|
|
with patch.object(req, '_reasoning_family', return_value=family):
|
|
args = await req._build_completion_args(model, [msg], None, {}, stream=stream)
|
|
|
|
assert args['model'] == f'{chat_provider}/openai/gpt-oss-120b'
|
|
expected = msg.model_dump(exclude_none=True)
|
|
expected['provider_specific_fields'].pop('finish_reason')
|
|
if chat_provider == 'groq':
|
|
expected.pop('provider_specific_fields')
|
|
expected['tool_calls'][0].pop('provider_specific_fields')
|
|
assert args['messages'] == [expected]
|
|
assert msg.model_dump() == original
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('stream', [False, True])
|
|
async def test_groq_http_request_excludes_replayed_metadata(stream, replayed_tool_message):
|
|
req = _make_configured_requester()
|
|
req.requester_cfg.update(custom_llm_provider='groq', base_url='https://groq.test/openai/v1')
|
|
model = _make_runtime_model()
|
|
model.model_entity.name = 'openai/gpt-oss-120b'
|
|
msg = replayed_tool_message
|
|
original = msg.model_dump()
|
|
bodies = []
|
|
|
|
def handle(request):
|
|
assert request.url == 'https://groq.test/openai/v1/chat/completions'
|
|
bodies.append(json.loads(request.content))
|
|
response = {
|
|
'id': 'completion-1',
|
|
'object': 'chat.completion',
|
|
'created': 1,
|
|
'model': 'openai/gpt-oss-120b',
|
|
'service_tier': 'on_demand',
|
|
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'Done'}, 'finish_reason': 'stop'}],
|
|
'usage': {'prompt_tokens': 1, 'completion_tokens': 1, 'total_tokens': 2},
|
|
}
|
|
if stream:
|
|
response['object'] = 'chat.completion.chunk'
|
|
response['choices'][0]['delta'] = response['choices'][0].pop('message')
|
|
return httpx.Response(
|
|
200,
|
|
headers={'content-type': 'text/event-stream'},
|
|
content=f'data: {json.dumps(response)}\n\ndata: [DONE]\n\n',
|
|
)
|
|
return httpx.Response(200, json=response)
|
|
|
|
messages = [
|
|
provider_message.Message(role='user', content='Search'),
|
|
msg,
|
|
provider_message.Message(role='tool', tool_call_id='call-1', content='Found'),
|
|
]
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
|
|
extra_args = {'client': _LeasedHTTPHandler(client, 10)}
|
|
if stream:
|
|
chunks = [chunk async for chunk in req.invoke_llm_stream(None, model, messages, extra_args=extra_args)]
|
|
assert ''.join(chunk.content or '' for chunk in chunks) == 'Done'
|
|
else:
|
|
result, _ = await req.invoke_llm(None, model, messages, extra_args=extra_args)
|
|
assert result.content == 'Done'
|
|
|
|
assert len(bodies) == 1
|
|
assert bodies[0]['model'] == 'openai/gpt-oss-120b'
|
|
assert bodies[0]['messages'] == [
|
|
{'role': 'user', 'content': 'Search'},
|
|
{
|
|
'role': 'assistant',
|
|
'content': 'Hello',
|
|
'tool_calls': [{'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}}],
|
|
},
|
|
{'role': 'tool', 'tool_call_id': 'call-1', 'content': 'Found'},
|
|
]
|
|
assert msg.model_dump() == original
|
|
|
|
|
|
def test_normalize_stream_tool_calls_preserves_provider_specific_fields():
|
|
"""Streaming tool calls should retain provider_specific_fields."""
|
|
req = _make_requester()
|
|
tool_call_state: dict[int, dict] = {}
|
|
|
|
# Simulate first chunk with id and type
|
|
raw_tool_calls_1 = [
|
|
{
|
|
'index': 0,
|
|
'id': 'call_abc',
|
|
'type': 'function',
|
|
'function': {
|
|
'name': 'get_weather',
|
|
'arguments': '',
|
|
},
|
|
'provider_specific_fields': {
|
|
'thought_signature': 'dGVzdF9zaWduYXR1cmU=',
|
|
},
|
|
},
|
|
]
|
|
result_1 = req._normalize_stream_tool_calls(raw_tool_calls_1, tool_call_state)
|
|
assert result_1 is not None
|
|
assert len(result_1) == 1
|
|
assert result_1[0]['provider_specific_fields']['thought_signature'] == 'dGVzdF9zaWduYXR1cmU='
|
|
|
|
# Simulate second chunk without provider_specific_fields (should be retained from state)
|
|
raw_tool_calls_2 = [
|
|
{
|
|
'index': 0,
|
|
'function': {
|
|
'arguments': '{"city": "Tokyo"}',
|
|
},
|
|
},
|
|
]
|
|
result_2 = req._normalize_stream_tool_calls(raw_tool_calls_2, tool_call_state)
|
|
assert result_2 is not None
|
|
assert len(result_2) == 1
|
|
# Should retain the provider_specific_fields from the first chunk
|
|
assert result_2[0]['provider_specific_fields']['thought_signature'] == 'dGVzdF9zaWduYXR1cmU='
|
|
assert result_2[0]['function']['arguments'] == '{"city": "Tokyo"}'
|
|
|
|
|
|
def test_normalize_stream_tool_calls_merges_function_level_psf():
|
|
"""Function-level provider_specific_fields should be merged into tool-level."""
|
|
req = _make_requester()
|
|
tool_call_state: dict[int, dict] = {}
|
|
|
|
raw_tool_calls = [
|
|
{
|
|
'index': 0,
|
|
'id': 'call_xyz',
|
|
'type': 'function',
|
|
'function': {
|
|
'name': 'search',
|
|
'arguments': '{}',
|
|
'provider_specific_fields': {
|
|
'thought_signature': 'ZnVuY19sZXZlbF9zaWc=',
|
|
},
|
|
},
|
|
},
|
|
]
|
|
result = req._normalize_stream_tool_calls(raw_tool_calls, tool_call_state)
|
|
assert result is not None
|
|
assert result[0]['provider_specific_fields']['thought_signature'] == 'ZnVuY19sZXZlbF9zaWc='
|
|
|
|
|
|
def test_tool_call_roundtrip_through_message_entity():
|
|
"""Full round-trip: LiteLLM response dict -> Message entity -> _convert_messages."""
|
|
# Simulate what LiteLLM returns for a Gemini tool call response
|
|
message_data = {
|
|
'role': 'assistant',
|
|
'content': None,
|
|
'tool_calls': [
|
|
{
|
|
'id': 'call_gemini_123',
|
|
'type': 'function',
|
|
'function': {
|
|
'name': 'web_search',
|
|
'arguments': '{"query": "test"}',
|
|
},
|
|
'provider_specific_fields': {
|
|
'thought_signature': 'Z2VtaW5pX3NpZ25hdHVyZQ==',
|
|
},
|
|
},
|
|
],
|
|
'provider_specific_fields': {
|
|
'thought_signatures': ['Z2VtaW5pX3NpZ25hdHVyZQ=='],
|
|
},
|
|
}
|
|
|
|
# Parse into Message entity (this is what invoke_llm does)
|
|
msg = provider_message.Message(**message_data)
|
|
|
|
# Verify the entity has the fields
|
|
assert msg.tool_calls is not None
|
|
assert len(msg.tool_calls) == 1
|
|
assert msg.tool_calls[0].provider_specific_fields is not None
|
|
assert msg.tool_calls[0].provider_specific_fields['thought_signature'] == 'Z2VtaW5pX3NpZ25hdHVyZQ=='
|
|
assert msg.provider_specific_fields is not None
|
|
assert msg.provider_specific_fields['thought_signatures'] == ['Z2VtaW5pX3NpZ25hdHVyZQ==']
|
|
|
|
# Convert back to dict for LiteLLM (this is what _convert_messages does)
|
|
req = _make_requester()
|
|
out = req._convert_messages([msg])
|
|
|
|
# Verify the fields are preserved in the output
|
|
assert out[0]['tool_calls'][0]['provider_specific_fields']['thought_signature'] == 'Z2VtaW5pX3NpZ25hdHVyZQ=='
|
|
assert out[0]['provider_specific_fields']['thought_signatures'] == ['Z2VtaW5pX3NpZ25hdHVyZQ==']
|