8e19f24984
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m5s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m2s
AI Code Review / AI Code Review (pull_request) Successful in 5m36s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- 删除即梦 cvtob 接入代码(jimeng_client.py / JIMENG_* 配置 / _call_jimeng_video_generation) - 新增 DoubaoClient.image_generation:调用 Seedream 5.0 Pro (doubao-seedream-5-0-pro-260628) 文生图/图生图 - 信任链:有参考图时先经 Seedream 图生图 AI 化(保持五官特征),AI 产物作为 reference_image 传 Seedance - 纯文字直传 Seedance 2.5;信任链强制走 reference_image 模式(非 first_frame),保留用户指定 ratio - Seedream 失败自动回退原图直传;图片/视频错误分类统一,支持 last_image_error - 删除 #2166 t2v 自动降级、#2169 jimeng 兜底逻辑 - config/base.py 新增 doubao_image_model/doubao_image_timeout,豆包模型升级到 seed-2-1 系列 - .env.example 清理 JIMENG_*,补全 VIDEO_*/IMAGE_* 配置 - points_rules.py 删除 jimeng-3.0 定价与模型配置 - 新增 10 个单测覆盖 Seedream 主路径 + 信任链 3 种场景;更新现有 mock
333 lines
14 KiB
Python
333 lines
14 KiB
Python
"""#2170 Seedream 图片生成 + 方舟信任链单测。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
|
|
from packages.shared.ai_client import DoubaoClient
|
|
|
|
|
|
def _make_client(**overrides):
|
|
client = DoubaoClient.__new__(DoubaoClient)
|
|
client.api_key = overrides.get("api_key", "test-key")
|
|
client.base_url = overrides.get("base_url", "https://ark.cn-beijing.volces.com/api/v3")
|
|
client.model = "doubao-model"
|
|
client.vision_model = "doubao-vision"
|
|
client.embedding_model = "doubao-embedding"
|
|
client.image_model = overrides.get("image_model", "doubao-seedream-5-0-pro-260628")
|
|
client.image_timeout = overrides.get("image_timeout", 120)
|
|
client.timeout = overrides.get("timeout", 30)
|
|
client.max_retries = overrides.get("max_retries", 0)
|
|
client.last_video_error = {}
|
|
client.last_image_error = {}
|
|
return client
|
|
|
|
|
|
# ── Seedream 图片生成单测 ──────────────────────────────────────────
|
|
|
|
|
|
class TestImageGenerationHappyPath:
|
|
def test_returns_none_when_no_api_key(self):
|
|
client = _make_client(api_key="")
|
|
assert client.image_generation("p") is None
|
|
err = client.get_last_image_error()
|
|
assert err["error_code"] == "auth_error"
|
|
|
|
def test_returns_none_on_empty_prompt(self):
|
|
client = _make_client()
|
|
assert client.image_generation(" ") is None
|
|
err = client.get_last_image_error()
|
|
assert err["error_code"] == "invalid_param"
|
|
|
|
def test_text_to_image_success(self):
|
|
client = _make_client()
|
|
captured = {}
|
|
ok_resp = MagicMock()
|
|
ok_resp.status_code = 200
|
|
ok_resp.json.return_value = {"data": [{"url": "https://cdn.example.com/i.png"}], "usage": {"tokens": 1}}
|
|
ok_resp.raise_for_status = MagicMock()
|
|
ok_resp.text = ""
|
|
|
|
def fake_post(url, **kwargs):
|
|
captured["url"] = url
|
|
captured["json"] = kwargs.get("json")
|
|
return ok_resp
|
|
|
|
with patch("packages.shared.ai_client.httpx.post", side_effect=fake_post):
|
|
result = client.image_generation("一只可爱的猫", size="1K")
|
|
assert result is not None
|
|
assert result["url"] == "https://cdn.example.com/i.png"
|
|
assert "/images/generations" in captured["url"]
|
|
assert captured["json"]["model"] == "doubao-seedream-5-0-pro-260628"
|
|
assert captured["json"]["size"] == "1K"
|
|
assert "image" not in captured["json"]
|
|
|
|
def test_image_to_image_single_ref_passed_as_string(self):
|
|
client = _make_client()
|
|
captured = {}
|
|
ok_resp = MagicMock(status_code=200)
|
|
ok_resp.json.return_value = {"data": [{"url": "https://cdn.example.com/out.png"}]}
|
|
ok_resp.raise_for_status = MagicMock()
|
|
ok_resp.text = ""
|
|
|
|
def fake_post(url, **kwargs):
|
|
captured["json"] = kwargs.get("json")
|
|
return ok_resp
|
|
|
|
with patch("packages.shared.ai_client.httpx.post", side_effect=fake_post):
|
|
client.image_generation("保持五官", reference_images=["https://img/x.jpg"])
|
|
assert captured["json"]["image"] == "https://img/x.jpg"
|
|
|
|
def test_image_to_image_multiple_refs_passed_as_list(self):
|
|
client = _make_client()
|
|
captured = {}
|
|
ok_resp = MagicMock(status_code=200)
|
|
ok_resp.json.return_value = {"data": [{"url": "https://cdn.example.com/out.png"}]}
|
|
ok_resp.raise_for_status = MagicMock()
|
|
|
|
def fake_post(url, **kwargs):
|
|
captured["json"] = kwargs.get("json")
|
|
return ok_resp
|
|
|
|
refs = [f"https://img/{i}.jpg" for i in range(3)]
|
|
with patch("packages.shared.ai_client.httpx.post", side_effect=fake_post):
|
|
client.image_generation("保持", reference_images=refs)
|
|
assert captured["json"]["image"] == refs
|
|
|
|
def test_400_sensitive_returns_portrait_intercept(self):
|
|
client = _make_client(max_retries=0)
|
|
bad_resp = MagicMock(status_code=400)
|
|
bad_resp.text = '{"error":{"code":"ContentRisk","message":"sensitive content detected"}}'
|
|
bad_resp.json.return_value = {"error": {"code": "ContentRisk"}}
|
|
bad_resp.raise_for_status.side_effect = httpx.HTTPStatusError("bad", request=MagicMock(), response=bad_resp)
|
|
with patch("packages.shared.ai_client.httpx.post", return_value=bad_resp):
|
|
assert client.image_generation("p", reference_images=["https://img/x.jpg"]) is None
|
|
err = client.get_last_image_error()
|
|
assert err["error_code"] == "portrait_intercept"
|
|
|
|
def test_500_retries_then_fails(self):
|
|
client = _make_client(max_retries=1)
|
|
bad_resp = MagicMock(status_code=500)
|
|
bad_resp.text = "internal error"
|
|
bad_resp.json.return_value = {"error": {"message": "internal"}}
|
|
bad_resp.raise_for_status.side_effect = httpx.HTTPStatusError("500", request=MagicMock(), response=bad_resp)
|
|
with (
|
|
patch("packages.shared.ai_client.httpx.post", return_value=bad_resp) as mp,
|
|
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
|
):
|
|
assert client.image_generation("p") is None
|
|
assert mp.call_count == 2
|
|
err = client.get_last_image_error()
|
|
assert err["error_code"] == "network_error"
|
|
|
|
|
|
# ── 信任链集成单测 ────────────────────────────────────────────────
|
|
|
|
|
|
class TestTrustChainIntegration:
|
|
def test_with_reference_image_triggers_seedream_then_seedance_with_reference_image_role(self, tmp_path):
|
|
client = _make_client()
|
|
captured_calls = []
|
|
|
|
seedream_ok = MagicMock(status_code=200)
|
|
seedream_ok.json.return_value = {"data": [{"url": "https://ai.example.com/trusted.png"}]}
|
|
seedream_ok.raise_for_status = MagicMock()
|
|
seedream_ok.text = ""
|
|
|
|
task_ok = MagicMock(status_code=200)
|
|
task_ok.json.return_value = {"id": "t-trust"}
|
|
task_ok.raise_for_status = MagicMock()
|
|
task_ok.text = ""
|
|
|
|
poll_ok = MagicMock(status_code=200)
|
|
poll_ok.json.return_value = {"status": "succeeded", "content": {"video_url": "https://cdn.example.com/v.mp4"}}
|
|
poll_ok.raise_for_status = MagicMock()
|
|
|
|
class FakeStream:
|
|
def __init__(self):
|
|
self._c = [b"OK"]
|
|
self._it = iter(self._c)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def iter_bytes(self, chunk_size=None):
|
|
return self._it
|
|
|
|
def fake_post(url, **kwargs):
|
|
captured_calls.append({"url": url, "json": kwargs.get("json")})
|
|
if "/images/generations" in url:
|
|
return seedream_ok
|
|
return task_ok
|
|
|
|
fake_uuid = MagicMock()
|
|
fake_uuid.hex = "00000001"
|
|
with (
|
|
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
|
patch("packages.shared.ai_client.httpx.get", return_value=poll_ok),
|
|
patch("packages.shared.ai_client.httpx.stream", return_value=FakeStream()),
|
|
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
|
patch("packages.shared.ai_client.time.time", side_effect=iter([1000.0, 1001.0, 1002.0, 9e9])),
|
|
patch("packages.shared.ai_client.uuid.uuid4", return_value=fake_uuid),
|
|
patch("packages.shared.ai_client.get_shared_settings") as ms,
|
|
):
|
|
ms.return_value = MagicMock(
|
|
doubao_video_poll_interval=0,
|
|
doubao_video_timeout=60,
|
|
doubao_video_model="doubao-seedance-2-5-260628",
|
|
)
|
|
out = client.video_generation(
|
|
"人物在海边散步",
|
|
image_url="https://img/raw.jpg",
|
|
duration=5,
|
|
ratio="9:16",
|
|
resolution="720p",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
assert out is not None
|
|
assert len(captured_calls) == 2
|
|
assert "/images/generations" in captured_calls[0]["url"]
|
|
assert captured_calls[0]["json"]["image"] == "https://img/raw.jpg"
|
|
seedance_payload = captured_calls[1]["json"]
|
|
content = seedance_payload["content"]
|
|
img_items = [c for c in content if c.get("type") == "image_url"]
|
|
assert len(img_items) == 1
|
|
assert img_items[0]["image_url"]["url"] == "https://ai.example.com/trusted.png"
|
|
assert img_items[0]["role"] == "reference_image"
|
|
assert seedance_payload["ratio"] == "9:16"
|
|
|
|
def test_no_reference_image_skips_seedream(self, tmp_path):
|
|
client = _make_client()
|
|
captured_calls = []
|
|
|
|
task_ok = MagicMock(status_code=200)
|
|
task_ok.json.return_value = {"id": "t-t2v"}
|
|
task_ok.raise_for_status = MagicMock()
|
|
poll_ok = MagicMock(status_code=200)
|
|
poll_ok.json.return_value = {"status": "succeeded", "content": {"video_url": "https://cdn.example.com/v.mp4"}}
|
|
poll_ok.raise_for_status = MagicMock()
|
|
|
|
class FakeStream:
|
|
def __init__(self):
|
|
self._it = iter([b"OK"])
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def iter_bytes(self, chunk_size=None):
|
|
return self._it
|
|
|
|
def fake_post(url, **kwargs):
|
|
captured_calls.append({"url": url, "json": kwargs.get("json")})
|
|
return task_ok
|
|
|
|
fake_uuid = MagicMock()
|
|
fake_uuid.hex = "00000002"
|
|
with (
|
|
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
|
patch("packages.shared.ai_client.httpx.get", return_value=poll_ok),
|
|
patch("packages.shared.ai_client.httpx.stream", return_value=FakeStream()),
|
|
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
|
patch("packages.shared.ai_client.time.time", side_effect=iter([1000.0, 1001.0, 9e9])),
|
|
patch("packages.shared.ai_client.uuid.uuid4", return_value=fake_uuid),
|
|
patch("packages.shared.ai_client.get_shared_settings") as ms,
|
|
):
|
|
ms.return_value = MagicMock(
|
|
doubao_video_poll_interval=0,
|
|
doubao_video_timeout=60,
|
|
doubao_video_model="doubao-seedance-2-5-260628",
|
|
)
|
|
out = client.video_generation("海边日落", duration=5, ratio="9:16", output_dir=str(tmp_path))
|
|
assert out is not None
|
|
assert len(captured_calls) == 1
|
|
assert "/contents/generations/tasks" in captured_calls[0]["url"]
|
|
content = captured_calls[0]["json"]["content"]
|
|
assert all(c.get("type") != "image_url" for c in content)
|
|
|
|
def test_seedream_failure_falls_back_to_original_image(self, tmp_path):
|
|
client = _make_client(max_retries=0)
|
|
captured_calls = []
|
|
|
|
seedream_fail = MagicMock(status_code=400)
|
|
seedream_fail.text = '{"error":{"code":"QuotaExceeded","message":"quota"}}'
|
|
seedream_fail.json.return_value = {"error": {"code": "QuotaExceeded"}}
|
|
seedream_fail.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"q", request=MagicMock(), response=seedream_fail
|
|
)
|
|
|
|
task_ok = MagicMock(status_code=200)
|
|
task_ok.json.return_value = {"id": "t-fb"}
|
|
task_ok.raise_for_status = MagicMock()
|
|
poll_ok = MagicMock(status_code=200)
|
|
poll_ok.json.return_value = {"status": "succeeded", "content": {"video_url": "https://cdn.example.com/v.mp4"}}
|
|
poll_ok.raise_for_status = MagicMock()
|
|
|
|
class FakeStream:
|
|
def __init__(self):
|
|
self._it = iter([b"OK"])
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def iter_bytes(self, chunk_size=None):
|
|
return self._it
|
|
|
|
def fake_post(url, **kwargs):
|
|
captured_calls.append({"url": url, "json": kwargs.get("json")})
|
|
if "/images/generations" in url:
|
|
return seedream_fail
|
|
return task_ok
|
|
|
|
fake_uuid = MagicMock()
|
|
fake_uuid.hex = "00000003"
|
|
with (
|
|
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
|
patch("packages.shared.ai_client.httpx.get", return_value=poll_ok),
|
|
patch("packages.shared.ai_client.httpx.stream", return_value=FakeStream()),
|
|
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
|
patch("packages.shared.ai_client.time.time", side_effect=iter([1000.0, 1001.0, 9e9])),
|
|
patch("packages.shared.ai_client.uuid.uuid4", return_value=fake_uuid),
|
|
patch("packages.shared.ai_client.get_shared_settings") as ms,
|
|
):
|
|
ms.return_value = MagicMock(
|
|
doubao_video_poll_interval=0,
|
|
doubao_video_timeout=60,
|
|
doubao_video_model="doubao-seedance-2-5-260628",
|
|
)
|
|
out = client.video_generation(
|
|
"海边散步",
|
|
image_url="https://img/raw.jpg",
|
|
duration=5,
|
|
ratio="9:16",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
assert out is not None
|
|
assert len(captured_calls) == 2
|
|
seedance_payload = captured_calls[1]["json"]
|
|
content = seedance_payload["content"]
|
|
img_items = [c for c in content if c.get("type") == "image_url"]
|
|
assert len(img_items) == 1
|
|
assert img_items[0]["image_url"]["url"] == "https://img/raw.jpg"
|
|
assert img_items[0]["role"] == "first_frame"
|
|
assert seedance_payload["ratio"] == "adaptive"
|