mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 00:45:25 +08:00
* 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]>
443 lines
18 KiB
Python
443 lines
18 KiB
Python
"""Real HTTP -> Core -> Plugin Runtime -> Runner -> Core -> HTTP provider tests.
|
|
|
|
Only the remote provider is deterministic. No requester, SDK action transport,
|
|
model manager, persistence or monitoring implementation is mocked.
|
|
"""
|
|
|
|
import json
|
|
import importlib.metadata
|
|
import os
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
import shutil
|
|
import select
|
|
import socket
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
import yaml
|
|
import httpx
|
|
from packaging.version import Version
|
|
|
|
from tests.e2e import test_runner_plugin_runtime as runtime
|
|
|
|
# Reuse the real process lifecycle; override the package/config and port below.
|
|
runner_runtime_ports = runtime.runner_runtime_ports
|
|
runner_runtime_process = runtime.runner_runtime_process
|
|
runner_langbot_process = runtime.runner_langbot_process
|
|
runner_client = runtime.runner_client
|
|
|
|
pytestmark = pytest.mark.e2e
|
|
RUNNER = runtime.QA_RUNNER_ID
|
|
USAGE = {
|
|
'prompt_tokens': 24,
|
|
'completion_tokens': 4,
|
|
'total_tokens': 28,
|
|
'prompt_tokens_details': {'cached_tokens': 16},
|
|
'completion_tokens_details': {'reasoning_tokens': 2},
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope='session')
|
|
def runner_e2e_tmpdir(tmp_path_factory):
|
|
# Retain logs/SQLite in pytest's bounded temp history for failure diagnosis.
|
|
return tmp_path_factory.mktemp('llm-chain')
|
|
|
|
|
|
@pytest.fixture(scope='session')
|
|
def runner_e2e_port():
|
|
return runtime._free_port()
|
|
|
|
|
|
@pytest.fixture(scope='session')
|
|
def runner_e2e_config_path(runner_e2e_tmpdir, runner_e2e_port, runner_runtime_ports):
|
|
path = runtime.runner_e2e_config_path.__wrapped__(runner_e2e_tmpdir, runner_e2e_port, runner_runtime_ports)
|
|
root = runner_e2e_tmpdir / 'agent-runner-qa-package'
|
|
manifest = root / 'components/runner/default.yaml'
|
|
data = yaml.safe_load(manifest.read_text(encoding='utf-8'))
|
|
data['spec']['permissions'] = {'models': ['invoke', 'stream', 'count_tokens']}
|
|
data['spec']['config'] = [
|
|
{'name': 'model', 'label': {'en_US': 'Model'}, 'type': 'llm-model-selector', 'required': True},
|
|
]
|
|
manifest.write_text(yaml.safe_dump(data), encoding='utf-8')
|
|
shutil.copyfile(Path(__file__).parent / 'utils/llm_probe_runner.py', root / 'components/runner/default.py')
|
|
if legacy_root := os.environ.get('LLM_E2E_LEGACY_API_DIR'):
|
|
for filename in ('legacy_api.py', 'legacy_message.py'):
|
|
shutil.copyfile(Path(legacy_root) / filename, root / filename)
|
|
shutil.make_archive(str(runner_e2e_tmpdir / 'agent-runner-qa'), 'zip', root_dir=root)
|
|
return path
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
def provider_wire():
|
|
records = []
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = 'HTTP/1.1'
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
def do_POST(self):
|
|
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
|
scenario = self.headers.get('x-e2e-case', 'normal')
|
|
row = {'body': body, 'headers': dict(self.headers), 'case': scenario, 'closed': False}
|
|
records.append(row)
|
|
attempt = sum(x['case'] == scenario for x in records)
|
|
status = 503 if scenario.startswith('retry') and attempt == 1 else 200
|
|
if scenario == 'auth':
|
|
status = 401
|
|
if status != 200:
|
|
data = json.dumps({'error': {'message': 'injected failure', 'type': 'api_error'}}).encode()
|
|
self.send_response(status)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.send_header('Retry-After', '0')
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
return
|
|
if scenario in ('first-timeout', 'total-timeout'):
|
|
time.sleep(1)
|
|
try:
|
|
if not body.get('stream'):
|
|
data = json.dumps(
|
|
{
|
|
'id': 'e2e',
|
|
'object': 'chat.completion',
|
|
'created': 1,
|
|
'model': body['model'],
|
|
'choices': [
|
|
{
|
|
'index': 0,
|
|
'message': {'role': 'assistant', 'content': 'hello world'},
|
|
'finish_reason': 'stop',
|
|
}
|
|
],
|
|
'usage': USAGE,
|
|
}
|
|
).encode()
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/event-stream')
|
|
self.send_header('Transfer-Encoding', 'chunked')
|
|
self.end_headers()
|
|
|
|
def send(event):
|
|
data = ('data: ' + (event if isinstance(event, str) else json.dumps(event)) + '\n\n').encode()
|
|
self.wfile.write(f'{len(data):X}\r\n'.encode() + data + b'\r\n')
|
|
self.wfile.flush()
|
|
|
|
base = {'id': 'e2e', 'object': 'chat.completion.chunk', 'created': 1, 'model': body['model']}
|
|
if scenario.startswith('reasoning-'):
|
|
send(
|
|
{
|
|
**base,
|
|
'choices': [
|
|
{'index': 0, 'delta': {'reasoning_content': 'private thought'}, 'finish_reason': None}
|
|
],
|
|
}
|
|
)
|
|
for index, content in enumerate(['hello ', 'world']):
|
|
send({**base, 'choices': [{'index': 0, 'delta': {'content': content}, 'finish_reason': None}]})
|
|
if index == 0 and (scenario in ('idle-timeout', 'disconnect') or scenario.startswith('cancel-')):
|
|
if scenario == 'disconnect':
|
|
self.close_connection = True
|
|
return
|
|
deadline = time.monotonic() + 1
|
|
while time.monotonic() < deadline:
|
|
if select.select([self.connection], [], [], 0.02)[0]:
|
|
if not self.connection.recv(1, socket.MSG_PEEK):
|
|
row['closed'] = True
|
|
return
|
|
send({**base, 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]})
|
|
send({**base, 'choices': [], 'usage': USAGE})
|
|
send('[DONE]')
|
|
self.wfile.write(b'0\r\n\r\n')
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
|
row['closed'] = True
|
|
|
|
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
yield f'http://127.0.0.1:{server.server_port}/v1', records
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
def chain(request, provider_wire):
|
|
request.getfixturevalue('runner_langbot_process')
|
|
port = request.getfixturevalue('runner_e2e_port')
|
|
client = httpx.Client(base_url=f'http://127.0.0.1:{port}', timeout=90, trust_env=False)
|
|
request.addfinalizer(client.close)
|
|
tmpdir = request.getfixturevalue('runner_e2e_tmpdir')
|
|
token = runtime._init_and_auth(client)
|
|
runtime._ensure_qa_plugin(client, token, tmpdir / 'agent-runner-qa.zip')
|
|
client.headers['Authorization'] = f'Bearer {token}'
|
|
|
|
def post(path, body):
|
|
response = client.post('/api/v1/' + path, json=body)
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload['code'] == 0, payload
|
|
return payload['data']
|
|
|
|
provider = post(
|
|
'provider/providers',
|
|
{
|
|
'name': 'LLM chain E2E',
|
|
'requester': 'openai-chat-completions',
|
|
'base_url': provider_wire[0],
|
|
'api_keys': ['e2e-not-a-secret'],
|
|
},
|
|
)['uuid']
|
|
model = post(
|
|
'provider/models/llm',
|
|
{
|
|
'name': 'gpt-4o',
|
|
'provider_uuid': provider,
|
|
'abilities': ['func_call', 'vision'],
|
|
'extra_args': {
|
|
'temperature': 0.1,
|
|
'request_policy': {'retries': 1, 'backoff_base': 0.01, 'backoff_max': 0.01},
|
|
},
|
|
},
|
|
)['uuid']
|
|
agent = post(
|
|
'agents',
|
|
{
|
|
'kind': 'agent',
|
|
'name': 'LLM chain E2E',
|
|
'config': {
|
|
'runner': {'id': RUNNER},
|
|
'runner_config': {RUNNER: {'model': model}},
|
|
},
|
|
},
|
|
)['uuid']
|
|
|
|
def invoke(mode, case='normal', extra=None, *, configuration=None, options=None):
|
|
selected_model, selected_agent = model, agent
|
|
if configuration:
|
|
configured_provider = post(
|
|
'provider/providers',
|
|
{
|
|
'name': case,
|
|
'requester': configuration.get('requester', 'openai-chat-completions'),
|
|
'base_url': provider_wire[0],
|
|
'api_keys': ['e2e-not-a-secret'],
|
|
},
|
|
)['uuid']
|
|
selected_model = post(
|
|
'provider/models/llm',
|
|
{
|
|
'name': configuration.get('name', 'gpt-4o'),
|
|
'provider_uuid': configured_provider,
|
|
'abilities': ['func_call', 'vision'],
|
|
'extra_args': configuration.get('extra_args', {}),
|
|
},
|
|
)['uuid']
|
|
selected_agent = post(
|
|
'agents',
|
|
{
|
|
'kind': 'agent',
|
|
'name': case,
|
|
'config': {
|
|
'runner': {'id': RUNNER},
|
|
'runner_config': {RUNNER: {'model': selected_model}},
|
|
},
|
|
},
|
|
)['uuid']
|
|
result = post(
|
|
f'agents/{selected_agent}/debug',
|
|
{
|
|
'text': json.dumps(
|
|
{'mode': mode, 'model': selected_model, 'case': case, 'extra': extra or {}, **(options or {})}
|
|
),
|
|
'conversation_id': 'e2e-' + case,
|
|
'event_type': 'message.received',
|
|
},
|
|
)
|
|
assert result.get('final_text'), result
|
|
return json.loads(result['final_text'])
|
|
|
|
return invoke, provider_wire[1], tmpdir
|
|
|
|
|
|
def test_multimodal_and_tool_definitions_survive_plugin_transport(chain):
|
|
invoke, records, _ = chain
|
|
image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='
|
|
messages = [
|
|
{
|
|
'role': 'user',
|
|
'content': [
|
|
{'type': 'text', 'text': 'Describe this image'},
|
|
{'type': 'image_url', 'image_url': {'url': image}},
|
|
],
|
|
}
|
|
]
|
|
result = invoke('runner-events', 'multimodal', options={'messages': messages, 'tool': True})
|
|
assert result['text'] == 'hello world', result
|
|
wire = records[-1]['body']
|
|
assert wire['messages'][0]['content'][1]['image_url']['url'] == image
|
|
assert wire['tools'][0]['function']['name'] == 'e2e_lookup'
|
|
|
|
|
|
def test_openrouter_budget_cache_and_explicit_attribution(chain):
|
|
invoke, records, _ = chain
|
|
result = invoke(
|
|
'runner-events',
|
|
'openrouter-budget-cache',
|
|
configuration={
|
|
'requester': 'openrouter-chat-completions',
|
|
'name': 'anthropic/claude-sonnet-4',
|
|
'extra_args': {
|
|
'reasoning_config': {'budget_tokens': 2048},
|
|
'prompt_caching': {'mode': 'anthropic'},
|
|
'max_tokens': 4096,
|
|
},
|
|
},
|
|
extra={
|
|
'extra_headers': {'HTTP-Referer': 'https://langbot.app', 'X-OpenRouter-Title': 'LangBot'},
|
|
'request_policy': {'conversation_header': 'x-langbot-conversation-id'},
|
|
},
|
|
options={'messages': [{'role': 'system', 'content': 'Stable prefix'}, {'role': 'user', 'content': 'Hello'}]},
|
|
)
|
|
assert result['text'] == 'hello world', result
|
|
wire = records[-1]
|
|
assert wire['body']['reasoning']['max_tokens'] == 2048
|
|
assert wire['body']['messages'][0]['content'][0]['cache_control']['type'] == 'ephemeral'
|
|
headers = {k.lower(): v for k, v in wire['headers'].items()}
|
|
assert headers['http-referer'] == 'https://langbot.app'
|
|
assert headers['x-openrouter-title'] == 'LangBot'
|
|
assert headers['x-langbot-conversation-id'].startswith('lb-')
|
|
assert 'prompt_caching' not in wire['body'] and 'reasoning_config' not in wire['body']
|
|
|
|
|
|
def test_openai_cache_options_and_call_override(chain):
|
|
invoke, records, _ = chain
|
|
result = invoke(
|
|
'runner-usage',
|
|
'openai-cache',
|
|
configuration={
|
|
'extra_args': {'temperature': 0.1, 'prompt_caching': {'mode': 'openai', 'retention': 'in_memory'}},
|
|
},
|
|
extra={'temperature': 0.7, 'prompt_cache_key': 'explicit-test-cache'},
|
|
)
|
|
assert result['text'] == 'hello world', result
|
|
assert records[-1]['body']['temperature'] == 0.7
|
|
assert records[-1]['body']['prompt_cache_key'] == 'explicit-test-cache'
|
|
assert records[-1]['body']['prompt_cache_retention'] == 'in_memory'
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'mode',
|
|
[
|
|
'runner-message',
|
|
'runner-usage',
|
|
'runner-stream',
|
|
'runner-events',
|
|
'plugin-message',
|
|
'plugin-usage',
|
|
'plugin-stream',
|
|
'plugin-events',
|
|
],
|
|
)
|
|
def test_public_api_shapes_and_usage(chain, mode):
|
|
invoke, records, _ = chain
|
|
result = invoke(mode, mode)
|
|
assert result['text'] == 'hello world', result
|
|
assert not result.get('error'), result
|
|
if mode.endswith(('usage', 'events')):
|
|
assert result['usage']['prompt_tokens_details']['cached_tokens'] == 16
|
|
assert result['usage']['completion_tokens_details']['reasoning_tokens'] == 2
|
|
wire = records[-1]['body']
|
|
assert wire['temperature'] == 0.1
|
|
assert 'request_policy' not in wire
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'case,extra,attempts,error',
|
|
[
|
|
('retry-once', {}, 2, False),
|
|
('retry-disabled', {'num_retries': 0}, 1, True),
|
|
('retry-max-disabled', {'max_retries': 0}, 1, True),
|
|
('auth', {}, 1, True),
|
|
('first-timeout', {'request_policy': {'first_event_timeout': 0.2, 'retries': 0}}, 1, True),
|
|
('total-timeout', {'request_policy': {'total_timeout': 0.2, 'retries': 0}}, 1, True),
|
|
('idle-timeout', {'request_policy': {'stream_idle_timeout': 0.2}}, 1, True),
|
|
('disconnect', {}, 1, True),
|
|
],
|
|
)
|
|
def test_failures_retry_boundaries_and_recovery(chain, case, extra, attempts, error):
|
|
invoke, records, _ = chain
|
|
result = invoke('runner-events', case, extra)
|
|
assert bool(result.get('error')) == error, result
|
|
assert sum(row['case'] == case for row in records) == attempts
|
|
assert invoke('runner-events', 'recover-' + case)['text'] == 'hello world'
|
|
|
|
|
|
@pytest.mark.parametrize('surface', ['runner-events', 'runner-stream', 'plugin-events', 'plugin-stream'])
|
|
def test_cancellation_releases_upstream_and_next_request_works(chain, surface):
|
|
invoke, records, tmpdir = chain
|
|
case = 'cancel-' + surface
|
|
result = invoke(case, case)
|
|
assert result['text'] == 'hello ', result
|
|
time.sleep(1.5)
|
|
cancelled = [row for row in records if row['case'] == case]
|
|
with sqlite3.connect(tmpdir / 'data/langbot.db') as db:
|
|
diagnostics = db.execute('SELECT session_id, details FROM monitoring_llm_calls').fetchall()
|
|
assert len(cancelled) == 1
|
|
assert invoke('runner-events', 'after-' + case)['text'] == 'hello world'
|
|
if (
|
|
not cancelled[0]['closed']
|
|
and Version(importlib.metadata.version('langbot-plugin')) <= Version('0.7.11')
|
|
and not os.environ.get('LLM_E2E_REQUIRE_STREAM_CLOSE')
|
|
):
|
|
pytest.xfail('Published SDK <=0.7.11 omits aclosing in public proxy wrappers; requires companion SDK fix')
|
|
assert cancelled[0]['closed'], diagnostics[-2:]
|
|
|
|
|
|
@pytest.mark.parametrize('remove_think', [True, False])
|
|
def test_reasoning_visibility_through_runner(chain, remove_think):
|
|
result = chain[0]('runner-events', f'reasoning-{remove_think}', options={'remove_think': remove_think})
|
|
assert result['text'].endswith('hello world'), result
|
|
assert ('private thought' in result['text']) == (not remove_think)
|
|
|
|
|
|
def test_invalid_budget_fails_before_network_and_recovers(chain):
|
|
invoke, records, _ = chain
|
|
result = invoke('runner-events', 'invalid-budget', {'reasoning_config': {'budget_tokens': 1}})
|
|
assert result.get('error'), result
|
|
assert not any(row['case'] == 'invalid-budget' for row in records)
|
|
assert invoke('runner-events', 'after-invalid-budget')['text'] == 'hello world'
|
|
|
|
|
|
def test_monitoring_persists_complete_usage_and_structured_errors(chain):
|
|
invoke, _, tmpdir = chain
|
|
invoke('runner-events', 'monitoring')
|
|
with sqlite3.connect(tmpdir / 'data/langbot.db') as db:
|
|
rows = db.execute('SELECT details FROM monitoring_llm_calls').fetchall()
|
|
details = [json.loads(row[0]) for row in rows if row[0]]
|
|
assert any(x.get('usage', {}).get('prompt_tokens_details', {}).get('cached_tokens') == 16 for x in details)
|
|
assert any(x.get('timing', {}).get('transport_setup_ms') is not None for x in details)
|
|
assert any(x.get('error', {}).get('code') for x in details if x.get('error'))
|
|
|
|
|
|
@pytest.mark.skipif(not os.environ.get('LLM_E2E_LEGACY_API_DIR'), reason='Historical SDK sources not supplied')
|
|
@pytest.mark.parametrize('mode', ['legacy-message', 'legacy-usage', 'legacy-stream', 'legacy-events'])
|
|
def test_historical_sdk_proxy_and_message_entities(chain, mode):
|
|
result = chain[0](mode, mode)
|
|
assert result['text'] == 'hello world', result
|
|
assert not result.get('error'), result
|
|
if mode.endswith(('usage', 'events')):
|
|
assert result['usage']['prompt_tokens_details']['cached_tokens'] == 16
|