Files
LangBot/tests/unit_tests/provider/test_request_policy.py
T
RockChinQandClaude Opus 5.5 653f9e731d Merge origin/master into feat/cloud-v2-subscriptions
Keep i18n'd requester error messages while adopting master's structured
RequesterError metadata.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:37:14 +08:00

550 lines
20 KiB
Python

import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import httpx
import litellm
import pytest
from langbot.pkg.provider.modelmgr.errors import RequesterError
from langbot.pkg.provider.modelmgr.request_policy import (
CallState,
RequestPolicy,
current_call,
error_metadata,
opaque_conversation_id,
)
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
from tests.unit_tests.provider.conftest import TEST_EXECUTION_CONTEXT
from tests.unit_tests.provider.test_litellmchat import MockRuntimeModel
def test_policy_precedence_zero_and_no_mutation():
model = {'num_retries': 3, 'request_policy': {'read_timeout': 42}}
policy = RequestPolicy.resolve({'request_policy': {'retries': 2}}, model, {'num_retries': 0})
assert policy.retries == 0
assert policy.read_timeout == 42
assert model['num_retries'] == 3
@pytest.mark.parametrize(
'policy',
[
{'retries': True},
{'retries': -1},
{'retries': 6},
{'read_timeout': 0},
{'total_timeout': float('nan')},
{'connect_timeout': float('inf')},
{'attribution': 'false'},
{'conversation_header': 'authorization'},
{'typo': 1},
],
)
def test_invalid_policy(policy):
with pytest.raises(RequesterError, match='Model request failed|模型请求失败'):
RequestPolicy.resolve({'request_policy': policy})
def test_retry_metadata_does_not_include_upstream_secrets():
response = httpx.Response(429, headers={'retry-after': '2', 'x-request-id': 'req-123'})
meta = error_metadata(SimpleNamespace(status_code=429, response=response))
assert meta == {'status_code': 429, 'retry_after': 2, 'request_id': 'req-123'}
adapter = LiteLLMRequester(Mock(), {})
exc = litellm.RateLimitError('secret-key-and-prompt', llm_provider='openai', model='test', response=response)
with pytest.raises(RequesterError) as caught:
adapter._handle_litellm_error(exc)
assert caught.value.retryable
assert caught.value.request_id == 'req-123'
assert 'secret-key' not in str(caught.value)
def test_quota_is_not_retryable():
adapter = LiteLLMRequester(Mock(), {})
exc = litellm.RateLimitError('quota', llm_provider='openai', model='test')
exc.body = {'error': {'code': 'insufficient_quota'}}
with pytest.raises(RequesterError) as caught:
adapter._handle_litellm_error(exc)
assert caught.value.code == 'quota_exceeded'
assert not caught.value.retryable
@pytest.mark.asyncio
async def test_headers_and_sdk_retry_ownership():
adapter = LiteLLMRequester(Mock(), {'base_url': 'https://openrouter.ai/api/v1'})
state = CallState(RequestPolicy(retries=2), 'lb-opaque')
token = current_call.set(state)
try:
args = await adapter._build_completion_args(
MockRuntimeModel(),
[],
extra_args={
'request_policy': {'retries': 2},
'num_retries': 2,
'extra_headers': {'x-openrouter-title': 'Custom app', 'x-other': 'keep'},
},
)
finally:
current_call.reset(token)
assert 'request_policy' not in args
assert args['num_retries'] == args['max_retries'] == 0
assert isinstance(args['timeout'], httpx.Timeout)
assert args['extra_headers'] == {
'x-openrouter-title': 'Custom app',
'x-other': 'keep',
'HTTP-Referer': 'https://langbot.app',
'x-session-id': 'lb-opaque',
}
@pytest.mark.asyncio
@pytest.mark.parametrize('url', ['https://gateway.example/v1', 'https://openrouter.ai.evil.example/v1'])
async def test_no_vendor_headers_on_unrelated_hosts(url):
adapter = LiteLLMRequester(Mock(), {'base_url': url})
args = await adapter._build_completion_args(MockRuntimeModel(), [])
assert not args.get('extra_headers')
assert args['num_retries'] == 0
def test_conversation_is_stable_and_scoped():
query = SimpleNamespace(session=None, launcher_type='person', launcher_id='123', bot_uuid='bot', pipeline_uuid='p')
first = opaque_conversation_id(query, TEST_EXECUTION_CONTEXT)
assert first == opaque_conversation_id(query, TEST_EXECUTION_CONTEXT)
query.bot_uuid = 'another-bot'
assert first != opaque_conversation_id(query, TEST_EXECUTION_CONTEXT)
assert opaque_conversation_id(None, TEST_EXECUTION_CONTEXT) is None
@pytest.mark.asyncio
async def test_runtime_retries_only_before_output(runtime_provider, runtime_llm_model):
provider = runtime_provider
attempts = 0
closed = []
async def stream(**kwargs):
nonlocal attempts
attempts += 1
try:
if attempts == 1:
raise RequesterError('transient', retryable=True)
yield MessageChunk(role='assistant', content='first')
raise RequesterError('after output', retryable=True)
finally:
closed.append(attempts)
provider.requester.invoke_llm_stream = stream
result = []
with pytest.raises(RequesterError, match='after output'):
async for chunk in provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'retries': 3, 'backoff_base': 0.001}},
execution_context=TEST_EXECUTION_CONTEXT,
):
result.append(chunk.content)
assert result == ['first']
assert attempts == 2
assert closed == [1, 2]
assert current_call.get() is None
@pytest.mark.asyncio
async def test_runtime_deadline_and_close(runtime_provider, runtime_llm_model):
closed = asyncio.Event()
async def stream(**kwargs):
try:
await asyncio.Event().wait()
yield MessageChunk(role='assistant', content='never')
finally:
closed.set()
runtime_provider.requester.invoke_llm_stream = stream
with pytest.raises(RequesterError) as caught:
async for _ in runtime_provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'first_event_timeout': 0.02}},
execution_context=TEST_EXECUTION_CONTEXT,
):
pass
assert caught.value.phase == 'first_event'
assert closed.is_set()
@pytest.mark.asyncio
async def test_retry_after_cannot_extend_total_budget():
state = CallState(RequestPolicy(retries=5, total_timeout=0.1))
state.metrics['attempts'] = 1
assert not await state.retry(RequesterError('busy', retryable=True, retry_after=30))
@pytest.mark.asyncio
async def test_cancellation_is_not_retried(runtime_provider, runtime_llm_model):
invoke = AsyncMock(side_effect=asyncio.CancelledError)
runtime_provider.requester.invoke_llm = invoke
with pytest.raises(asyncio.CancelledError):
await runtime_provider.invoke_llm(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'retries': 3}},
execution_context=TEST_EXECUTION_CONTEXT,
)
assert invoke.await_count == 1
@pytest.mark.asyncio
async def test_complete_usage_and_timings_persist(runtime_provider, runtime_llm_model):
from langbot_plugin.api.entities.builtin.pipeline.query import Query
query = Query.model_construct(
query_id=1, launcher_type='person', launcher_id=1, variables={}, bot_uuid='bot', pipeline_uuid='p', session=None
)
object.__setattr__(query, '_execution_context', TEST_EXECUTION_CONTEXT)
usage = {
'prompt_tokens': 20,
'completion_tokens': 10,
'total_tokens': 30,
'prompt_tokens_details': {'cached_tokens': 15},
'completion_tokens_details': {'reasoning_tokens': 8},
'cost': 0.01,
}
runtime_provider.requester.invoke_llm = AsyncMock(return_value=(Message(role='assistant', content='ok'), usage))
with patch(
'langbot.pkg.pipeline.monitoring_helper.MonitoringHelper.record_llm_call', new_callable=AsyncMock
) as record:
result = await runtime_provider.invoke_llm(query, runtime_llm_model, [])
assert result.content == 'ok'
details = record.call_args.kwargs['details']
assert details['usage'] == usage
assert details['timing']['attempts'] == 1
assert details['timing']['total_ms'] >= details['timing']['response_completed_ms']
assert query.variables['_llm_usage'] == usage
@pytest.mark.asyncio
async def test_hidden_reasoning_marks_raw_event_and_usage():
state = CallState(RequestPolicy())
adapter = LiteLLMRequester(Mock(), {})
async def raw():
yield SimpleNamespace(
choices=[
SimpleNamespace(
delta=SimpleNamespace(model_dump=lambda: {'reasoning_content': 'hidden'}), finish_reason=None
)
]
)
yield SimpleNamespace(choices=[], usage={'prompt_tokens': 3, 'completion_tokens': 2, 'total_tokens': 5})
token = current_call.set(state)
try:
with patch(
'langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=AsyncMock(return_value=raw())
):
chunks = [c async for c in adapter.invoke_llm_stream(None, MockRuntimeModel(), [], remove_think=True)]
finally:
current_call.reset(token)
assert state.raw_started
assert 'first_event_ms' in state.metrics and 'first_reasoning_ms' in state.metrics
assert 'first_content_ms' not in state.metrics
assert state.usage['total_tokens'] == 5
assert not any(c.content for c in chunks)
@pytest.mark.asyncio
async def test_real_http_retry_count_and_request_body(runtime_provider, runtime_llm_model):
"""Exercise the pinned LiteLLM/OpenAI SDK against a local HTTP server."""
from aiohttp import web
received = []
async def handler(request):
received.append(await request.json())
if len(received) == 1:
return web.json_response({'error': {'message': 'busy', 'type': 'server_error'}}, status=503)
return web.json_response(
{
'id': 'chatcmpl-local',
'object': 'chat.completion',
'created': 1,
'model': 'gpt-4o',
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}],
'usage': {'prompt_tokens': 4, 'completion_tokens': 1, 'total_tokens': 5},
}
)
app = web.Application()
app.router.add_post('/v1/chat/completions', handler)
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]
runtime_provider.requester = LiteLLMRequester(
Mock(),
{
'base_url': f'http://127.0.0.1:{port}/v1',
'custom_llm_provider': 'openai',
},
)
runtime_llm_model.model_entity.name = 'gpt-4o'
runtime_llm_model.model_entity.extra_args = {}
try:
with pytest.raises(RequesterError):
await runtime_provider.invoke_llm(
None,
runtime_llm_model,
[Message(role='user', content='hello')],
extra_args={'num_retries': 0},
execution_context=TEST_EXECUTION_CONTEXT,
)
assert len(received) == 1
received.clear()
result = await runtime_provider.invoke_llm(
None,
runtime_llm_model,
[Message(role='user', content='hello')],
extra_args={'request_policy': {'retries': 1, 'backoff_base': 0.001}},
execution_context=TEST_EXECUTION_CONTEXT,
)
assert result.content == 'ok'
assert len(received) == 2
assert all('request_policy' not in body and 'num_retries' not in body for body in received)
finally:
await runner.cleanup()
@pytest.mark.asyncio
async def test_stream_idle_timeout_after_output(runtime_provider, runtime_llm_model):
async def stream(**kwargs):
yield MessageChunk(role='assistant', content='first')
await asyncio.Event().wait()
runtime_provider.requester.invoke_llm_stream = stream
with pytest.raises(RequesterError) as caught:
async for _ in runtime_provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'stream_idle_timeout': 0.02}},
execution_context=TEST_EXECUTION_CONTEXT,
):
pass
assert caught.value.phase == 'stream_idle'
def test_details_migration_preserves_existing_records_and_is_idempotent():
import importlib
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0038_llm_call_details')
engine = sa.create_engine('sqlite://')
try:
with engine.begin() as connection:
connection.exec_driver_sql('CREATE TABLE monitoring_llm_calls (id TEXT PRIMARY KEY)')
connection.exec_driver_sql("INSERT INTO monitoring_llm_calls (id) VALUES ('old')")
with Operations.context(MigrationContext.configure(connection)):
migration.upgrade()
migration.upgrade()
assert connection.exec_driver_sql('SELECT id, details FROM monitoring_llm_calls').one() == ('old', None)
table = sa.Table('monitoring_llm_calls', sa.MetaData(), autoload_with=connection)
payload = {'usage': {'completion_tokens_details': {'reasoning_tokens': 8}}, 'timing': {'first_event_ms': 1}}
connection.execute(table.insert().values(id='new', details=payload))
assert connection.execute(sa.select(table.c.details).where(table.c.id == 'new')).scalar() == payload
finally:
engine.dispose()
@pytest.mark.asyncio
async def test_hidden_raw_events_reset_idle_not_first_visible_deadline(runtime_provider, runtime_llm_model):
adapter = LiteLLMRequester(Mock(), {})
runtime_provider.requester = adapter
runtime_llm_model.model_entity.extra_args = {}
async def raw():
for _ in range(5):
await asyncio.sleep(0.3)
yield SimpleNamespace(
choices=[
SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'role': 'assistant'}), finish_reason=None)
]
)
yield SimpleNamespace(
choices=[
SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'content': 'answer'}), finish_reason='stop')
]
)
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=AsyncMock(return_value=raw())):
chunks = [
chunk
async for chunk in runtime_provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'first_event_timeout': 1, 'stream_idle_timeout': 2}},
execution_context=TEST_EXECUTION_CONTEXT,
)
]
assert ''.join(chunk.content or '' for chunk in chunks) == 'answer'
@pytest.mark.asyncio
async def test_monitoring_service_persists_full_details():
from langbot.pkg.api.http.service.monitoring import MonitoringService
persistence = SimpleNamespace(execute_async=AsyncMock())
service = MonitoringService(SimpleNamespace(persistence_mgr=persistence))
details = {
'usage': {
'prompt_tokens': 4,
'completion_tokens': 3,
'total_tokens': 9,
'cache_read_input_tokens': 2,
'provider_extra': {'cost': 0.01},
},
'timing': {'attempts': 2},
'error': None,
}
await service.record_llm_call(
TEST_EXECUTION_CONTEXT,
bot_id='b',
bot_name='b',
pipeline_id='p',
pipeline_name='p',
session_id='s',
model_name='m',
input_tokens=4,
output_tokens=3,
duration=10,
details=details,
)
values = persistence.execute_async.call_args.args[0].compile().params
assert values['details'] == details
assert values['total_tokens'] == 9
def test_codex_usage_preserves_provider_fields():
from langbot.pkg.provider.modelmgr.requesters.codex import _usage
raw = {
'input_tokens': 4,
'output_tokens': 2,
'cost': 0.02,
'output_tokens_details': {'reasoning_tokens': 1},
'future_counter': 7,
}
normalized = _usage({'usage': raw})
assert all(normalized[key] == value for key, value in raw.items())
assert normalized['total_tokens'] == 6
@pytest.mark.asyncio
async def test_nonstream_uses_response_budget_not_first_event():
adapter = LiteLLMRequester(Mock(), {})
state = CallState(RequestPolicy(first_event_timeout=0.001, total_timeout=1))
async def completion(**kwargs):
await asyncio.sleep(0.02)
return 'complete'
token = current_call.set(state)
try:
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=completion):
assert await adapter._completion({'stream': False}) == 'complete'
finally:
current_call.reset(token)
@pytest.mark.asyncio
@pytest.mark.parametrize('cancel_read', [False, True])
async def test_litellm_stream_close_and_cancel_release_upstream(runtime_provider, runtime_llm_model, cancel_read):
adapter = LiteLLMRequester(Mock(), {})
runtime_provider.requester = adapter
runtime_llm_model.model_entity.extra_args = {}
closed = asyncio.Event()
reading = asyncio.Event()
async def raw():
try:
yield SimpleNamespace(
choices=[
SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'content': 'first'}), finish_reason=None)
]
)
reading.set()
await asyncio.Event().wait()
finally:
closed.set()
completion = AsyncMock(return_value=raw())
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=completion):
stream = runtime_provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'retries': 3}},
execution_context=TEST_EXECUTION_CONTEXT,
)
assert (await anext(stream)).content == 'first'
if cancel_read:
task = asyncio.create_task(anext(stream))
await reading.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
else:
await stream.aclose()
assert closed.is_set()
assert completion.await_count == 1
assert current_call.get() is None
@pytest.mark.asyncio
async def test_cancel_during_backoff_does_not_start_another_attempt(runtime_provider, runtime_llm_model):
runtime_provider.requester.invoke_llm = AsyncMock(side_effect=RequesterError('busy', retryable=True))
sleeping = asyncio.Event()
async def backoff(delay):
sleeping.set()
await asyncio.Event().wait()
with patch('langbot.pkg.provider.modelmgr.request_policy.asyncio.sleep', new=backoff):
task = asyncio.create_task(
runtime_provider.invoke_llm(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'retries': 3}},
execution_context=TEST_EXECUTION_CONTEXT,
)
)
await sleeping.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert runtime_provider.requester.invoke_llm.await_count == 1
@pytest.mark.asyncio
async def test_legacy_none_chunk_is_ignored(runtime_provider, runtime_llm_model):
async def stream(**kwargs):
yield None
yield MessageChunk(role='assistant', content='ok')
runtime_provider.requester.invoke_llm_stream = stream
chunks = [
chunk
async for chunk in runtime_provider.invoke_llm_stream(
None, runtime_llm_model, [], execution_context=TEST_EXECUTION_CONTEXT
)
]
assert [chunk.content for chunk in chunks] == ['ok']