Files
LangBot/tests/unit_tests/provider/test_llm_preparation.py
T
RockChinQandHyu 616981b0cf perf(llm): harden request policy and optimize connections, reasoning and prompt caching (#2661)
* fix(llm): centralize request policy and preserve stream diagnostics

* test(llm): dispose SQLite engine after migration regression

* perf(llm): reuse connections and unify reasoning and prompt cache policy

* fix(llm): preserve explicit native prompt cache options

* perf(llm): fix measured TLS setup and streaming reuse overhead

* fix(llm): resolve plugin request-chain regressions found by E2E

---------

Co-authored-by: Hyu <[email protected]>
2026-10-15 19:48:35 +08:00

517 lines
20 KiB
Python

"""Connection ownership, preparation reuse and provider-specific request policy."""
import asyncio
import copy
import threading
import json
from pathlib import Path
from collections import OrderedDict
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import httpx
import pytest
import yaml
from aiohttp import web
from langbot.pkg.provider.modelmgr import reasoning
from langbot.pkg.provider.modelmgr.errors import RequesterError
from langbot.pkg.provider.modelmgr.http_pool import RequesterHTTPPool
from langbot.pkg.provider.modelmgr.prompt_caching import apply_prompt_caching
from langbot.pkg.provider.modelmgr.request_policy import CallState, RequestPolicy, current_call
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
from langbot.pkg.provider.modelmgr.requesters.litellmchat import _LeasedHTTPHandler
from langbot.pkg.provider.modelmgr.requesters import codex
from langbot_plugin.api.entities.builtin.provider.message import Message
from tests.unit_tests.provider.test_reasoning_control import _runtime_model, _requester
@pytest.mark.asyncio
async def test_pool_retirement_waits_for_stream_lease():
pool = RequesterHTTPPool()
async with pool.lease() as first:
async with pool.lease() as second:
assert first is second
await pool.aclose()
assert not first.is_closed
with pytest.raises(RequesterError, match='retired'):
async with pool.lease():
pass
assert not first.is_closed
assert first.is_closed
await pool.aclose()
@pytest.mark.asyncio
@pytest.mark.parametrize('provider,name', [('openai', 'gpt-4o'), ('openrouter', 'openai/gpt-4o')])
async def test_real_http_reuses_socket_with_rotating_key_and_timeout(monkeypatch, provider, name):
monkeypatch.setenv('NO_PROXY', '127.0.0.1,localhost')
sockets, headers, bodies = [], [], []
async def handle(request):
sockets.append(request.transport)
headers.append(request.headers['Authorization'])
bodies.append(await request.json())
return web.json_response(
{
'id': 'r',
'object': 'chat.completion',
'created': 1,
'model': 'gpt-4o',
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}],
'usage': {'prompt_tokens': 1, 'completion_tokens': 1, 'total_tokens': 2},
}
)
app = web.Application()
app.router.add_post('/v1/chat/completions', handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
adapter = LiteLLMRequester(Mock(), {'custom_llm_provider': provider, 'base_url': f'http://127.0.0.1:{port}/v1'})
await adapter.initialize()
model = _runtime_model(adapter, name=name)
model.provider.token_mgr.get_token = Mock(side_effect=['first-key', 'second-key'])
try:
for seconds in (10, 20):
state = CallState(RequestPolicy(read_timeout=seconds), 'lb-scoped')
token = current_call.set(state)
try:
message, _ = await adapter.invoke_llm(
None,
model,
[Message(role='user', content='Hi')],
extra_args={'prompt_caching': {'mode': 'openai', 'retention': 'in_memory'}},
)
assert message.content == 'ok'
finally:
current_call.reset(token)
assert sockets[0] is sockets[1]
assert headers == ['Bearer first-key', 'Bearer second-key']
assert bodies[0]['prompt_cache_key'] == bodies[1]['prompt_cache_key']
assert bodies[0]['prompt_cache_retention'] == 'in_memory'
assert all('prompt_caching' not in body and 'reasoning_config' not in body for body in bodies)
finally:
await adapter.aclose()
await runner.cleanup()
@pytest.mark.asyncio
@pytest.mark.parametrize('cancel', [False, True])
async def test_stream_holds_pool_lease_until_consumer_close(cancel):
adapter = _requester('openai')
await adapter.initialize()
model = _runtime_model(adapter, name='gpt-4o')
model.provider.token_mgr.get_token = Mock(return_value='test')
closed = asyncio.Event()
async def raw():
try:
yield SimpleNamespace(
choices=[
SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'content': 'hi'}), finish_reason=None)
]
)
await asyncio.Event().wait()
finally:
closed.set()
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=AsyncMock(return_value=raw())):
stream = adapter.invoke_llm_stream(None, model, [])
await anext(stream)
client = adapter._http_pool.client
await adapter.aclose()
assert not client.is_closed
if cancel:
pending = asyncio.create_task(anext(stream))
await asyncio.sleep(0)
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await pending
else:
await stream.aclose()
assert closed.is_set() and client.is_closed
@pytest.mark.asyncio
async def test_preparation_reused_only_within_call_and_is_not_mutated():
adapter = _requester('openai')
adapter.ap.tool_mgr = SimpleNamespace(
generate_tools_for_openai=AsyncMock(
return_value=[{'type': 'function', 'function': {'name': 'tool', 'parameters': {'type': 'object'}}}]
)
)
model = _runtime_model(adapter, name='gpt-4o')
model.provider.token_mgr.get_token = Mock(side_effect=['key-1', 'key-2', 'key-3'])
messages, funcs = [Message(role='user', content='original')], [object()]
state = CallState(RequestPolicy(retries=1))
with patch.object(adapter, '_convert_messages', wraps=adapter._convert_messages) as convert:
token = current_call.set(state)
try:
first = await adapter._build_completion_args(model, messages, funcs)
first['messages'][0]['content'] = 'mutated'
first['tools'][0]['function']['name'] = 'mutated'
second = await adapter._build_completion_args(model, messages, funcs)
finally:
current_call.reset(token)
assert convert.call_count == 1
assert adapter.ap.tool_mgr.generate_tools_for_openai.await_count == 1
assert second['messages'][0]['content'] == 'original'
assert second['tools'][0]['function']['name'] == 'tool'
assert second['api_key'] == 'key-2'
await adapter._build_completion_args(model, messages, funcs)
assert convert.call_count == 2
assert messages[0].content == 'original'
@pytest.mark.asyncio
async def test_token_counter_runs_off_event_loop():
adapter = _requester('openai')
model = _runtime_model(adapter, name='gpt-4o')
model.provider.token_mgr.get_token = Mock(return_value='test')
main_thread = threading.get_ident()
def count(**kwargs):
assert threading.get_ident() != main_thread
return 12
with patch('litellm.token_counter', new=count):
assert await adapter.count_tokens(model, []) == 12
def test_metadata_cache_is_bounded_and_returns_copies():
adapter = _requester('openai')
compute = Mock(return_value={'levels': ['low']})
first = adapter._cached_metadata('model', compute)
first['levels'].append('bad')
assert adapter._cached_metadata('model', compute) == {'levels': ['low']}
assert compute.call_count == 1
for index in range(150):
adapter._cached_metadata(index, lambda: index)
assert len(adapter._metadata_cache) == 128
@pytest.mark.parametrize(
'config',
[
{'budget_tokens': True},
{'budget_tokens': 0},
{'budget_tokens': -1},
{'budget_tokens': 1.5},
{'level': 'high', 'budget_tokens': 1024},
{'level': 'disabled', 'budget_tokens': 1024},
],
)
def test_invalid_reasoning_budget(config):
with pytest.raises(ValueError):
reasoning.normalize_reasoning_config(config)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'provider,name,budget,expected',
[
('anthropic', 'claude-sonnet-4-5', 2048, {'thinking': {'type': 'enabled', 'budget_tokens': 2048}}),
('openai', 'qwen3-32b', 2048, {'extra_body': {'enable_thinking': True, 'thinking_budget': 2048}}),
('gemini', 'gemini-2.5-flash', 2048, {'thinking': {'type': 'enabled', 'budget_tokens': 2048}}),
('openrouter', 'anthropic/claude-sonnet-4.5', 2048, {'extra_body': {'reasoning': {'max_tokens': 2048}}}),
],
)
async def test_budget_mapping_through_existing_extra_args(provider, name, budget, expected):
adapter = _requester(provider)
model = _runtime_model(adapter, name=name)
model.provider.token_mgr.get_token = Mock(return_value='test')
args = await adapter._build_completion_args(
model, [], extra_args={'reasoning_config': {'budget_tokens': budget}, 'max_tokens': 4096}
)
for key, value in expected.items():
assert args[key] == value
assert 'reasoning_config' not in args
assert model.reasoning_config_override is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'provider,name,budget',
[
('openai', 'gpt-5', 2048),
('anthropic', 'claude-opus-4-7', 2048),
('anthropic', 'claude-sonnet-4-5', 512),
('gemini', 'gemini-2.5-flash', 30000),
('gemini', 'gemini-2.5-flash-lite', 128),
],
)
async def test_budget_not_silently_dropped_on_unsupported_model(provider, name, budget):
adapter = _requester(provider)
model = _runtime_model(adapter, name=name)
model.provider.token_mgr.get_token = Mock(return_value='test')
with pytest.raises(RequesterError, match='budget'):
await adapter._build_completion_args(model, [], extra_args={'reasoning_config': {'budget_tokens': budget}})
def test_runner_level_replaces_call_and_model_budget():
adapter = _requester('anthropic')
model = _runtime_model(adapter, name='claude-sonnet-4-5')
model.model_entity.reasoning_config = {'budget_tokens': 2048}
call = {'reasoning_config': {'budget_tokens': 4096}}
assert reasoning.resolve_reasoning_config(model, call)['budget_tokens'] == 4096
model.reasoning_config_override = {'level': 'low'}
assert reasoning.resolve_reasoning_config(model, call) == {'level': 'low'}
def test_anthropic_cache_boundary_stable_and_input_unchanged():
messages = [{'role': 'system', 'content': 'stable instructions'}, {'role': 'user', 'content': 'first'}]
original = copy.deepcopy(messages)
args = {'messages': messages, 'prompt_caching': {'mode': 'anthropic', 'ttl': '1h'}}
apply_prompt_caching(args, None, provider='anthropic', family='anthropic')
prefix = args['messages'][0]
assert prefix['content'][-1]['cache_control'] == {'type': 'ephemeral', 'ttl': '1h'}
assert messages == original
assert 'cache_control' not in args['messages'][1]
args['messages'].append({'role': 'user', 'content': 'second'})
args['prompt_caching'] = {'mode': 'anthropic'}
apply_prompt_caching(args, None, provider='anthropic', family='anthropic')
assert args['messages'][0] == prefix
def test_explicit_cache_settings_win_and_default_does_nothing():
args = {
'model': 'openai/gpt-4o',
'extra_body': {'prompt_cache_key': 'explicit', 'prompt_cache_retention': '24h'},
'prompt_caching': {'mode': 'openai', 'retention': 'in_memory'},
}
apply_prompt_caching(args, CallState(RequestPolicy(), 'lb-scoped'), provider='openai', family='openai')
assert 'prompt_cache_key' not in args and 'prompt_cache_retention' not in args
args = {'messages': [{'role': 'system', 'content': 'same'}], 'prompt_caching': {'mode': 'provider_default'}}
apply_prompt_caching(args, None, provider='anthropic', family='anthropic')
assert args == {'messages': [{'role': 'system', 'content': 'same'}]}
def test_native_cache_options_bypass_model_filter_without_legacy_hints():
args = {
'model': 'openai/gpt-test',
'prompt_cache_options': {'mode': 'implicit', 'ttl': '30m'},
'prompt_caching': {'mode': 'openai', 'retention': '24h'},
}
apply_prompt_caching(args, CallState(RequestPolicy(), 'lb-scoped'), provider='openai', family='openai')
assert args == {
'model': 'openai/gpt-test',
'extra_body': {'prompt_cache_options': {'mode': 'implicit', 'ttl': '30m'}},
}
@pytest.mark.parametrize(
'config',
[
{'mode': 'typo'},
{'mode': 'anthropic', 'ttl': '24h'},
{'mode': 'openai', 'retention': 'forever'},
{'mode': 'provider_default', 'ttl': '1h'},
],
)
def test_invalid_cache_config(config):
with pytest.raises(RequesterError):
apply_prompt_caching(
{'model': 'test', 'messages': [], 'prompt_caching': config}, None, provider='openai', family='openai'
)
@pytest.mark.asyncio
async def test_gateway_transport_never_retries_or_closes_borrowed_client():
calls = 0
async def fail(request):
nonlocal calls
calls += 1
raise httpx.ConnectError('unavailable', request=request)
async with httpx.AsyncClient(transport=httpx.MockTransport(fail)) as client:
handler = _LeasedHTTPHandler(client, 10)
with pytest.raises(httpx.ConnectError):
await handler.post('https://example.com', json={})
await handler.close()
assert calls == 1 and not client.is_closed
@pytest.mark.asyncio
@pytest.mark.parametrize('provider', ['anthropic', 'openrouter', 'openrouter-metadata'])
async def test_anthropic_cache_and_budget_reach_upstream(provider):
bodies = []
async def handle(request):
bodies.append(json.loads(request.content))
if provider == 'anthropic':
payload = {
'id': 'msg_test',
'type': 'message',
'role': 'assistant',
'model': 'claude-sonnet-4-5',
'content': [{'type': 'text', 'text': 'ok'}],
'stop_reason': 'end_turn',
'usage': {'input_tokens': 1, 'output_tokens': 1, 'cache_read_input_tokens': 50},
}
else:
payload = {
'id': 'r',
'object': 'chat.completion',
'created': 1,
'model': 'anthropic/claude-sonnet-4.5',
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}],
'usage': {
'prompt_tokens': 51,
'completion_tokens': 1,
'total_tokens': 52,
'prompt_tokens_details': {'cached_tokens': 50},
},
}
return httpx.Response(200, json=payload)
adapter = _requester('openai' if provider == 'openrouter-metadata' else provider)
if provider == 'openrouter-metadata':
metadata = yaml.safe_load(
(
Path(__file__).parents[3] / 'src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml'
).read_text(encoding='utf-8')
)
adapter.requester_cfg.update(
requester_name=metadata['metadata']['name'], custom_llm_provider=metadata['spec']['litellm_provider']
)
name = 'claude-sonnet-4-5' if provider == 'anthropic' else 'anthropic/claude-sonnet-4.5'
model = _runtime_model(adapter, name=name)
model.provider.token_mgr.get_token = Mock(return_value='test')
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
result, usage = await adapter.invoke_llm(
None,
model,
[
Message(role='system', content='stable'),
Message(role='user', content='hello'),
],
extra_args={
'client': _LeasedHTTPHandler(client, 10),
'reasoning_config': {'budget_tokens': 2048},
'max_tokens': 4096,
'prompt_caching': {'mode': 'anthropic', 'ttl': '1h'},
},
)
assert result.content == 'ok'
assert usage
body = bodies[0]
if provider == 'anthropic':
assert body['system'][-1]['cache_control'] == {'type': 'ephemeral', 'ttl': '1h'}
assert body['thinking']['budget_tokens'] == 2048
else:
assert body['messages'][0]['content'][-1]['cache_control'] == {'type': 'ephemeral', 'ttl': '1h'}
assert body['reasoning']['max_tokens'] == 2048
assert 'reasoning_config' not in body and 'prompt_caching' not in body
@pytest.mark.asyncio
async def test_codex_reuses_real_connection(monkeypatch):
monkeypatch.setenv('NO_PROXY', '127.0.0.1,localhost')
sockets = []
async def handle(request):
sockets.append(request.transport)
await request.read()
events = [
{'type': 'response.output_text.delta', 'delta': 'ok'},
{'type': 'response.completed', 'response': {'id': 'r', 'status': 'completed', 'output': []}},
]
return web.Response(
text=''.join('data: ' + json.dumps(event) + '\n\n' for event in events), content_type='text/event-stream'
)
app = web.Application()
app.router.add_post('/responses', handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
monkeypatch.setattr(codex, 'BASE_URL', f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}')
adapter = object.__new__(codex.CodexRequester)
adapter.workspace, adapter.provider = 'w', 'p'
adapter._replay = OrderedDict()
adapter._http_pool = RequesterHTTPPool()
adapter.auth = SimpleNamespace(
access=AsyncMock(
return_value={
'access_token': 'test',
'account_id': 'a',
'connection_id': 'c',
}
)
)
model = SimpleNamespace(model_entity=SimpleNamespace(name='test', extra_args={}, reasoning_config=None))
try:
for _ in range(2):
message, _ = await adapter.invoke_llm(None, model, [])
assert message.content == 'ok'
assert sockets[0] is sockets[1]
finally:
await adapter.aclose()
await runner.cleanup()
@pytest.mark.asyncio
async def test_explicit_client_and_redirected_provider_are_preserved():
adapter = _requester('openai')
await adapter.initialize()
explicit = object()
for args in ({'model': 'openai/gpt-4o', 'client': explicit}, {'model': 'anthropic/claude-sonnet-4-5'}):
async with adapter._client_args(args) as result:
assert result is args
assert adapter._http_pool.client is None
await adapter.aclose()
@pytest.mark.asyncio
async def test_codex_terminal_tail_is_bounded_and_releases_stream():
closed = asyncio.Event()
class HangingTail(httpx.AsyncByteStream):
async def __aiter__(self):
yield b'data: {"type":"response.completed"}\n\n'
await asyncio.Event().wait()
async def aclose(self):
closed.set()
async with httpx.AsyncClient(
transport=httpx.MockTransport(lambda request: httpx.Response(200, stream=HangingTail()))
) as client:
async with client.stream('GET', 'https://example.com') as response:
events = [event async for event in codex.sse_events(response)]
assert events == [{'type': 'response.completed'}]
assert closed.is_set()
@pytest.mark.asyncio
async def test_custom_tls_keeps_litellm_transport(monkeypatch):
import litellm
adapter = _requester('openai')
await adapter.initialize()
monkeypatch.setattr(litellm, 'ssl_verify', '/custom/ca.pem')
args = {'model': 'openai/gpt-4o'}
async with adapter._client_args(args) as result:
assert result is args
assert adapter._http_pool.client is None
await adapter.aclose()
@pytest.mark.asyncio
async def test_pooled_sdk_preserves_environment_fallbacks(monkeypatch):
monkeypatch.setenv('OPENAI_BASE_URL', 'https://gateway.example/v1')
monkeypatch.setenv('OPENAI_API_KEY', 'env-test-key')
adapter = _requester('openai')
await adapter.initialize()
try:
async with adapter._client_args({'model': 'openai/gpt-4o', 'api_key': ''}) as args:
assert str(args['client'].base_url) == 'https://gateway.example/v1/'
assert args['client'].api_key == 'env-test-key'
finally:
await adapter.aclose()