mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-06 17:47:14 +00:00
0f216a0d4d
* feat(provider): support Codex subscriptions with ChatGPT sign-in * style: format Codex live integration test * fix(provider): preserve Codex identity in temporary model tests * fix(web): portal provider selector without dialog overflow * fix(web): allow native scrolling in provider dropdown * fix(provider): surface safe Codex quota and upstream errors * fix(web): provide reliable Codex copy feedback in dialogs * feat(provider): confirm cascade deletion from edit dialog * fix(persistence): discard connections after failed commit * fix(web): polish provider loading and confirmation motion --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
201 lines
7.8 KiB
Python
201 lines
7.8 KiB
Python
"""Replay synthetic HTTP/SSE traffic through the real Codex requester."""
|
|
|
|
import json
|
|
from collections import OrderedDict
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import httpx
|
|
import pytest
|
|
import langbot_plugin.api.entities.builtin.provider.message as pm
|
|
|
|
from langbot.pkg.provider.modelmgr.requesters.codex import CodexRequester, sse_events
|
|
|
|
|
|
TOKENS = {'access_token': 'access-secret', 'account_id': 'account', 'connection_id': 'connection'}
|
|
MODEL = SimpleNamespace(model_entity=SimpleNamespace(name='codex-test', extra_args={}, reasoning_config=None))
|
|
|
|
|
|
def requester(monkeypatch, handler):
|
|
real_client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
httpx, 'AsyncClient', lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs)
|
|
)
|
|
obj = object.__new__(CodexRequester)
|
|
obj.workspace, obj.provider = 'w', 'p'
|
|
obj._replay = OrderedDict()
|
|
obj.auth = SimpleNamespace(access=AsyncMock(return_value=TOKENS))
|
|
return obj
|
|
|
|
|
|
def stream(events):
|
|
return httpx.Response(200, content=''.join('data: ' + json.dumps(event) + '\r\n\r\n' for event in events))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_tools_usage_and_scoped_opaque_replay(monkeypatch):
|
|
call = {'type': 'function_call', 'call_id': 'call_1', 'name': 'lookup', 'arguments': '{"q":"test"}'}
|
|
output = [
|
|
{'type': 'reasoning', 'encrypted_content': 'opaque-secret'},
|
|
call,
|
|
{'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'Hello'}]},
|
|
]
|
|
requests = []
|
|
|
|
def handler(request):
|
|
requests.append(request)
|
|
return stream(
|
|
[
|
|
{'type': 'response.created', 'response': {'id': 'resp_1'}},
|
|
{'type': 'response.output_text.delta', 'delta': 'Hel'},
|
|
{'type': 'response.output_text.delta', 'delta': 'lo'},
|
|
{'type': 'response.function_call_arguments.delta', 'delta': '{broken'},
|
|
{'type': 'response.output_item.done', 'item': call, 'output_index': 1},
|
|
{
|
|
'type': 'response.completed',
|
|
'response': {
|
|
'id': 'resp_1',
|
|
'status': 'completed',
|
|
'output': output,
|
|
'usage': {'input_tokens': 4, 'output_tokens': 3, 'input_tokens_details': {'cached_tokens': 2}},
|
|
},
|
|
},
|
|
]
|
|
)
|
|
|
|
obj = requester(monkeypatch, handler)
|
|
query = SimpleNamespace(query_id='q', variables=None)
|
|
messages = [pm.Message(role='system', content='Be brief'), pm.Message(role='user', content='Hi')]
|
|
message, usage = await obj.invoke_llm(query, MODEL, messages)
|
|
assert message.content == 'Hello'
|
|
assert len(message.tool_calls) == 1
|
|
assert message.tool_calls[0].function.arguments == '{"q":"test"}'
|
|
assert usage['total_tokens'] == 7
|
|
assert query.variables['_stream_usage'] == usage
|
|
assert 'opaque-secret' not in message.model_dump_json()
|
|
body = json.loads(requests[0].content)
|
|
assert body['store'] is False and body['stream'] is True
|
|
assert body['instructions'] == 'Be brief'
|
|
assert requests[0].url.path.endswith('/codex/responses')
|
|
assert requests[0].headers['authorization'] == 'Bearer access-secret'
|
|
assert requests[0].headers['originator'] == 'langbot'
|
|
same = obj._body(query, MODEL, [message], None, None, TOKENS)
|
|
assert same['input'] == output
|
|
other = obj._body(SimpleNamespace(query_id='q'), MODEL, [message], None, None, TOKENS)
|
|
assert 'opaque-secret' not in json.dumps(other)
|
|
rotated = obj._body(query, MODEL, [message], None, None, {**TOKENS, 'connection_id': 'new'})
|
|
assert 'opaque-secret' not in json.dumps(rotated)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
'events',
|
|
[
|
|
[{'type': 'response.failed', 'error': 'access-secret'}],
|
|
[{'type': 'response.incomplete'}],
|
|
[{'type': 'error'}],
|
|
[{'type': 'response.output_text.delta', 'delta': 'partial'}],
|
|
[],
|
|
],
|
|
)
|
|
async def test_failure_and_truncated_stream_never_succeed(monkeypatch, events):
|
|
obj = requester(monkeypatch, lambda request: stream(events))
|
|
with pytest.raises(ValueError) as caught:
|
|
await obj.invoke_llm(None, MODEL, [])
|
|
assert 'access-secret' not in str(caught.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_only_text_stream_and_usage(monkeypatch):
|
|
obj = requester(
|
|
monkeypatch,
|
|
lambda request: stream(
|
|
[
|
|
{
|
|
'type': 'response.done',
|
|
'response': {
|
|
'output': [{'type': 'message', 'content': [{'type': 'output_text', 'text': 'done'}]}],
|
|
'usage': {'input_tokens': 2, 'output_tokens': 1},
|
|
},
|
|
}
|
|
]
|
|
),
|
|
)
|
|
query = SimpleNamespace(query_id='q', variables={})
|
|
chunks = [chunk async for chunk in obj.invoke_llm_stream(query, MODEL, [])]
|
|
assert ''.join(chunk.content or '' for chunk in chunks) == 'done'
|
|
assert chunks[-1].is_final
|
|
assert query.variables['_stream_usage']['total_tokens'] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('status', [401, 403, 429, 500])
|
|
async def test_http_error_secrecy_and_bounded_401_retry(monkeypatch, status):
|
|
requests = []
|
|
|
|
def handler(request):
|
|
requests.append(request)
|
|
return httpx.Response(status, text='access-secret refresh-secret')
|
|
|
|
obj = requester(monkeypatch, handler)
|
|
with pytest.raises(ValueError) as caught:
|
|
await obj.invoke_llm(None, MODEL, [])
|
|
assert 'secret' not in str(caught.value)
|
|
assert len(requests) == (2 if status == 401 else 1)
|
|
if status == 401:
|
|
assert obj.auth.access.call_args.kwargs == {'rejected_token': 'access-secret'}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_mapping_filtering_and_deduplication(monkeypatch):
|
|
requests = []
|
|
|
|
def handler(request):
|
|
requests.append(request)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
'models': [
|
|
{'slug': 'a', 'input_modalities': ['text', 'image'], 'supported_reasoning_levels': ['low']},
|
|
{'slug': 'hidden', 'visibility': 'hide'},
|
|
{'slug': 'a', 'input_modalities': ['text', 'image'], 'supported_reasoning_levels': ['low']},
|
|
]
|
|
},
|
|
)
|
|
|
|
obj = requester(monkeypatch, handler)
|
|
catalog = await obj.scan_models()
|
|
assert len(catalog['models']) == 1
|
|
assert catalog['models'][0]['abilities'] == ['func_call', 'vision', 'reasoning']
|
|
assert catalog['debug'] is None
|
|
assert requests[0].url.path.endswith('/codex/models')
|
|
assert 'client_version' in requests[0].url.params
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('payload', [{'models': ['secret']}, [], {'models': None}])
|
|
async def test_catalog_malformed_safe(monkeypatch, payload):
|
|
obj = requester(monkeypatch, lambda request: httpx.Response(200, json=payload))
|
|
with pytest.raises(ValueError, match='invalid model catalog'):
|
|
await obj.scan_models()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sse_multiline_crlf_comments_and_chunk_boundaries():
|
|
class Bytes(httpx.AsyncByteStream):
|
|
async def __aiter__(self):
|
|
for value in b': comment\r\nevent: test\r\ndata: {"type":\r\ndata: "test"}\r\n\r\ndata: [DONE]\r\n\r\n':
|
|
yield bytes([value])
|
|
|
|
response = httpx.Response(200, stream=Bytes())
|
|
assert [event async for event in sse_events(response)] == [{'type': 'test'}]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'key', ['base_url', 'headers', 'api_key', 'store', 'stream', 'previous_response_id', 'temperature']
|
|
)
|
|
def test_advanced_parameters_cannot_override_transport(monkeypatch, key):
|
|
obj = requester(monkeypatch, lambda request: httpx.Response(200))
|
|
with pytest.raises(ValueError, match='Unsupported Codex advanced'):
|
|
obj._body(None, MODEL, [], None, {key: 'secret'}, TOKENS)
|