fix(backends): make streamed replies and session state consistent

- Validate stage prefixes before forwarding model tokens. Invalid prefixes
  may use the existing repair budget; once output starts, validation failure
  aborts the turn without appending a regenerated reply. Bound the token
  queue and close inner streams when consumption stops.
- Replace delete-time lock eviction with reference-counted session locks
  covering holders and waiters. Serialize set/get with chat/stream/delete
  and release cancelled waiters without replacing a live lock.
- Store person synonyms under workflow-native driver keys and expose
  consistent alias views to single/group reads and graph prompt injection.
- Separate committed field patches from public formUpdate visibility.
  Commit callbacks keep repository fields synchronized even when updates
  are hidden or the consumer closes after graph completion.
- Disable OpenAI SDK automatic retries. Own bounded retry attempts in the
  application, use 1/2/4 second backoff, retry streams only before text is
  emitted, enforce attempt timeouts, and close upstream/client resources.
- Translate FastGPT SDK max_retries (total attempts) from the configured
  extra-retry count, and verify the real SDK loop uses four attempts.
- Document the per-LLM budget and streaming failure semantics without
  claiming the whole multi-call HTTP turn has the same timeout.

Validation: 267 offline tests passed, including actual SDK requests through
MockTransport, stream repair/abort, timeout/close, lock races, aliases and
formUpdate visibility; existing four dependency deprecation warnings.
This commit is contained in:
Eric Wang
2026-10-23 15:54:51 +08:00
parent 952bffe6f0
commit 24e2cfbe10
18 changed files with 852 additions and 316 deletions
+45 -40
View File
@@ -52,55 +52,60 @@ def make_service(form_update=None):
# ---- set/get:白名单、分组、外部表示 ----
def test_set_then_get_single_key_roundtrip():
@pytest.mark.asyncio
async def test_set_then_get_single_key_roundtrip():
service, backend = make_service()
code, msg = service.set_info("s1", "hphm1", "苏B12345")
code, msg = await service.set_info("s1", "hphm1", "苏B12345")
assert (code, msg) == ("200", "")
assert json.loads(service.get_info("s1", "hphm1")) == "苏B12345"
assert json.loads(await service.get_info("s1", "hphm1")) == "苏B12345"
# set/get 零模型调用
assert backend.llm_calls == 0
def test_get_groups_match_existing_contract():
@pytest.mark.asyncio
async def test_get_groups_match_existing_contract():
service, _ = make_service()
service.set_info("s1", "ywrysw", "0") # False
service.set_info("s1", "jdcsl", "0") # int 0
service.set_info("s1", "xm2", "张三")
await service.set_info("s1", "ywrysw", "0") # False
await service.set_info("s1", "jdcsl", "0") # int 0
await service.set_info("s1", "xm2", "张三")
acd = json.loads(service.get_info("s1", "acdinfo"))
acd = json.loads(await service.get_info("s1", "acdinfo"))
assert acd["ywrysw"] == "0" # False -> "0"
assert acd["jdcsl"] == "0" # int 0 -> "0"(内部类型不同)
assert acd["sgyy"] == "" # 未知 -> 空串
human2 = json.loads(service.get_info("s1", "acdhuman2"))
human2 = json.loads(await service.get_info("s1", "acdhuman2"))
assert human2["xm2"] == "张三"
assert human2["sfzmwh2"] == "" # 全字段齐全,未填为空
all_groups = json.loads(service.get_info("s1", "all"))
all_groups = json.loads(await service.get_info("s1", "all"))
assert set(all_groups) == {"acdinfo", "acdhuman1", "acdhuman2"}
def test_get_unknown_key_returns_json_empty_string():
@pytest.mark.asyncio
async def test_get_unknown_key_returns_json_empty_string():
"""兼容现状:未知 key 返回 JSON 空串 '""'。"""
service, _ = make_service()
service.set_info("s1", "hphm1", "苏B12345")
assert service.get_info("s1", "no_such_key") == '""'
assert service.get_info("ghost-session", "hphm1") == '""'
await service.set_info("s1", "hphm1", "苏B12345")
assert await service.get_info("s1", "no_such_key") == '""'
assert await service.get_info("ghost-session", "hphm1") == '""'
def test_set_unknown_or_internal_key_rejected():
@pytest.mark.asyncio
async def test_set_unknown_or_internal_key_rejected():
service, backend = make_service()
code, msg = service.set_info("s1", "no_such_key", "x")
code, msg = await service.set_info("s1", "no_such_key", "x")
assert code == "500" and msg == SET_INFO_INVALID_KEY_MSG
code, _ = service.set_info("s1", "stage_code", "1002")
code, _ = await service.set_info("s1", "stage_code", "1002")
assert code == "500" # 内部字段保护
assert service.get_info("s1", "hphm1") == '""' # 未被污染
assert await service.get_info("s1", "hphm1") == '""' # 未被污染
assert backend.llm_calls == 0
def test_set_rejects_unparsable_value():
@pytest.mark.asyncio
async def test_set_rejects_unparsable_value():
service, _ = make_service()
code, msg = service.set_info("s1", "ywrysw", "maybe")
code, msg = await service.set_info("s1", "ywrysw", "maybe")
assert code == "500" and "无法解析" in msg
@@ -110,8 +115,8 @@ def test_set_rejects_unparsable_value():
async def test_chat_sees_set_info_values_and_writes_back_patch():
"""set_info 写入 -> chat 输入注入 -> 图差量写回 -> get_info 读到。"""
service, backend = make_service(form_update={"ywrysw": False})
service.set_info("s1", "hphm1", "苏B12345")
service.set_info("s1", "xm2", "李四")
await service.set_info("s1", "hphm1", "苏B12345")
await service.set_info("s1", "xm2", "李四")
result = await service.chat(
_input("s1", "我是刚才报警的驾驶员")
@@ -124,8 +129,8 @@ async def test_chat_sees_set_info_values_and_writes_back_patch():
assert injected["person_fields"]["xm2"] == "李四"
# 图产出的差量写回 repository,get_info 立即可读
assert json.loads(service.get_info("s1", "acdinfo"))["ywrysw"] == "0"
assert json.loads(service.get_info("s1", "hphm1")) == "苏B12345"
assert json.loads(await service.get_info("s1", "acdinfo"))["ywrysw"] == "0"
assert json.loads(await service.get_info("s1", "hphm1")) == "苏B12345"
@pytest.mark.asyncio
@@ -135,21 +140,21 @@ async def test_chat_form_patch_mapped_to_business_keys():
"has_injury": False, "vehicle_count": 2, "accident_cause": "追尾",
})
await service.chat(_input("s1", "两辆汽车追尾,没有人受伤"))
acd = json.loads(service.get_info("s1", "acdinfo"))
acd = json.loads(await service.get_info("s1", "acdinfo"))
assert acd["ywrysw"] == "0"
assert acd["jdcsl"] == "2"
assert acd["sgyy"] == "追尾"
# 表单键本身不落 repository(未知业务键 -> JSON 空串)
assert json.loads(service.get_info("s1", "has_injury")) == ""
assert json.loads(await service.get_info("s1", "has_injury")) == ""
@pytest.mark.asyncio
async def test_set_info_acd_value_mapped_into_form_injection():
"""set_info 写业务键 -> chat 注入为图表单键(反向映射)。"""
service, backend = make_service()
service.set_info("s1", "ywrysw", "0")
service.set_info("s1", "jdcsl", "2")
service.set_info("s1", "bjrjs", "1") # 无表单来源,不注入
await service.set_info("s1", "ywrysw", "0")
await service.set_info("s1", "jdcsl", "2")
await service.set_info("s1", "bjrjs", "1") # 无表单来源,不注入
await service.chat(_input("s1", "继续办理"))
injected = backend.calls[0].injected_fields
assert injected["accident_fields"] == {"has_injury": False, "vehicle_count": 2}
@@ -166,7 +171,7 @@ async def test_chat_without_business_state_passes_no_injection():
async def test_stream_injects_fields_writes_back_form_update():
"""流式:注入同 chat;FormUpdate 事件差量捕获写回后透传。"""
service, backend = make_service(form_update={"has_injury": False})
service.set_info("s1", "xm1", "王五")
await service.set_info("s1", "xm1", "王五")
events = [
event async for event in service.stream(_input("s1", "继续办理"))
]
@@ -174,7 +179,7 @@ async def test_stream_injects_fields_writes_back_form_update():
assert kinds == ["TextDelta", "FormUpdate", "TextDelta"]
assert backend.stream_calls[0].injected_fields["person_fields"]["xm1"] == "王五"
# FormUpdate 差量已写回 repository(表单键 -> 业务键)
assert json.loads(service.get_info("s1", "acdinfo"))["ywrysw"] == "0"
assert json.loads(await service.get_info("s1", "acdinfo"))["ywrysw"] == "0"
# ---- delete:清理 + 幂等 ----
@@ -187,13 +192,13 @@ async def test_delete_clears_business_state_and_is_idempotent():
drops.append(session_id)
service, backend = make_service()
service.set_info("s1", "hphm1", "苏B12345")
await service.set_info("s1", "hphm1", "苏B12345")
await service.chat(_input("s1", "你好"))
await service.delete_session("s1", drop_checkpoint=drop_checkpoint)
assert drops == ["s1"]
# 再读为空、再聊天不注入旧数据
assert service.get_info("s1", "hphm1") == '""'
assert await service.get_info("s1", "hphm1") == '""'
await service.chat(_input("s1", "还在吗"))
assert backend.calls[-1].injected_fields is None
@@ -210,16 +215,16 @@ async def test_delete_tolerates_missing_checkpoint():
raise RuntimeError("no such thread")
service, _ = make_service()
service.set_info("s1", "xm1", "王五")
await service.set_info("s1", "xm1", "王五")
# checkpoint 不存在视为删除成功的一部分,不向上抛
await service.delete_session("s1", drop_checkpoint=missing)
assert service.get_info("s1", "xm1") == '""'
assert await service.get_info("s1", "xm1") == '""'
def _input(session_id, text):
from src.backends.chat import ChatInput
return ChatInput(session_id=session_id, text=text, client_mode="direct")
return ChatInput(session_id=session_id, text=text, client_mode="direct", need_form_update=True)
# ---- 并发隔离(A2):同会话串行、不同会话并行、delete 与 chat 互斥 ----
@@ -311,13 +316,13 @@ async def test_concurrent_delete_serialization():
@pytest.mark.asyncio
async def test_concurrent_chat_and_set_info_no_corruption():
"""set_info(同步无锁)与并发 chat 交错:repository 键值保持完整。"""
"""set_info 与 chat 按同一会话串行,最终保留写入的完整车牌。"""
backend = SerialProbeBackend()
service = AccidentAppService(backend, InMemorySessionRepository())
results = await asyncio.gather(
service.chat(_input("s1", "a")),
asyncio.to_thread(service.set_info, "s1", "hphm1", "苏B12345"),
service.set_info("s1", "hphm1", "苏B12345"),
)
assert results[1] == ("200", "")
# 无论时序如何,最终两个写者之一的车牌值完整存在(无撕裂)
assert json.loads(service.get_info("s1", "hphm1")) in ("苏B12345", "")
# set 写入在 chat 结束后执行,不能丢失。
assert json.loads(await service.get_info("s1", "hphm1")) == "苏B12345"
@@ -0,0 +1,158 @@
import asyncio
import json
from contextlib import aclosing
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
from openai import AsyncOpenAI
from src.agent.llm import LLMAPIError, LLMRateLimitError, OpenAILLMService
from src.agent.prompts import SINGLE_CONFIRM, render_prompt
from src.backends.chat import ChatInput, FormUpdate, TextDelta
from src.backends.langgraph import LangGraphBackend
from src.services.app_service import AccidentAppService
from src.services.repository import InMemorySessionRepository
class PatchGraph:
async def ainvoke(self, *args, **kwargs):
return {'response_text': '<state>1002</state>ok', 'turn_patch': {'has_injury': False}}
@pytest.mark.asyncio
async def test_person_synonyms_share_storage_and_prompt_values():
repo = InMemorySessionRepository()
service = AccidentAppService(LangGraphBackend(graph=PatchGraph()), repo)
for index in (1, 2):
for api, native in ((f'sfzmhm{index}', f'driver{index}ID'), (f'sjhm{index}', f'driver{index}Phonenumber')):
await service.set_info('s', native, 'old')
await service.set_info('s', api, 'new')
assert json.loads(await service.get_info('s', api)) == 'new'
assert json.loads(await service.get_info('s', native)) == 'new'
assert api not in repo.load('s')
fields = service._injected_fields('s')['person_fields']
prompt = render_prompt(SINGLE_CONFIRM, {'person_fields': fields})
assert 'old' not in prompt
assert 'new' in prompt
await service.set_info('s', 'driver1ID', 'latest')
group = json.loads(await service.get_info('s', 'acdhuman1'))
assert group['driver1ID'] == group['sfzmhm1'] == 'latest'
@pytest.mark.asyncio
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('visible', [False, True])
async def test_form_visibility_does_not_control_internal_commit(stream, visible):
backend = LangGraphBackend(graph=PatchGraph())
service = AccidentAppService(backend, InMemorySessionRepository())
request = ChatInput('s', 'hello', need_form_update=visible)
if stream:
events = [e async for e in service.stream(request)]
assert any(isinstance(e, FormUpdate) for e in events) is visible
else:
result = await service.chat(request)
assert bool(result.form_update) is visible
assert json.loads(await service.get_info('s', 'ywrysw')) == '0'
@pytest.mark.asyncio
async def test_commit_is_not_lost_when_consumer_closes_after_graph_completes():
backend = LangGraphBackend(graph=PatchGraph())
repo = InMemorySessionRepository()
service = AccidentAppService(backend, repo)
async with aclosing(service.stream(ChatInput('s', 'hello'))) as events:
assert isinstance(await anext(events), TextDelta)
assert json.loads(await service.get_info('s', 'ywrysw')) == '0'
assert not service._locks._entries
@pytest.mark.asyncio
async def test_factory_disables_sdk_retries():
llm = OpenAILLMService(model='m', api_key='audit')
try:
assert llm._get_client().max_retries == 0
finally:
await llm.aclose()
def completion(stream):
if not stream:
return httpx.Response(200, json={
'id': 'r', 'object': 'chat.completion', 'created': 0, 'model': 'm',
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}],
})
data = {'id': 'r', 'object': 'chat.completion.chunk', 'created': 0, 'model': 'm',
'choices': [{'index': 0, 'delta': {'content': 'ok'}, 'finish_reason': None}]}
return httpx.Response(200, headers={'content-type': 'text/event-stream'},
content='data: '+json.dumps(data)+'\n\ndata: [DONE]\n\n')
@pytest.mark.asyncio
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('status', [429, 503])
async def test_actual_http_attempt_count_is_not_multiplied_by_sdk(stream, status):
calls = []
def handler(request):
calls.append(json.loads(request.content))
if len(calls) <= 3:
return httpx.Response(status, json={'error': {'message': 'retry'}})
return completion(stream)
llm = OpenAILLMService(model='m', api_key='audit', max_retries=3,
extra_body={'reasoning_effort': 'none'})
llm._client = AsyncOpenAI(api_key='audit', base_url='http://audit/v1', max_retries=0,
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
llm._retry_delay = AsyncMock()
try:
result = ''.join([t async for t in llm.stream_tokens('system', 'user')]) if stream else await llm.complete('system', 'user')
assert result == 'ok'
assert len(calls) == 4
assert llm._retry_delay.await_count == 3
assert all(c['reasoning_effort'] == 'none' for c in calls)
finally:
await llm.aclose()
@pytest.mark.asyncio
async def test_stream_failure_after_first_token_closes_response_without_retry():
class Response:
def __init__(self): self.closed = False
def __aiter__(self): return self.generate()
async def generate(self):
yield SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content='partial'))])
raise OSError('broken socket')
async def close(self): self.closed = True
response = Response()
create = AsyncMock(return_value=response)
llm = OpenAILLMService(model='m', api_key='audit')
llm._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
tokens = []
with pytest.raises(LLMAPIError):
async for token in llm.stream_tokens('system', 'user'):
tokens.append(token)
assert tokens == ['partial']
assert create.await_count == 1
assert response.closed
@pytest.mark.asyncio
@pytest.mark.parametrize('stream', [False, True])
async def test_retry_budget_exhaustion_is_exact(stream):
calls = []
def handler(request):
calls.append(request)
return httpx.Response(429, json={'error': {'message': 'retry'}})
llm = OpenAILLMService(model='m', api_key='audit', max_retries=2)
llm._retry_delay = AsyncMock()
llm._client = AsyncOpenAI(api_key='audit', base_url='http://audit/v1', max_retries=0,
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
try:
with pytest.raises(LLMRateLimitError):
if stream:
async for _ in llm.stream_tokens('system', 'user'): pass
else:
await llm.complete('system', 'user')
assert len(calls) == 3
finally:
await llm.aclose()
@@ -0,0 +1,151 @@
import asyncio
import pytest
from src.agent.collect import CollectReplyError, validated_reply
from src.agent.llm import FakeLLMService
from src.backends.chat import ChatInput, ChatResult, TextDelta
from src.backends.langgraph import LangGraphBackend
from src.services.app_service import AccidentAppService
from src.services.repository import InMemorySessionRepository
class ValidatingGraph:
def __init__(self):
self.committed = None
async def ainvoke(self, data, *, config, context):
raw, stage = await validated_reply(context.llm, 'system', 'user', '1002')
self.committed = stage
return {'response_text': raw, 'turn_patch': {}}
class ScriptedStream:
def __init__(self, replies):
self.replies = iter(replies)
self.calls = 0
async def complete(self, *args, **kwargs):
return '{}'
async def stream_tokens(self, *args, **kwargs):
self.calls += 1
for chunk in next(self.replies):
yield chunk
@pytest.mark.asyncio
async def test_invalid_prefix_is_not_sent_and_repair_is_the_only_reply():
graph = ValidatingGraph()
llm = ScriptedStream([
['<state>9999</state>invalid'],
['<sta', 'te>1002</state>', 'repaired'],
])
backend = LangGraphBackend(graph=graph, llm=llm)
events = [e async for e in backend.stream(ChatInput('s', 'hello'))]
assert ''.join(e.text for e in events if isinstance(e, TextDelta)) == '<state>1002</state>repaired'
assert graph.committed == '1002'
assert llm.calls == 2
@pytest.mark.asyncio
async def test_failure_after_output_aborts_without_appending_a_repair():
graph = ValidatingGraph()
llm = ScriptedStream([
['<state>1002</state>', 'partial', '<state>9999</state>'],
['<state>1002</state>must not appear'],
])
backend = LangGraphBackend(graph=graph, llm=llm)
events = []
with pytest.raises(CollectReplyError):
async for event in backend.stream(ChatInput('s', 'hello')):
events.append(event)
assert llm.calls == 1
assert graph.committed is None
assert 'must not appear' not in ''.join(e.text for e in events)
@pytest.mark.asyncio
async def test_repair_exhaustion_emits_no_invalid_text():
llm = ScriptedStream([['<state>9999</state>x'], ['malformed']])
backend = LangGraphBackend(graph=ValidatingGraph(), llm=llm)
events = []
with pytest.raises(CollectReplyError):
async for event in backend.stream(ChatInput('s', 'hello')):
events.append(event)
assert events == []
class BlockingBackend:
def __init__(self):
self.entered = asyncio.Queue()
self.release = asyncio.Event()
self.active = 0
self.peak = 0
async def complete(self, request):
self.active += 1
self.peak = max(self.peak, self.active)
await self.entered.put(request.text)
try:
await self.release.wait()
return ChatResult('<state>1002</state>ok', form_update={'vehicle_count': 1})
finally:
self.active -= 1
@pytest.mark.asyncio
async def test_delete_waiter_and_new_chat_keep_one_lock():
backend = BlockingBackend()
service = AccidentAppService(backend, InMemorySessionRepository())
dropping, drop_release = asyncio.Event(), asyncio.Event()
async def drop(_):
dropping.set()
await drop_release.wait()
deletion = asyncio.create_task(service.delete_session('s', drop))
await dropping.wait()
first = asyncio.create_task(service.chat(ChatInput('s', 'a')))
await asyncio.sleep(0)
drop_release.set()
await deletion
assert await asyncio.wait_for(backend.entered.get(), 1) == 'a'
second = asyncio.create_task(service.chat(ChatInput('s', 'b')))
await asyncio.sleep(0)
assert backend.entered.empty()
backend.release.set()
await asyncio.wait_for(asyncio.gather(first, second), 2)
assert backend.peak == 1
assert not service._locks._entries
@pytest.mark.asyncio
async def test_set_waits_for_chat_and_cannot_be_overwritten_by_old_result():
backend = BlockingBackend()
service = AccidentAppService(backend, InMemorySessionRepository())
chat = asyncio.create_task(service.chat(ChatInput('s', 'a')))
await backend.entered.get()
setting = asyncio.create_task(service.set_info('s', 'jdcsl', '2'))
await asyncio.sleep(0)
assert not setting.done()
backend.release.set()
await asyncio.gather(chat, setting)
assert await service.get_info('s', 'jdcsl') == '"2"'
@pytest.mark.asyncio
async def test_cancelled_waiter_does_not_leak_or_replace_lock():
backend = BlockingBackend()
service = AccidentAppService(backend, InMemorySessionRepository())
chat = asyncio.create_task(service.chat(ChatInput('s', 'a')))
await backend.entered.get()
waiting = asyncio.create_task(service.get_info('s', 'jdcsl'))
await asyncio.sleep(0)
waiting.cancel()
with pytest.raises(asyncio.CancelledError):
await waiting
assert len(service._locks._entries) == 1
backend.release.set()
await chat
assert not service._locks._entries
@@ -0,0 +1,94 @@
import asyncio
from contextlib import aclosing
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.agent.collect import validated_reply
from src.agent.llm import LLMTimeoutError, OpenAILLMService
from src.backends.chat import ChatInput, TextDelta
from src.backends.langgraph import LangGraphBackend
from src.services.app_service import AccidentAppService
from src.services.repository import InMemorySessionRepository
class Graph:
async def ainvoke(self, data, *, config, context):
raw, _ = await validated_reply(context.llm, 'system', 'user', '1002')
return {'response_text': raw, 'turn_patch': {}}
@pytest.mark.asyncio
async def test_valid_prefix_reaches_client_before_model_finishes():
release = asyncio.Event()
finished = asyncio.Event()
class LLM:
async def stream_tokens(self, *args, **kwargs):
yield '<sta'
yield 'te>1002</state>'
await release.wait()
yield 'tail'
finished.set()
backend = LangGraphBackend(graph=Graph(), llm=LLM())
async with aclosing(backend.stream(ChatInput('s', 'hello'))) as events:
first = await asyncio.wait_for(anext(events), 1)
assert first == TextDelta('<state>1002</state>')
assert not finished.is_set()
release.set()
remaining = [e async for e in events]
assert ''.join(e.text for e in remaining) == 'tail'
assert finished.is_set()
@pytest.mark.asyncio
async def test_closing_application_stream_cancels_model_before_unlocking():
cancelled = asyncio.Event()
class LLM:
async def stream_tokens(self, *args, **kwargs):
yield '<state>1002</state>'
try:
await asyncio.Future()
finally:
cancelled.set()
service = AccidentAppService(LangGraphBackend(graph=Graph(), llm=LLM()), InMemorySessionRepository())
async with aclosing(service.stream(ChatInput('s', 'hello'))) as events:
await asyncio.wait_for(anext(events), 1)
assert cancelled.is_set()
assert not service._locks._entries
assert await service.set_info('s', 'hphm1', 'test') == ('200', '')
@pytest.mark.asyncio
@pytest.mark.parametrize('stream', [False, True])
async def test_timeout_covers_whole_attempt_and_retries_are_bounded(stream):
responses = []
class HangingResponse:
closed = False
def __aiter__(self): return self.generate()
async def generate(self):
await asyncio.Future()
yield None
async def close(self): self.closed = True
async def create(**kwargs):
if stream:
response = HangingResponse()
responses.append(response)
return response
await asyncio.Future()
llm = OpenAILLMService(model='m', api_key='audit', timeout_seconds=0.01, max_retries=2)
call = AsyncMock(side_effect=create)
llm._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=call)))
llm._retry_delay = AsyncMock()
# 先加载 SDK 错误类;外层看门狗不测模块惰性导入耗时。
assert llm._retryable(TimeoutError())
assert isinstance(llm._error(TimeoutError()), LLMTimeoutError)
async with asyncio.timeout(10):
with pytest.raises(LLMTimeoutError):
if stream:
async for _ in llm.stream_tokens('system', 'user'): pass
else:
await llm.complete('system', 'user')
assert call.await_count == 3
assert llm._retry_delay.await_count == 2
assert all(response.closed for response in responses)