"""超时失败时把上游 assistant 原话带回来(`upstream_note`)。 背景:生图超时的固定文案只会说「大概是被 GPT 拦了」,用户没法自己判断。 上游被拦时往往只回一段文字、既没调生图工具也没有图片资产,这段文字是唯一线索。 更关键的是:上游在 SSE 结束时就已经明说 `tool_invoked=false`(本轮没调用生图工具), 这种回合永远等不到图。再按 image gen 轮询只会把整段超时预算耗光(线上实测 82~97 秒), 所以这里要求「先做一次短探测取回原话、立刻收工」,轮询只作为探测失败时的兜底。 """ from __future__ import annotations import pytest from services.image_task_service import _public_task from services.openai_backend_api import ( ImagePollTimeoutError, ImageResolutionResult, OpenAIBackendAPI, ) def _backend_without_init() -> OpenAIBackendAPI: """只借方法,不触发网络/账号初始化。""" return OpenAIBackendAPI.__new__(OpenAIBackendAPI) def _assistant(text: str, *, create_time: float, hidden: bool = False) -> dict: return { "message": { "author": {"role": "assistant"}, "content": {"content_type": "text", "parts": [text]}, "create_time": create_time, "metadata": {"is_visually_hidden_from_conversation": True} if hidden else {}, } } def test_latest_assistant_text_picks_newest_visible_message() -> None: api = _backend_without_init() conversation = { "mapping": { "u1": { "message": { "author": {"role": "user"}, "content": {"content_type": "text", "parts": ["生成一张手机壁纸"]}, "create_time": 1, "metadata": {}, } }, "a1": _assistant("第一次回复:正在生成。", create_time=2), "a2": _assistant("抱歉,我无法生成这张图片。", create_time=3), } } assert api._latest_assistant_text(conversation) == "抱歉,我无法生成这张图片。" def test_latest_assistant_text_ignores_hidden_and_non_assistant() -> None: api = _backend_without_init() conversation = { "mapping": { "a1": _assistant("对用户可见的原话", create_time=2), "a2": _assistant("内部思考,不该给用户看", create_time=9, hidden=True), "t1": { "message": { "author": {"role": "tool"}, "recipient": "image_gen", "content": {"content_type": "text", "parts": ["工具输出"]}, "create_time": 10, "metadata": {}, } }, } } assert api._latest_assistant_text(conversation) == "对用户可见的原话" @pytest.mark.parametrize("payload", [None, {}, {"mapping": None}, {"mapping": {}}]) def test_latest_assistant_text_tolerates_empty_documents(payload) -> None: api = _backend_without_init() assert api._latest_assistant_text(payload) == "" def test_poll_timeout_carries_upstream_text(monkeypatch) -> None: """轮询跑完预算仍没等到图片时,异常必须把最后一句话带出来。""" from services.config import config # 默认初始等待 10s(+抖动)会直接把小预算吃光,一次会话文档都拿不到; # 这里压掉等待和轮询间隔,让循环真的发起一轮请求。 monkeypatch.setattr(type(config), "image_poll_initial_wait_secs", property(lambda self: 0.0)) monkeypatch.setattr(type(config), "image_poll_interval_secs", property(lambda self: 0.05)) api = _backend_without_init() conversation = { "mapping": { "a1": _assistant("我不能生成含有真实人物的图片。", create_time=5), } } def _fake_get_conversation(conversation_id: str) -> dict: return conversation monkeypatch.setattr(api, "_get_conversation", _fake_get_conversation, raising=False) with pytest.raises(ImagePollTimeoutError) as excinfo: api._poll_image_results("conv-upstream-text", timeout_secs=0.3) error = excinfo.value assert error.conversation_id == "conv-upstream-text" assert error.upstream_text == "我不能生成含有真实人物的图片。" def test_public_task_exposes_upstream_note() -> None: item = _public_task( { "id": "task-1", "status": "error", "mode": "edit", "model": "gpt-image-2", "conversation_id": "cid-1", "error": "ChatGPT生图超时 (已等待 91 秒)。", "upstream_note": "抱歉,我现在无法生成这个内容。", } ) assert item["upstream_note"] == "抱歉,我现在无法生成这个内容。" assert item["conversation_id"] == "cid-1" def test_public_task_omits_upstream_note_when_absent() -> None: item = _public_task({"id": "task-2", "status": "error", "mode": "generate", "model": "gpt-image-2"}) assert "upstream_note" not in item def test_public_task_exposes_can_continue_waiting_only_when_set() -> None: terminal = _public_task( {"id": "task-3", "status": "error", "mode": "generate", "model": "gpt-image-2", "conversation_id": "cid-1", "can_continue_waiting": False} ) assert terminal["can_continue_waiting"] is False # 没显式下发就不出现 —— 前台继续按旧的「有会话 id 就能继续等」规则走。 legacy = _public_task({"id": "task-4", "status": "error", "mode": "generate", "model": "gpt-image-2"}) assert "can_continue_waiting" not in legacy def test_public_task_exposes_can_view_official_only_when_denied() -> None: """只有后端明确说「官网没有可核对的东西」时才下发 false(配额耗尽),前台据此收按钮。""" denied = _public_task( {"id": "task-5", "status": "error", "mode": "edit", "model": "gpt-image-2", "conversation_id": "cid-1", "error": QUOTA_TEXT, "can_view_official": False} ) assert denied["can_view_official"] is False # 老任务 / 普通失败(超时、内容拒绝)没有这个字段 -> 前台保持原样给入口。 legacy = _public_task( {"id": "task-6", "status": "error", "mode": "edit", "model": "gpt-image-2", "conversation_id": "cid-2", "error": "生图超时 (已等待 91 秒)。"} ) assert "can_view_official" not in legacy def test_latest_assistant_text_respects_cutoff_for_memory_turns() -> None: """多轮会话里不能把上一轮的回答当成本轮的结论。""" api = _backend_without_init() conversation = { "mapping": { "a_old": _assistant("上一轮的回答:图已生成。", create_time=100), "u_new": { "message": { "author": {"role": "user"}, "content": {"content_type": "text", "parts": ["再来一张"]}, "create_time": 200, "metadata": {}, } }, } } assert api._latest_assistant_text(conversation, min_create_time=190) == "" assert api._latest_assistant_text(conversation) == "上一轮的回答:图已生成。" def test_latest_assistant_text_respects_after_message_id() -> None: api = _backend_without_init() conversation = { "mapping": { "u_prev": { "message": { "author": {"role": "user"}, "content": {"content_type": "text", "parts": ["上一轮的提问"]}, "create_time": 90, "metadata": {}, } }, "a_old": {**_assistant("上一轮的回答", create_time=100), "parent": "u_prev"}, "u_old": { "message": { "author": {"role": "user"}, "content": {"content_type": "text", "parts": ["这一次的提问"]}, "create_time": 200, "metadata": {}, } }, "a_new": {**_assistant("这一轮的原话", create_time=300), "parent": "u_old"}, } } assert api._latest_assistant_text(conversation, after_message_id="u_old") == "这一轮的原话" # 界标不在文档里(且没有时间界)时宁可不给,也别把上一轮的回答顶上来。 assert api._latest_assistant_text(conversation, after_message_id="u_missing") == "" def _terminal_doc(text: str = "请上传需要修改的图片。", *, create_time: float = 1791893410.45, recipient: str = "all", content_type: str = "text", status: str = "finished_successfully", is_complete: bool = True, children: list | None = None, hidden: bool = False) -> dict: """复刻真机取证 `6ace1f9f-…` 的会话形状:user → reasoning_recap → 文本叶子。""" return { "current_node": "leaf", "mapping": { "u1": { "parent": "root", "children": ["recap"], "message": { "author": {"role": "user"}, "recipient": "all", "content": {"content_type": "text", "parts": ["把图片上的文字改成英文"]}, "create_time": create_time - 8, "status": "finished_successfully", "metadata": {}, }, }, "recap": { "parent": "u1", "children": ["leaf"], "message": { "author": {"role": "assistant"}, "recipient": "all", "content": {"content_type": "reasoning_recap", "parts": ["内部思考"]}, "create_time": create_time - 0.01, "status": "finished_successfully", "metadata": {"model_slug": "gpt-5-6-t-mini"}, }, }, "leaf": { "parent": "recap", "children": children or [], "message": { "author": {"role": "assistant"}, "recipient": recipient, "content": {"content_type": content_type, "parts": [text]}, "create_time": create_time, "status": status, "metadata": { "is_complete": is_complete, "finish_details": {"type": "stop", "stop_tokens": [200002]}, **({"is_visually_hidden_from_conversation": True} if hidden else {}), }, }, }, }, } def test_terminal_text_answer_detects_real_shape() -> None: """真机形状(文本叶子 + is_complete + recipient=all)必须被认成终局。""" api = _backend_without_init() assert api._terminal_text_answer(_terminal_doc()) == "请上传需要修改的图片。" @pytest.mark.parametrize( "override", [ {"recipient": "image_gen"}, # 工具调用中间态 {"content_type": "reasoning_recap"}, # 内部思考,不是给用户的回答 {"status": "in_progress"}, # 还没结束 {"is_complete": False}, {"children": ["tool1"]}, # 还有子节点,回合在往下走 {"hidden": True}, # 对用户不可见 ], ) def test_terminal_text_answer_rejects_non_terminal_shapes(override) -> None: api = _backend_without_init() doc = _terminal_doc(**override) if override.get("is_complete") is False: doc["mapping"]["leaf"]["message"]["metadata"]["finish_details"] = {} assert api._terminal_text_answer(doc) == "" def test_terminal_text_answer_ignores_previous_turn_leaf() -> None: """续写场景:叶子还是上一轮的终局文本时不能当成这一轮的回答。""" api = _backend_without_init() doc = _terminal_doc(create_time=100) assert api._terminal_text_answer(doc, min_create_time=190) == "" assert api._terminal_text_answer(doc, after_message_id="leaf") == "" @pytest.mark.parametrize("payload", [None, {}, {"mapping": None}, {"mapping": {}, "current_node": "x"}]) def test_terminal_text_answer_tolerates_empty_documents(payload) -> None: api = _backend_without_init() assert api._terminal_text_answer(payload) == "" REFUSAL_TEXT = "非常抱歉,生成的图片可能违反了关于与第三方内容相似性的防护限制。如果你认为此判断有误,请重试或修改提示语。" def _refusal_doc(text: str = REFUSAL_TEXT) -> dict: """真机取证 `6ace48ab-…`(让 GPT 生成迪士尼壁纸被内容策略拦下)的会话形状: user → assistant(`recipient=<工具id>`, `content_type=code`, 生图工具调用) → assistant 文本叶子。 ⚠️ 与 `_terminal_doc` 的关键差别:文本叶子的 ``metadata`` 里**没有** `is_complete` / `finish_details`,只有 ``is_error: true``(上游用「错误/拒绝」收尾)。 """ return { "current_node": "leaf", "mapping": { "u1": { "parent": "root", "children": ["tool"], "message": { "author": {"role": "user"}, "recipient": "all", "content": {"content_type": "text", "parts": ["生成一张迪士尼手机壁纸"]}, "create_time": 1791903914.23, "status": "finished_successfully", "metadata": {"message_source": None}, }, }, "tool": { "parent": "u1", "children": ["leaf"], "message": { "author": {"role": "assistant"}, "recipient": "t2uay3k.sj1i4kz", "content": {"content_type": "code", "parts": [""]}, "create_time": 1791903917.13, "status": "finished_successfully", "metadata": {"can_save": True, "parent_id": "u1"}, }, }, "leaf": { "parent": "tool", "children": [], "message": { "author": {"role": "assistant"}, "recipient": "all", "content": {"content_type": "text", "parts": [text]}, "create_time": 1791903935.45, "status": "finished_successfully", "metadata": { "can_save": True, "is_error": True, "model_switcher_deny": False, "permissions": {}, "turn_exchange_id": "t2uay3k", "working_turn_id": "t2uay3k", }, }, }, }, } def test_terminal_text_answer_detects_refusal_error_leaf() -> None: """内容策略拒绝的叶子没有 is_complete/finish_details,只有 is_error=true,同样要认成终局。 真机代价:这条 21 秒就出现的拒绝文案被判成「普通超时」,白轮询 86.8s / 91.8s / 30s 三次。 """ api = _backend_without_init() assert api._terminal_text_answer(_refusal_doc()) == REFUSAL_TEXT # 放宽只针对上游的显式错误标记:去掉 is_error 后(既无 is_complete 也无 finish_details) # 不能再认成终局,否则任意中途文本都会被当成结论。 doc = _refusal_doc() doc["mapping"]["leaf"]["message"]["metadata"].pop("is_error") assert api._terminal_text_answer(doc) == "" def test_poll_raises_text_only_answer_for_refusal_leaf(monkeypatch) -> None: """拒绝文案要在**第一次轮询**就带回去,不跑满预算。""" from services.config import config from services.openai_backend_api import ImageTextOnlyAnswer monkeypatch.setattr(type(config), "image_poll_initial_wait_secs", property(lambda self: 0.0)) monkeypatch.setattr(type(config), "image_poll_interval_secs", property(lambda self: 5.0)) api = _backend_without_init() calls = {"count": 0} def _fake_get_conversation(conversation_id: str) -> dict: calls["count"] += 1 return _refusal_doc() monkeypatch.setattr(api, "_get_conversation", _fake_get_conversation, raising=False) with pytest.raises(ImageTextOnlyAnswer) as excinfo: api._poll_image_results("conv-refusal", timeout_secs=120.0) assert excinfo.value.text == REFUSAL_TEXT assert excinfo.value.conversation_id == "conv-refusal" assert calls["count"] == 1 def test_poll_detects_terminal_text_at_timeout_fallback(monkeypatch) -> None: """预算在两次轮询之间耗尽时,收尾补取的那一次也要认出终局文本,而不是报超时。 真机形状:拒绝文案在 21s 出现,而 30s 预算的轮询停在 20s —— 差一点点就漏判。 """ import time as _time from services.config import config from services.openai_backend_api import ImageTextOnlyAnswer monkeypatch.setattr(type(config), "image_poll_initial_wait_secs", property(lambda self: 0.0)) monkeypatch.setattr(type(config), "image_poll_interval_secs", property(lambda self: 0.05)) api = _backend_without_init() monkeypatch.setattr(api, "_get_conversation", lambda cid: _refusal_doc(), raising=False) real_terminal = api._terminal_text_answer started = _time.time() def _late_terminal(conversation, **kwargs): # 模拟「文案还没落地」:预算耗尽前的每一次轮询都判不出来,只有收尾补取那一次能判出来。 if _time.time() - started < 0.3: return "" return real_terminal(conversation, **kwargs) monkeypatch.setattr(api, "_terminal_text_answer", _late_terminal, raising=False) with pytest.raises(ImageTextOnlyAnswer) as excinfo: api._poll_image_results("conv-refusal-slow", timeout_secs=0.3) assert excinfo.value.text == REFUSAL_TEXT assert excinfo.value.conversation_id == "conv-refusal-slow" def test_pool_turns_refusal_into_terminal_error(monkeypatch) -> None: """拒绝文案经号池层要变成「终局错误」:带 conversation_id + terminal_no_image。 这样前台同时拿到两件事:卡片上有「查看官网」入口,且不会出现没用的「继续等待」按钮。 """ from services.protocol import conversation as conv class _StubBackend: def __init__(self, **kwargs) -> None: self.progress_callback = None def close(self) -> None: return None monkeypatch.setattr(conv.account_service, "get_available_access_token", lambda *args, **kwargs: "tok-refusal", raising=False) monkeypatch.setattr(conv.account_service, "get_account", lambda token: {"email": "refusal@duck.com"}, raising=False) monkeypatch.setattr(conv, "record_image_failure", lambda *args, **kwargs: None, raising=False) monkeypatch.setattr(conv, "OpenAIBackendAPI", _StubBackend, raising=False) # ⚠️ finally 里的会话隐藏/取证登记走的是**真实台账**(本机 config 开着取证): # 不拦住 register_conversation,假 conversation_id="cid-refusal" 会写进 # data/share_evidence.json,worker 之后反复尝试给这条假会话创建分享链接。 monkeypatch.setattr( "services.share_evidence_service.share_evidence_service.register_conversation", lambda **kwargs: None, ) def _fake_stream(backend, request, index, total, **kwargs): yield conv.ImageOutput( kind="message", model=request.model, index=index, total=total, text=REFUSAL_TEXT, conversation_id="cid-refusal", ) monkeypatch.setattr(conv, "stream_image_outputs", _fake_stream) with pytest.raises(conv.ImageGenerationError) as excinfo: list(conv._stream_image_outputs_with_pool(_request())) exc = excinfo.value assert exc.terminal_no_image is True assert exc.conversation_id == "cid-refusal" assert exc.code == "content_policy_violation" assert exc.can_view_official is True assert str(exc) == REFUSAL_TEXT def test_quota_failure_task_hides_official_entry(tmp_path) -> None: """端到端:号池层抛出的「配额用完」错误要落进任务记录,并下发 can_view_official=False。 只测单个函数会漏掉 `_update_task(**{...})` 那一层写入的拼写错误 —— 那会让整个功能静默失效 (前端永远拿到 undefined = 照旧显示按钮)。 """ import time from services.image_task_service import ImageTaskService from services.protocol.conversation import ImageGenerationError def handler(_payload): raise ImageGenerationError( QUOTA_TEXT, status_code=429, error_type="rate_limit_error", code="image_quota_exhausted", conversation_id="cid-quota", terminal_no_image=True, can_view_official=False, ) service = ImageTaskService( tmp_path / "image_tasks.json", generation_handler=handler, edit_handler=handler, retention_days_getter=lambda: 30, ) owner = {"id": "owner-1", "name": "Owner", "role": "admin"} service.submit_generation( owner, client_task_id="quota-task", prompt="cat", model="gpt-image-2", size=None, base_url="http://local.test", ) deadline = time.time() + 3 item = None while time.time() < deadline: item = (service.list_tasks(owner, ["quota-task"]).get("items") or [None])[0] if item and item.get("status") == "error": break time.sleep(0.02) assert item and item.get("status") == "error", item assert item["can_view_official"] is False # 会话 id 仍然落库(排查/日志要用),只是前台不给入口。 assert item["conversation_id"] == "cid-quota" def test_pool_switches_account_when_quota_exhausted(monkeypatch) -> None: """配额用完的账号要当场移出号池 + 本轮换号重试:别把上游账号额度问题变成用户的失败。 只把错误标成 no-official 是不够的:本地 quota 对免费号常常是「未知」(不计数), 不主动移出池子的话这个账号会被后续每个请求反复挑中,每个用户都吃一次同样的报错。 """ from unittest import mock from services.protocol import conversation as conv switched_tokens: list[str] = [] class _StubBackend: def __init__(self, access_token: str = "", **kwargs) -> None: self.access_token = access_token self.progress_callback = None def close(self) -> None: return None def _fake_get_token(*args, excluded_tokens=None, **kwargs): return "tok-alive" if "tok-dead" in set(excluded_tokens or set()) else "tok-dead" monkeypatch.setattr(conv.account_service, "get_available_access_token", _fake_get_token, raising=False) monkeypatch.setattr( conv.account_service, "get_account", lambda token: {"email": f"{token}@duck.com"}, raising=False, ) monkeypatch.setattr(conv, "record_image_failure", lambda *args, **kwargs: None, raising=False) monkeypatch.setattr(conv, "OpenAIBackendAPI", _StubBackend, raising=False) monkeypatch.setattr( "services.share_evidence_service.share_evidence_service.register_conversation", lambda **kwargs: None, ) mark_quota_exhausted = mock.MagicMock() monkeypatch.setattr(conv.account_service, "mark_image_quota_exhausted", mark_quota_exhausted, raising=False) def _fake_stream(backend, request, index, total, **kwargs): if backend.access_token == "tok-dead": # 上游只回文字 = 终局结论:本账号配额用完。 yield conv.ImageOutput( kind="message", model=request.model, index=index, total=total, text=QUOTA_TEXT, conversation_id="cid-dead", ) return switched_tokens.append(backend.access_token) yield conv.ImageOutput( kind="result", model=request.model, index=index, total=total, data=[{"b64_json": "x"}], conversation_id="cid-alive", ) monkeypatch.setattr(conv, "stream_image_outputs", _fake_stream) outputs = list(conv._stream_image_outputs_with_pool(_request())) # 换号成功:用户拿到图,而不是「Free 套餐额度用完」的报错。 assert switched_tokens == ["tok-alive"] assert [item.kind for item in outputs] == ["result"] # 额度用完的账号被当场移出可用池(否则它会被后续每个请求反复挑中)。 mark_quota_exhausted.assert_called_once() assert mark_quota_exhausted.call_args.args[0] == "tok-dead" def test_pool_reports_upstream_quota_text_when_switch_budget_runs_out(monkeypatch) -> None: """连着撞到额度用尽的账号、换号预算用完后,出站的是**上游原话**。 站长按这句原话配了「报错文案映射」(→ 大白话),所以出站文案不能被内部改写。 """ from unittest import mock from services.protocol import conversation as conv class _StubBackend: def __init__(self, access_token: str = "", **kwargs) -> None: self.access_token = access_token self.progress_callback = None def close(self) -> None: return None def _fake_get_token(*args, excluded_tokens=None, **kwargs): excluded = set(excluded_tokens or set()) if len(excluded) >= 4: raise RuntimeError("no available image quota") return f"tok-{len(excluded) + 1}" monkeypatch.setattr(conv.account_service, "get_available_access_token", _fake_get_token, raising=False) monkeypatch.setattr(conv.account_service, "get_account", lambda token: {"email": "dead@duck.com"}, raising=False) monkeypatch.setattr(conv, "record_image_failure", lambda *args, **kwargs: None, raising=False) monkeypatch.setattr(conv, "OpenAIBackendAPI", _StubBackend, raising=False) monkeypatch.setattr( "services.share_evidence_service.share_evidence_service.register_conversation", lambda **kwargs: None, ) monkeypatch.setattr(conv.account_service, "mark_image_quota_exhausted", mock.MagicMock(), raising=False) def _fake_stream(backend, request, index, total, **kwargs): yield conv.ImageOutput( kind="message", model=request.model, index=index, total=total, text=QUOTA_TEXT, conversation_id="cid-dead", ) monkeypatch.setattr(conv, "stream_image_outputs", _fake_stream) with pytest.raises(conv.ImageGenerationError) as excinfo: list(conv._stream_image_outputs_with_pool(_request())) exc = excinfo.value assert exc.code == "image_quota_exhausted" assert exc.can_view_official is False # 换号次数有预算(至少 2 次尝试 = 至少换 1 次),不能把整池挨个 20s 试一遍把用户拖住。 assert exc.account_email == "dead@duck.com" assert str(exc) == QUOTA_TEXT def test_pool_reports_internal_quota_error_when_pool_drains(monkeypatch) -> None: """号池被掏空时要冒出内部英文 `no available image quota`——站长的报错文案规则按它配。 ⚠️ 这串英文是「对外的内部哨兵」:api/support.py 按它回 429、log_service 按它标 insufficient_quota、站长的「报错文案映射」按它换大白话。所以池空时**不能**用 上游原话(那是另一条规则的事)把它替掉,否则规则永远匹配不上。 """ from unittest import mock from services.protocol import conversation as conv class _StubBackend: def __init__(self, access_token: str = "", **kwargs) -> None: self.access_token = access_token self.progress_callback = None def close(self) -> None: return None def _fake_get_token(*args, excluded_tokens=None, **kwargs): # 只有 1 个账号:它被移出池子后,号池就空了。 if set(excluded_tokens or set()): raise RuntimeError("no available image quota") return "tok-only" monkeypatch.setattr(conv.account_service, "get_available_access_token", _fake_get_token, raising=False) monkeypatch.setattr(conv.account_service, "get_account", lambda token: {"email": "only@duck.com"}, raising=False) monkeypatch.setattr(conv, "record_image_failure", lambda *args, **kwargs: None, raising=False) monkeypatch.setattr(conv, "OpenAIBackendAPI", _StubBackend, raising=False) monkeypatch.setattr( "services.share_evidence_service.share_evidence_service.register_conversation", lambda **kwargs: None, ) mark_quota_exhausted = mock.MagicMock() monkeypatch.setattr(conv.account_service, "mark_image_quota_exhausted", mark_quota_exhausted, raising=False) def _fake_stream(backend, request, index, total, **kwargs): yield conv.ImageOutput( kind="message", model=request.model, index=index, total=total, text=QUOTA_TEXT, conversation_id="cid-only", ) monkeypatch.setattr(conv, "stream_image_outputs", _fake_stream) with pytest.raises(conv.ImageGenerationError) as excinfo: list(conv._stream_image_outputs_with_pool(_request())) assert "no available image quota" in str(excinfo.value) mark_quota_exhausted.assert_called_once() def test_pool_marks_quota_notice_as_no_official_evidence(monkeypatch) -> None: """账号生图配额用完时不能再挂「查看官网对话」入口。 真机报错(HF 日志,账号 defy-scruffy-armed@duck.com,20s 失败): 「你已达到 Free 套餐的图像生成请求上限。上限将在 23小时 后重置,届时可创建更多图像。」 上游这一轮**什么都没生成**,点「查看官网」进去只有同一句提示 → 纯噪声。 同时它也不该被谎称成 content_policy_violation(那会让这个账号逃过配额校验)。 """ from services.protocol import conversation as conv class _StubBackend: def __init__(self, **kwargs) -> None: self.progress_callback = None def close(self) -> None: return None monkeypatch.setattr(conv.account_service, "get_available_access_token", lambda *args, **kwargs: "tok-quota", raising=False) monkeypatch.setattr(conv.account_service, "get_account", lambda token: {"email": "quota@duck.com"}, raising=False) monkeypatch.setattr(conv, "record_image_failure", lambda *args, **kwargs: None, raising=False) monkeypatch.setattr(conv, "OpenAIBackendAPI", _StubBackend, raising=False) monkeypatch.setattr( "services.share_evidence_service.share_evidence_service.register_conversation", lambda **kwargs: None, ) def _fake_stream(backend, request, index, total, **kwargs): yield conv.ImageOutput( kind="message", model=request.model, index=index, total=total, text=QUOTA_TEXT, conversation_id="cid-quota", ) monkeypatch.setattr(conv, "stream_image_outputs", _fake_stream) with pytest.raises(conv.ImageGenerationError) as excinfo: list(conv._stream_image_outputs_with_pool(_request())) exc = excinfo.value assert exc.terminal_no_image is True assert exc.can_view_official is False assert exc.code == "image_quota_exhausted" assert exc.status_code == 429 assert exc.conversation_id == "cid-quota" assert str(exc) == QUOTA_TEXT QUOTA_TEXT = "你已达到 Free 套餐的图像生成请求上限。上限将在 23小时 后重置,届时可创建更多图像。" @pytest.mark.parametrize( "message", [ QUOTA_TEXT, # 套餐名/时长变化、只剩后半句时同样要认出来。 "你已达到 Plus 套餐的图像生成请求上限。", "上限将在 1小时 后重置,届时可创建更多图像。", # 英文原话(账号 locale 为英文时)。 "You've reached the Free plan's limit for image generation requests. " "The limit will reset in 23 hours.", "image generation request limit reached", ], ) def test_is_image_quota_exhausted_message_matches_quota_notices(message: str) -> None: from services.protocol.conversation import is_image_quota_exhausted_message assert is_image_quota_exhausted_message(message) is True @pytest.mark.parametrize( "message", [ # 内容策略拒绝:官网会话里**有原话可核对**,「查看官网对话」必须保留。 REFUSAL_TEXT, # 上传限流是另一类(换号即可),不是生图配额用完。 "你已达到文件上传上限。请17小时 内重试。", # 空值 / 普通超时文案。 "", "生图超时 (已等待 91 秒)。", ], ) def test_is_image_quota_exhausted_message_ignores_other_failures(message: str) -> None: from services.protocol.conversation import is_image_quota_exhausted_message assert is_image_quota_exhausted_message(message) is False def test_poll_raises_text_only_answer_instead_of_waiting(monkeypatch) -> None: """终局纯文字要在**第一次轮询**就抛出来,不能把 120s 预算跑满。""" from services.config import config from services.openai_backend_api import ImageTextOnlyAnswer monkeypatch.setattr(type(config), "image_poll_initial_wait_secs", property(lambda self: 0.0)) monkeypatch.setattr(type(config), "image_poll_interval_secs", property(lambda self: 5.0)) api = _backend_without_init() calls = {"count": 0} def _fake_get_conversation(conversation_id: str) -> dict: calls["count"] += 1 return _terminal_doc("请上传需要修改的图片。") monkeypatch.setattr(api, "_get_conversation", _fake_get_conversation, raising=False) with pytest.raises(ImageTextOnlyAnswer) as excinfo: api._poll_image_results("conv-text-only", timeout_secs=120.0) assert excinfo.value.text == "请上传需要修改的图片。" assert excinfo.value.conversation_id == "conv-text-only" assert calls["count"] == 1 def test_poll_keeps_waiting_when_turn_not_terminal(monkeypatch) -> None: """回合还没结束(叶子有子节点)时必须继续轮询,等到真超时。""" from services.config import config monkeypatch.setattr(type(config), "image_poll_initial_wait_secs", property(lambda self: 0.0)) monkeypatch.setattr(type(config), "image_poll_interval_secs", property(lambda self: 0.05)) api = _backend_without_init() doc = _terminal_doc(children=["tool1"]) monkeypatch.setattr(api, "_get_conversation", lambda cid: doc, raising=False) with pytest.raises(ImagePollTimeoutError) as excinfo: api._poll_image_results("conv-still-running", timeout_secs=0.3) assert excinfo.value.conversation_id == "conv-still-running" def _request(**overrides): from services.protocol.conversation import ConversationRequest return ConversationRequest( prompt="生成一张手机壁纸", model="gpt-image-2", message_as_error=True, **overrides, ) def _stub_stream_events(monkeypatch, *, turn_use_case: str = "image gen") -> None: from services.protocol import conversation as conv def _fake_events(*args, **kwargs): yield { "type": "conversation.done", "text": "", "conversation_id": "cid-9", "parent_message_id": "", "file_ids": [], "sediment_ids": [], "blocked": False, "tool_invoked": False, "turn_use_case": turn_use_case, "done": True, } monkeypatch.setattr(conv, "conversation_events", _fake_events) def test_stream_returns_upstream_text_when_turn_ended_with_text(monkeypatch) -> None: """轮询识别出「终局纯文字」-> 直接把它当结果返回(前台显示上游原话)。""" from services.openai_backend_api import ImageTextOnlyAnswer from services.protocol import conversation as conv _stub_stream_events(monkeypatch) api = _backend_without_init() def _fake_resolve(*args, **kwargs): raise ImageTextOnlyAnswer("请上传需要修改的图片。", conversation_id="cid-9") monkeypatch.setattr(api, "resolve_conversation_image_result", _fake_resolve, raising=False) outputs = list(conv.stream_image_outputs(api, _request())) assert [output.kind for output in outputs] == ["message"] assert outputs[0].text == "请上传需要修改的图片。" # 会话 id 必须带出去,前台才有「查看官网」入口。 assert outputs[0].conversation_id == "cid-9" def test_stream_still_polls_and_returns_nothing_when_no_text(monkeypatch) -> None: """没有终局文字时行为不变:照常轮询,轮询返回空就什么都不产出。""" from services.protocol import conversation as conv _stub_stream_events(monkeypatch) api = _backend_without_init() resolved: list[str] = [] def _fake_resolve(conversation_id, file_ids, sediment_ids, **kwargs): resolved.append(conversation_id) return ImageResolutionResult(urls=[], message_id="") monkeypatch.setattr(api, "resolve_conversation_image_result", _fake_resolve, raising=False) outputs = list(conv.stream_image_outputs(api, _request())) assert resolved == ["cid-9"] assert outputs == []