File size: 38,281 Bytes
2aabbd0 1d719cf 2aabbd0 1d719cf 2aabbd0 1d719cf 2aabbd0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 | """超时失败时把上游 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 == []
|