Files
gpt-image-panel/backend/tests/test_assistant_contract.py
Shayne Wong e566f514c7 feat: add support for GPT Image 2.5 models with extended quality options and new UI components
- Introduced new image model picker component for selecting models and qualities.
- Updated image model utilities to handle new models and quality options.
- Implemented backend logic for validating image model options and handling requests.
- Added tests for image model selection, quality handling, and API interactions.
- Adjusted existing tests to accommodate new model features and ensure compatibility.
2026-09-10 19:23:50 +08:00

1212 lines
47 KiB
Python
Executable File

from backend.tests.support.contract import * # noqa: F403
def test_ai_assistant_health_reports_success_and_config_errors(client, monkeypatch):
settings = client.get("/api/settings").json()
missing_initial_config = client.post("/api/assistant/health")
assert missing_initial_config.status_code == 200
assert missing_initial_config.json()["status"] == "error"
assert "Prompt Optimizer endpoint URL is not configured" in missing_initial_config.json()["message"]
configured = client.post(
"/api/settings",
json=_settings_payload(
settings,
prompt_optimizer={
"enabled": True,
"api_url": "https://example.com/v1/chat/completions",
"model": "shared-model",
"timeout_seconds": 45,
"api_key": "${TEST_PROMPT_OPTIMIZER_API_KEY}",
},
),
)
assert configured.status_code == 200
enabled = client.post(
"/api/settings",
json=_assistant_payload(
configured.json(),
ai_assistant={
"enabled": True,
"vision_model": "assistant-vision-model",
},
),
)
assert enabled.status_code == 200
probe_calls: list[dict[str, object]] = []
async def fake_probe(*, api_url, api_key, api_path, model, timeout_seconds):
probe_calls.append(
{
"api_url": api_url,
"api_key": api_key,
"api_path": api_path,
"model": model,
"timeout_seconds": timeout_seconds,
}
)
return {
"status": "ok",
"message": f"AI Assistant responded successfully with model {model}",
"model": model,
"duration_ms": 12,
"status_code": 200,
}
monkeypatch.setattr(assistant_router.assistant_client, "probe_assistant_endpoint", fake_probe)
healthy = client.post("/api/assistant/health")
assert healthy.status_code == 200
assert healthy.json() == {
"status": "ok",
"message": "AI Assistant responded successfully with model shared-model",
"model": "shared-model",
"duration_ms": 12,
"status_code": 200,
}
assert probe_calls[-1]["api_url"] == "https://example.com"
assert probe_calls[-1]["api_key"] == "optimizer-key"
assert probe_calls[-1]["api_path"] == "/v1/chat/completions"
assert probe_calls[-1]["timeout_seconds"] == 45
draft_healthy = client.post(
"/api/assistant/health",
json={
"enabled": True,
"vision_model": "draft-assistant-vision-model",
"api_path": "/v1/responses",
},
)
assert draft_healthy.status_code == 200
assert draft_healthy.json()["status"] == "ok"
assert draft_healthy.json()["model"] == "shared-model"
assert probe_calls[-1]["api_path"] == "/v1/chat/completions"
assert "model" not in settings_repo.load_ai_assistant_settings()
missing_url_settings = client.post(
"/api/settings",
json=_settings_payload(
enabled.json(),
prompt_optimizer={
"enabled": True,
"api_url": "",
"model": "shared-model",
"timeout_seconds": 45,
"api_key": "${TEST_PROMPT_OPTIMIZER_API_KEY}",
},
),
)
assert missing_url_settings.status_code == 200
missing_url = client.post("/api/assistant/health")
assert missing_url.status_code == 200
assert missing_url.json()["status"] == "error"
assert "not configured" in missing_url.json()["message"]
def test_ai_assistant_health_timeout_returns_structured_504(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_settings_payload(
settings,
prompt_optimizer={
"enabled": True,
"api_url": "https://example.com/v1/chat/completions",
"model": "shared-model",
"timeout_seconds": 7,
"api_key": "${TEST_PROMPT_OPTIMIZER_API_KEY}",
},
),
)
assert configured.status_code == 200
enabled = client.post(
"/api/settings",
json=_assistant_payload(
configured.json(),
ai_assistant={"enabled": True},
),
)
assert enabled.status_code == 200
async def timeout_probe(**kwargs):
raise assistant_router.assistant_client.AssistantTimeoutError("AI Assistant request timed out")
monkeypatch.setattr(assistant_router.assistant_client, "probe_assistant_endpoint", timeout_probe)
resp = client.post("/api/assistant/health")
assert resp.status_code == 200
assert resp.json() == {
"status": "error",
"message": "AI Assistant request timed out",
"model": "shared-model",
"duration_ms": 7000,
"status_code": 504,
}
def test_ai_assistant_prompt_tools_and_param_recommendations(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(settings, optimizer_api_url="https://example.com/v1/responses"),
)
assert configured.status_code == 200
seen: dict[str, object] = {}
async def fake_request_assistant_json(**kwargs):
seen["kwargs"] = kwargs
schema = kwargs["schema"]
if "rewritten_prompt" in schema:
return ({"rewritten_prompt": "expanded prompt", "warnings": ["warn"]}, "assistant-model", 11)
if "score" in schema:
return (
{
"score": 83,
"summary": "Clear enough",
"issues": [
{"severity": "warning", "message": "Too vague", "suggestion": "Add lighting"}
],
"warnings": [],
},
"assistant-model",
12,
)
if "variants" in schema:
return (
{
"variants": [
{"title": "Variant 1", "prompt": "one", "angle": "alt"},
{"title": "Variant 2", "prompt": "two"},
],
"warnings": ["useful"],
},
"assistant-model",
13,
)
if "model_name" in schema:
return (
{
"model_name": "assistant-model",
"size": "1024x1024",
"quality": "high",
"output_format": "png",
"n": 8,
"rationale": "good fit",
"warnings": ["responses path"],
},
"assistant-model",
14,
)
raise AssertionError(f"Unexpected schema {schema}")
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", fake_request_assistant_json)
rewrite = client.post(
"/api/assistant/prompt/rewrite",
json={
"prompt": "tiny robot",
"instruction": "make it cinematic",
"target_language": "en",
"api_path": "/v1/images/generations",
"model": "gpt-image-2",
"size": "1024x1024",
"quality": "high",
},
)
assert rewrite.status_code == 200
assert rewrite.json()["rewritten_prompt"] == "expanded prompt"
assert rewrite.json()["warnings"] == ["warn"]
check = client.post(
"/api/assistant/prompt/check",
json={
"prompt": "tiny robot",
"api_path": "/v1/responses",
"model": "gpt-image-2",
"size": "1024x1024",
"quality": "high",
},
)
assert check.status_code == 200
assert check.json()["score"] == 83
assert check.json()["issues"][0]["severity"] == "warning"
variants = client.post(
"/api/assistant/prompt/variants",
json={
"prompt": "tiny robot",
"instruction": "make it cinematic",
"count": 3,
"target_language": "same",
"api_path": "/v1/chat/completions",
"model": "gpt-image-2",
"size": "1024x1024",
"quality": "high",
},
)
assert variants.status_code == 200
assert [item["title"] for item in variants.json()["variants"]] == ["Variant 1", "Variant 2"]
recommend_chat = client.post(
"/api/assistant/generate/recommend-params",
json={
"prompt": "tiny robot",
"api_path": "/v1/chat/completions",
"current_model": "gpt-image-2",
"current_size": "1024x1024",
"current_quality": "high",
"current_output_format": "png",
"current_n": 1,
},
)
assert recommend_chat.status_code == 200
assert recommend_chat.json()["size"] is None
assert any("does not support" in warning for warning in recommend_chat.json()["warnings"])
recommend_images = client.post(
"/api/assistant/generate/recommend-params",
json={
"prompt": "tiny robot",
"api_path": "/v1/images/generations",
"current_model": "gpt-image-2",
"current_size": "1024x1024",
"current_quality": "high",
"current_output_format": "png",
"current_n": 1,
},
)
assert recommend_images.status_code == 200
assert recommend_images.json()["size"] == "1024x1024"
assert recommend_images.json()["n"] == 8
assert recommend_images.json()["model_name"] == "assistant-model"
assert seen["kwargs"]["api_url"] == "https://example.com"
assert seen["kwargs"]["api_key"] == "optimizer-key"
assert seen["kwargs"]["api_path"] == "/v1/responses"
def test_ai_assistant_request_concurrency_limit_returns_429(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(settings),
)
assert configured.status_code == 200
config.AI_ASSISTANT_MAX_CONCURRENCY = 1
acquired_once = False
def fake_acquire_background_slot(**kwargs):
nonlocal acquired_once
assert kwargs["slot_count"] == 1
if acquired_once:
return None
acquired_once = True
return "ai_assistant_request:0"
async def fake_request_assistant_json(**kwargs):
return ({"rewritten_prompt": "limited prompt", "warnings": []}, "assistant-model", 10)
monkeypatch.setattr(assistant_runtime, "acquire_background_slot", fake_acquire_background_slot)
monkeypatch.setattr(assistant_runtime, "release_background_slot", lambda **kwargs: True)
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", fake_request_assistant_json)
ok = client.post("/api/assistant/prompt/rewrite", json={"prompt": "tiny robot", "target_language": "en"})
assert ok.status_code == 200
limited = client.post("/api/assistant/prompt/rewrite", json={"prompt": "tiny robot", "target_language": "en"})
assert limited.status_code == 429
assert "concurrency limit" in limited.json()["detail"]
def test_ai_assistant_response_fields_are_truncated_before_persistence(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(settings),
)
assert configured.status_code == 200
entry = _fake_gallery_entry("assistant-long-fields", "prompt", "1024x1024", "assistant-long-fields.png")
assert entry is not None
async def fake_request_assistant_json(**kwargs):
return (
{
"description": "d" * 5000,
"prompt": "p" * 40000,
"analysis": {
"subjects": ["s" * 500 for _ in range(20)],
"style": "x" * 2000,
"composition": "c" * 2000,
"lighting": "l" * 2000,
"colors": ["red" * 80 for _ in range(20)],
},
"warnings": ["w" * 900 for _ in range(20)],
},
"assistant-vision-model",
11,
)
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", fake_request_assistant_json)
resp = client.post("/api/assistant/gallery/assistant-long-fields/analyze")
assert resp.status_code == 200
body = resp.json()
assert len(body["description"]) == 2000
assert len(body["prompt"]) == 32000
assert len(body["analysis"]["style"]) == 500
assert len(body["analysis"]["composition"]) == 800
assert len(body["warnings"]) == 10
assert all(len(warning) == 500 for warning in body["warnings"])
metadata = settings_repo.get_gallery_ai_metadata("assistant-long-fields")
assert metadata is not None
assert len(metadata["description"]) == 2000
assert len(metadata["prompt"]) == 32000
def test_ai_assistant_job_diagnose_does_not_expose_secrets(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(settings),
)
assert configured.status_code == 200
image_jobs_repo.upsert_generate_job(
{
"job_id": "diagnose-job",
"status": "error",
"stage": "generation_failed",
"message": "Failed upstream with Authorization: Bearer env-secret and https://api.example.com/v1/path?api_key=env-secret",
"operation": "generation",
"prompt": "secret prompt",
"size": "1024x1024",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
"completed_at": "2026-01-01T00:00:00+00:00",
"api_path": "/v1/images/generations",
"api_preset_name": "Default",
"error": "upstream failed api_key=env-secret",
}
)
async def fake_request_assistant_json(**kwargs):
assert "secret prompt" not in kwargs["user_prompt"]
assert "env-secret" not in kwargs["user_prompt"]
assert "[REDACTED]" in kwargs["user_prompt"]
return (
{
"summary": "Upstream failure",
"likely_causes": ["invalid prompt"],
"recommended_actions": ["retry"],
"warnings": ["no secrets"],
},
"assistant-model",
15,
)
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", fake_request_assistant_json)
resp = client.post("/api/assistant/jobs/diagnose-job/diagnose", json={})
assert resp.status_code == 200
body = resp.json()
assert body["summary"] == "Upstream failure"
assert body["safe_job"]["job_id"] == "diagnose-job"
assert "prompt" not in body["safe_job"]
assert "env-secret" not in json.dumps(body)
def test_ai_assistant_gallery_metadata_and_analysis_flow(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(settings),
)
assert configured.status_code == 200
seeded = _fake_gallery_entry("assistant-gallery", "gallery prompt", "1024x1024", "assistant-gallery.png")
assert seeded is not None
async def fake_request_assistant_json(**kwargs):
if "preview" in kwargs["user_prompt"]:
assert kwargs["image"]["mime_type"] == "image/png"
assert kwargs["image"]["source_has_alpha"] is True
assert kwargs["image"]["bytes"] <= config.AI_ASSISTANT_IMAGE_MAX_BYTES
schema = kwargs["schema"]
if "analysis" not in schema and "description" in schema:
return (
{
"description": "A small red square",
"warnings": [],
},
"assistant-vision-model",
15,
)
if "analysis" not in schema and "prompt" in schema:
assert "stored_prompt" not in kwargs["user_prompt"]
assert "gallery prompt" not in kwargs["user_prompt"]
assert "visible pixels" in kwargs["system_prompt"]
assert "negative prompt" in kwargs["system_prompt"]
return (
{
"prompt": "red square on white background",
"warnings": [],
},
"assistant-vision-model",
16,
)
return (
{
"description": "A small red square",
"prompt": "red square on white background",
"analysis": {
"subjects": ["square"],
"style": "minimal",
"composition": "centered",
"lighting": "flat",
"colors": ["red", "white"],
},
"warnings": [],
},
"assistant-vision-model",
17,
)
raise AssertionError("unexpected assistant request")
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", fake_request_assistant_json)
describe = client.post("/api/assistant/gallery/assistant-gallery/describe")
assert describe.status_code == 200
assert describe.json()["description"] == "A small red square"
assert describe.json()["prompt"] == ""
assert describe.json()["analysis"] == {}
prompt = client.post("/api/assistant/gallery/assistant-gallery/prompt")
assert prompt.status_code == 200
assert prompt.json()["description"] == ""
assert prompt.json()["prompt"] == "red square on white background"
analyze = client.post("/api/assistant/gallery/assistant-gallery/analyze")
assert analyze.status_code == 200
assert analyze.json()["description"] == "A small red square"
metadata = client.get("/api/assistant/gallery/assistant-gallery/metadata")
assert metadata.status_code == 200
assert metadata.json()["prompt"] == "red square on white background"
assert metadata.json()["model"] == "assistant-vision-model"
def test_ai_assistant_uploaded_image_prompt_is_bounded_language_aware_and_not_persisted(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post("/api/settings", json=_assistant_runtime_payload(settings))
assert configured.status_code == 200
seen: dict[str, object] = {}
async def fake_request_assistant_json(**kwargs):
seen.update(kwargs)
return (
{
"prompt": "p" * 5000,
"warnings": ["w" * 700 for _ in range(12)],
},
"assistant-vision-model",
23,
)
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", fake_request_assistant_json)
gallery_count = gallery_queries.get_gallery_count()
response = client.post(
"/api/assistant/image/prompt",
data={"target_language": "zh-CN"},
files={"image": ("local.png", PNG_BYTES, "image/png")},
)
assert response.status_code == 200
body = response.json()
assert body["prompt"] == "p" * 5000
assert body["model"] == "assistant-vision-model"
assert body["duration_ms"] == 23
assert len(body["warnings"]) == 10
assert all(len(warning) == 500 for warning in body["warnings"])
assert gallery_queries.get_gallery_count() == gallery_count
assert seen["image"]["mime_type"] == "image/png"
assert seen["image"]["source_has_alpha"] is True
assert seen["image"]["bytes"] <= config.AI_ASSISTANT_IMAGE_MAX_BYTES
assert json.loads(seen["user_prompt"])["target_language"] == "zh-CN"
assert "Write the prompt in Simplified Chinese" in seen["system_prompt"]
assert "subject identity and action" in seen["system_prompt"]
assert "overall color palette and color relationships" in seen["system_prompt"]
assert "dominant and accent colors" in seen["system_prompt"]
assert "artistic medium and rendering style" in seen["system_prompt"]
assert "line quality" in seen["system_prompt"]
assert "named character, mascot, or public figure" in seen["system_prompt"]
assert "anime, manga, comics, games, film, or television" in seen["system_prompt"]
assert "the source work or franchise" in seen["system_prompt"]
assert "not as attribution of the input image's source" in seen["system_prompt"]
assert "If the identity is uncertain, do not guess" in seen["system_prompt"]
assert "Use high information density" in seen["system_prompt"]
assert "spatial relationships" in seen["system_prompt"]
assert "single prompt in the requested language" in seen["system_prompt"]
assert "Do not invent unseen brands" in seen["system_prompt"]
assert "artist names, exact camera or lens settings, or background stories" in seen["system_prompt"]
assert "Do not create a separate negative prompt" in seen["system_prompt"]
@pytest.mark.parametrize(
("filename", "image_bytes", "content_type"),
[
("not-image.txt", b"plain text", "text/plain"),
("damaged.png", PNG_BYTES[:32], "image/png"),
("vector.svg", b"<svg></svg>", "image/svg+xml"),
],
)
def test_ai_assistant_uploaded_image_prompt_rejects_unsafe_or_damaged_files(
client,
monkeypatch,
filename,
image_bytes,
content_type,
):
settings = client.get("/api/settings").json()
configured = client.post("/api/settings", json=_assistant_runtime_payload(settings))
assert configured.status_code == 200
async def unexpected_request(**kwargs):
raise AssertionError("invalid uploads must not reach the AI Assistant")
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", unexpected_request)
response = client.post(
"/api/assistant/image/prompt",
files={"image": (filename, image_bytes, content_type)},
)
assert response.status_code == 400
def test_ai_assistant_uploaded_image_prompt_rejects_pixel_and_request_size_limits(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post("/api/settings", json=_assistant_runtime_payload(settings))
assert configured.status_code == 200
monkeypatch.setattr(config, "MAX_IMAGE_PIXELS", 0)
pixel_limited = client.post(
"/api/assistant/image/prompt",
files={"image": ("pixel-limit.png", PNG_BYTES, "image/png")},
)
assert pixel_limited.status_code == 400
assert "fully decodable" in pixel_limited.json()["detail"]
monkeypatch.setattr(config, "MAX_FILE_SIZE_MB", 0)
byte_limited = client.post(
"/api/assistant/image/prompt",
files={"image": ("byte-limit.png", PNG_BYTES, "image/png")},
)
assert byte_limited.status_code == 413
assert "too large" in byte_limited.json()["detail"]
def test_ai_assistant_uploaded_image_prompt_rejects_decompression_bomb_warning(client, monkeypatch):
from PIL import Image
settings = client.get("/api/settings").json()
configured = client.post("/api/settings", json=_assistant_runtime_payload(settings))
assert configured.status_code == 200
image = io.BytesIO()
Image.new("RGB", (5, 4)).save(image, format="PNG")
monkeypatch.setattr(config, "MAX_IMAGE_PIXELS", 16)
async def unexpected_request(**kwargs):
raise AssertionError("decompression-bomb upload must not reach the AI Assistant")
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", unexpected_request)
response = client.post(
"/api/assistant/image/prompt",
files={"image": ("pixel-warning.png", image.getvalue(), "image/png")},
)
assert response.status_code == 400
assert "fully decodable" in response.json()["detail"]
def test_ai_assistant_uploaded_image_prompt_validates_language_and_maps_upstream_errors(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post("/api/settings", json=_assistant_runtime_payload(settings))
assert configured.status_code == 200
invalid_language = client.post(
"/api/assistant/image/prompt",
data={"target_language": "same"},
files={"image": ("local.png", PNG_BYTES, "image/png")},
)
assert invalid_language.status_code == 422
async def failing_request(**kwargs):
raise assistant_router.assistant_client.AssistantError("vision upstream failed", status=503)
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", failing_request)
upstream_failure = client.post(
"/api/assistant/image/prompt",
files={"image": ("local.png", PNG_BYTES, "image/png")},
)
assert upstream_failure.status_code == 502
assert "vision upstream failed" in upstream_failure.json()["detail"]
def test_ai_assistant_uploaded_image_prompt_rejects_animations(client, monkeypatch):
from PIL import Image
settings = client.get("/api/settings").json()
assert client.post("/api/settings", json=_assistant_runtime_payload(settings)).status_code == 200
animated = io.BytesIO()
Image.new("RGB", (1, 1), "red").save(
animated,
format="GIF",
save_all=True,
append_images=[Image.new("RGB", (1, 1), "blue")],
)
async def unexpected_request(**kwargs):
raise AssertionError("animated upload must not reach the AI Assistant")
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", unexpected_request)
response = client.post(
"/api/assistant/image/prompt",
files={"image": ("animated.gif", animated.getvalue(), "image/gif")},
)
assert response.status_code == 400
assert "Animated images are not supported" in response.json()["detail"]
def test_ai_assistant_gallery_prompt_language_and_empty_prompt_do_not_persist(client, monkeypatch):
settings = client.get("/api/settings").json()
assert client.post("/api/settings", json=_assistant_runtime_payload(settings)).status_code == 200
assert _fake_gallery_entry("assistant-empty-prompt", "", "1024x1024", "assistant-empty-prompt.png")
seen_system_prompts: list[str] = []
async def empty_prompt(**kwargs):
seen_system_prompts.append(kwargs["system_prompt"])
return ({"description": "square", "prompt": "", "warnings": []}, "vision-model", 3)
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", empty_prompt)
prompt_response = client.post("/api/assistant/gallery/assistant-empty-prompt/prompt?target_language=zh-CN")
analyze_response = client.post("/api/assistant/gallery/assistant-empty-prompt/analyze?target_language=zh-CN")
metadata = client.get("/api/assistant/gallery/assistant-empty-prompt/metadata")
assert prompt_response.status_code == 502
assert analyze_response.status_code == 502
assert metadata.status_code == 200
assert metadata.json()["prompt"] == ""
assert all("Write the prompt in Simplified Chinese" in prompt for prompt in seen_system_prompts)
def test_ai_assistant_gallery_batch_analysis_jobs_and_limits(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(settings),
)
assert configured.status_code == 200
first = _fake_gallery_entry("assistant-batch-1", "prompt 1", "1024x1024", "assistant-batch-1.png")
second = _fake_gallery_entry("assistant-batch-2", "prompt 2", "1024x1024", "assistant-batch-2.png")
assert first is not None and second is not None
seen: list[str] = []
async def fake_request_assistant_json(**kwargs):
seen.append(kwargs["user_prompt"])
return (
{
"description": "batch",
"prompt": "batch prompt",
"analysis": {"subjects": ["batch"]},
"warnings": [],
},
"assistant-vision-model",
9,
)
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", fake_request_assistant_json)
submitted = client.post(
"/api/assistant/gallery/batch/analyze",
json={"ids": ["assistant-batch-1", "assistant-batch-2"]},
)
assert submitted.status_code == 202
job_id = submitted.json()["job_id"]
job = _wait_for_gallery_batch_analyze_job(client, job_id)
assert job["status"] == "success"
assert job["requested_count"] == 2
assert job["analyzed_count"] == 2
assert settings_repo.get_gallery_ai_metadata("assistant-batch-1") is not None
assert settings_repo.get_gallery_ai_metadata("assistant-batch-2") is not None
assert len(seen) == 2
events = client.get(f"/api/assistant/gallery/batch/analyze/{job_id}/events")
assert events.status_code == 200
assert events.headers["content-type"].startswith("text/event-stream")
assert "event: analysis" in events.text
assert job_id in events.text
coordination_repo.create_gallery_job(
job_id="assistant-batch-running",
kind="ai_analyze",
status="running",
stage="analyzing",
message="busy",
progress=5,
created_at="2026-01-01T00:00:00+00:00",
updated_at="2026-01-01T00:00:00+00:00",
lease_expires_at="2999-01-01T00:00:00+00:00",
requested_count=1,
payload={"ids": ["assistant-batch-1"]},
)
limited = client.post(
"/api/assistant/gallery/batch/analyze",
json={"ids": ["assistant-batch-1"]},
)
assert limited.status_code == 429
assert "already queued or running" in limited.json()["detail"]
def test_ai_assistant_gallery_batch_analysis_limits_selection_and_uses_filter_payload(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(settings),
)
assert configured.status_code == 200
first = _fake_gallery_entry("assistant-filter-1", "filter prompt 1", "1024x1024", "assistant-filter-1.png")
second = _fake_gallery_entry("assistant-filter-2", "filter prompt 2", "1024x1024", "assistant-filter-2.png")
assert first is not None and second is not None
token_resp = client.post("/api/gallery/batch/selection-tokens", json={"filters": {"prompt": "filter prompt"}})
assert token_resp.status_code == 201
token = token_resp.json()["selection_token"]
original_limit = config.AI_ASSISTANT_BATCH_MAX_IMAGES
config.AI_ASSISTANT_BATCH_MAX_IMAGES = 1
try:
too_many = client.post("/api/assistant/gallery/batch/analyze", json={"selection_token": token})
finally:
config.AI_ASSISTANT_BATCH_MAX_IMAGES = original_limit
assert too_many.status_code == 413
async def fake_request_assistant_json(**kwargs):
return (
{
"description": "filter batch",
"prompt": "filter prompt",
"analysis": {"subjects": ["filter"]},
"warnings": [],
},
"assistant-vision-model",
9,
)
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", fake_request_assistant_json)
submitted = client.post("/api/assistant/gallery/batch/analyze", json={"selection_token": token})
assert submitted.status_code == 202
job_id = submitted.json()["job_id"]
queued_job = coordination_repo.get_gallery_job("ai_analyze", job_id)
assert queued_job is not None
assert "filters" in queued_job["payload"]
assert "ids" not in queued_job["payload"]
job = _wait_for_gallery_batch_analyze_job(client, job_id)
assert job["status"] == "success"
assert job["requested_count"] == 2
assert job["analyzed_count"] == 2
def test_ai_assistant_gallery_batch_analysis_selection_snapshot_excludes_later_rows(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post("/api/settings", json=_assistant_runtime_payload(settings))
assert configured.status_code == 200
_fake_gallery_entry("assistant-snapshot-1", "snapshot prompt 1", "1024x1024", "assistant-snapshot-1.png")
_fake_gallery_entry("assistant-snapshot-2", "snapshot prompt 2", "1024x1024", "assistant-snapshot-2.png")
token_resp = client.post("/api/gallery/batch/selection-tokens", json={"filters": {"prompt": "snapshot prompt"}})
assert token_resp.status_code == 201
monkeypatch.setattr(assistant_router, "_kick_ai_analyze_dispatcher", lambda: None)
submitted = client.post(
"/api/assistant/gallery/batch/analyze",
json={"selection_token": token_resp.json()["selection_token"]},
)
assert submitted.status_code == 202
job_id = submitted.json()["job_id"]
queued = coordination_repo.get_gallery_job("ai_analyze", job_id)
assert queued is not None
assert queued["payload"]["snapshot"]
_fake_gallery_entry("assistant-snapshot-3", "snapshot prompt 3", "1024x1024", "assistant-snapshot-3.png")
analyzed_ids: list[str] = []
async def fake_analyze_with_lease(image_id, **kwargs):
analyzed_ids.append(image_id)
monkeypatch.setattr(assistant_router, "_analyze_gallery_image_with_lease_renewal", fake_analyze_with_lease)
claimed = coordination_repo.claim_next_gallery_job(
kind="ai_analyze",
worker_id="owner-a",
lease_expires_at="2999-01-01T00:00:00+00:00",
now="2026-01-01T00:00:00+00:00",
running_limit=1,
)
assert claimed is not None
asyncio.run(assistant_router._run_ai_analyze_job(claimed))
stored = coordination_repo.get_gallery_job("ai_analyze", job_id)
assert stored is not None
assert stored["status"] == "success"
assert stored["requested_count"] == 2
assert stored["processed_count"] == 2
assert stored["exported_count"] == 2
assert set(analyzed_ids) == {"assistant-snapshot-1", "assistant-snapshot-2"}
def test_ai_assistant_gallery_batch_analysis_selection_snapshot_counts_deleted_rows(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post("/api/settings", json=_assistant_runtime_payload(settings))
assert configured.status_code == 200
_fake_gallery_entry("assistant-snapshot-delete-1", "snapshot delete 1", "1024x1024", "assistant-snapshot-delete-1.png")
_fake_gallery_entry("assistant-snapshot-delete-2", "snapshot delete 2", "1024x1024", "assistant-snapshot-delete-2.png")
token_resp = client.post("/api/gallery/batch/selection-tokens", json={"filters": {"prompt": "snapshot delete"}})
assert token_resp.status_code == 201
monkeypatch.setattr(assistant_router, "_kick_ai_analyze_dispatcher", lambda: None)
submitted = client.post(
"/api/assistant/gallery/batch/analyze",
json={"selection_token": token_resp.json()["selection_token"]},
)
assert submitted.status_code == 202
job_id = submitted.json()["job_id"]
deleted, _deleted_files = gallery_mutations.delete_gallery_image("assistant-snapshot-delete-2")
assert deleted is True
analyzed_ids: list[str] = []
async def fake_analyze_with_lease(image_id, **kwargs):
analyzed_ids.append(image_id)
monkeypatch.setattr(assistant_router, "_analyze_gallery_image_with_lease_renewal", fake_analyze_with_lease)
claimed = coordination_repo.claim_next_gallery_job(
kind="ai_analyze",
worker_id="owner-a",
lease_expires_at="2999-01-01T00:00:00+00:00",
now="2026-01-01T00:00:00+00:00",
running_limit=1,
)
assert claimed is not None
asyncio.run(assistant_router._run_ai_analyze_job(claimed))
stored = coordination_repo.get_gallery_job("ai_analyze", job_id)
assert stored is not None
assert stored["status"] == "error"
assert stored["requested_count"] == 2
assert stored["processed_count"] == 1
assert stored["exported_count"] == 1
assert stored["missing_count"] == 1
assert stored["failed_count"] == 0
assert analyzed_ids == ["assistant-snapshot-delete-1"]
def test_ai_assistant_gallery_batch_analysis_validates_runtime_before_queueing(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(
settings,
optimizer_api_key="${MISSING_ASSISTANT_API_KEY}",
),
)
assert configured.status_code == 200
monkeypatch.delenv("MISSING_ASSISTANT_API_KEY", raising=False)
entry = _fake_gallery_entry("assistant-batch-config-error", "prompt", "1024x1024", "assistant-batch-config.png")
assert entry is not None
submitted = client.post(
"/api/assistant/gallery/batch/analyze",
json={"ids": ["assistant-batch-config-error"]},
)
assert submitted.status_code == 422
assert "MISSING_ASSISTANT_API_KEY is not set or empty" in submitted.json()["detail"]
assert coordination_repo.count_active_gallery_jobs("ai_analyze") == 0
def test_ai_assistant_gallery_batch_analysis_renews_lease_during_image_calls(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(settings),
)
assert configured.status_code == 200
entry = _fake_gallery_entry("assistant-batch-renew", "prompt", "1024x1024", "assistant-batch-renew.png")
assert entry is not None
original_interval = assistant_router.AI_ANALYZE_JOB_LEASE_RENEW_SECONDS
assistant_router.AI_ANALYZE_JOB_LEASE_RENEW_SECONDS = 0.01
renewals: list[tuple[str, str]] = []
original_renew = coordination_repo.renew_gallery_job_lease
def fake_renew_gallery_job_lease(**kwargs):
renewals.append((kwargs["job_id"], kwargs["lease_owner"]))
return original_renew(**kwargs)
async def fake_request_assistant_json(**kwargs):
await asyncio.sleep(0.05)
return (
{
"description": "renewed",
"prompt": "renewed prompt",
"analysis": {"subjects": ["renewed"]},
"warnings": [],
},
"assistant-vision-model",
9,
)
monkeypatch.setattr(assistant_router, "renew_gallery_job_lease", fake_renew_gallery_job_lease)
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", fake_request_assistant_json)
try:
submitted = client.post(
"/api/assistant/gallery/batch/analyze",
json={"ids": ["assistant-batch-renew"]},
)
assert submitted.status_code == 202
job_id = submitted.json()["job_id"]
job = _wait_for_gallery_batch_analyze_job(client, job_id)
finally:
assistant_router.AI_ANALYZE_JOB_LEASE_RENEW_SECONDS = original_interval
assert job["status"] == "success"
assert renewals
assert all(item[0] == job_id for item in renewals)
assert all(item[1] for item in renewals)
def test_ai_assistant_gallery_batch_analysis_retries_assistant_slot_backpressure(client, monkeypatch):
calls = 0
async def fake_analyze_with_lease(image_id, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
raise assistant_router.HTTPException(status_code=429, detail="AI Assistant is at its concurrency limit")
monkeypatch.setattr(assistant_router, "AI_ASSISTANT_SLOT_RETRY_SECONDS", 0)
monkeypatch.setattr(assistant_router, "_analyze_gallery_image_with_lease_renewal", fake_analyze_with_lease)
job = coordination_repo.create_gallery_job(
job_id="assistant-batch-backpressure",
kind="ai_analyze",
status="running",
stage="analyzing",
message="running",
progress=0,
created_at="2026-01-01T00:00:00+00:00",
updated_at="2026-01-01T00:00:00+00:00",
lease_owner="owner-a",
lease_expires_at="2999-01-01T00:00:00+00:00",
requested_count=1,
payload={"ids": ["assistant-backpressure-1"]},
)
asyncio.run(assistant_router._run_ai_analyze_job(job))
stored = coordination_repo.get_gallery_job("ai_analyze", "assistant-batch-backpressure")
assert stored is not None
assert stored["status"] == "success"
assert stored["processed_count"] == 1
assert stored["exported_count"] == 1
assert stored["failed_count"] == 0
assert calls == 2
def test_ai_assistant_gallery_batch_analysis_stops_when_lease_owner_changes(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(settings),
)
assert configured.status_code == 200
entry = _fake_gallery_entry("assistant-batch-owner", "prompt", "1024x1024", "assistant-batch-owner.png")
assert entry is not None
async def fake_analyze_with_lease(*args, **kwargs):
return None
progress_calls = 0
original_update_progress = coordination_repo.update_gallery_job_progress
def fake_update_progress(job_id, updates, **kwargs):
nonlocal progress_calls
progress_calls += 1
if progress_calls == 1:
return False
return original_update_progress(job_id, updates, **kwargs)
monkeypatch.setattr(assistant_router, "_analyze_gallery_image_with_lease_renewal", fake_analyze_with_lease)
monkeypatch.setattr(assistant_router, "update_gallery_job_progress", fake_update_progress)
job = coordination_repo.create_gallery_job(
job_id="assistant-batch-owner-job",
kind="ai_analyze",
status="running",
stage="analyzing",
message="running",
progress=0,
created_at="2026-01-01T00:00:00+00:00",
updated_at="2026-01-01T00:00:00+00:00",
lease_owner="owner-a",
lease_expires_at="2999-01-01T00:00:00+00:00",
requested_count=1,
payload={"ids": ["assistant-batch-owner"]},
)
asyncio.run(assistant_router._run_ai_analyze_job(job))
stored = coordination_repo.get_gallery_job("ai_analyze", "assistant-batch-owner-job")
assert stored is not None
assert stored["status"] == "running"
assert stored["progress"] == 0
assert progress_calls == 1
def test_ai_assistant_gallery_batch_analysis_resumes_id_jobs_after_stored_progress(client, monkeypatch):
analyzed_ids: list[str] = []
async def fake_analyze_with_lease(image_id, **kwargs):
analyzed_ids.append(image_id)
monkeypatch.setattr(assistant_router, "_analyze_gallery_image_with_lease_renewal", fake_analyze_with_lease)
job = coordination_repo.create_gallery_job(
job_id="assistant-batch-resume-ids",
kind="ai_analyze",
status="running",
stage="analyzing",
message="running",
progress=32,
created_at="2026-01-01T00:00:00+00:00",
updated_at="2026-01-01T00:00:00+00:00",
lease_owner="owner-a",
lease_expires_at="2999-01-01T00:00:00+00:00",
requested_count=3,
processed_count=1,
exported_count=1,
payload={"ids": ["assistant-resume-1", "assistant-resume-2", "assistant-resume-3"]},
)
asyncio.run(assistant_router._run_ai_analyze_job(job))
stored = coordination_repo.get_gallery_job("ai_analyze", "assistant-batch-resume-ids")
assert stored is not None
assert stored["status"] == "success"
assert stored["processed_count"] == 3
assert stored["exported_count"] == 3
assert analyzed_ids == ["assistant-resume-2", "assistant-resume-3"]
def test_ai_assistant_gallery_batch_analysis_checkpoints_completed_selection_rows(client, monkeypatch):
def fake_get_gallery_id_batch(*args, **kwargs):
return [
{"id": "assistant-select-1", "sort_seq": 30},
{"id": "assistant-select-2", "sort_seq": 20},
{"id": "assistant-select-3", "sort_seq": 10},
]
analyzed_ids: list[str] = []
async def fake_analyze_with_lease(image_id, **kwargs):
analyzed_ids.append(image_id)
if image_id == "assistant-select-2":
raise assistant_router.AIAnalyzeJobLeaseLost("lost lease")
monkeypatch.setattr(assistant_router, "get_gallery_id_batch", fake_get_gallery_id_batch)
monkeypatch.setattr(assistant_router, "_analyze_gallery_image_with_lease_renewal", fake_analyze_with_lease)
job = coordination_repo.create_gallery_job(
job_id="assistant-batch-selection-checkpoint",
kind="ai_analyze",
status="running",
stage="analyzing",
message="running",
progress=0,
created_at="2026-01-01T00:00:00+00:00",
updated_at="2026-01-01T00:00:00+00:00",
lease_owner="owner-a",
lease_expires_at="2999-01-01T00:00:00+00:00",
requested_count=3,
payload={"filters": {}, "checkpoint": None},
)
asyncio.run(assistant_router._run_ai_analyze_job(job))
stored = coordination_repo.get_gallery_job("ai_analyze", "assistant-batch-selection-checkpoint")
assert stored is not None
assert stored["status"] == "running"
assert stored["processed_count"] == 1
assert stored["exported_count"] == 1
assert stored["payload"]["checkpoint"] == {"id": "assistant-select-1", "sort_seq": 30}
assert analyzed_ids == ["assistant-select-1", "assistant-select-2"]
def test_ai_assistant_gallery_batch_analysis_treats_missing_images_as_error(client, monkeypatch):
settings = client.get("/api/settings").json()
configured = client.post(
"/api/settings",
json=_assistant_runtime_payload(settings),
)
assert configured.status_code == 200
entry = _fake_gallery_entry("assistant-batch-missing", "prompt", "1024x1024", "assistant-batch-missing.png")
assert entry is not None
image_path = image_files.safe_image_path(entry.filename)
assert image_path is not None
image_path.unlink()
async def fake_request_assistant_json(**kwargs):
raise AssertionError("missing image should not call assistant")
monkeypatch.setattr(assistant_router.assistant_client, "request_assistant_json", fake_request_assistant_json)
submitted = client.post(
"/api/assistant/gallery/batch/analyze",
json={"ids": ["assistant-batch-missing"]},
)
assert submitted.status_code == 202
job = _wait_for_gallery_batch_analyze_job(client, submitted.json()["job_id"])
assert job["status"] == "error"
assert job["stage"] == "error"
assert job["requested_count"] == 1
assert job["processed_count"] == 1
assert job["analyzed_count"] == 0
assert job["missing_count"] == 1
assert job["failed_count"] == 0
assert "missing" in job["error"]
def test_ai_assistant_gallery_batch_jobs_are_cleaned_by_gallery_job_gc(client):
coordination_repo.create_gallery_job(
job_id="assistant-batch-stale-success",
kind="ai_analyze",
status="success",
stage="completed",
message="done",
progress=100,
created_at="2026-01-01T00:00:00+00:00",
updated_at="2026-01-01T00:00:00+00:00",
completed_at="2026-01-01T00:00:00+00:00",
requested_count=1,
exported_count=1,
payload={"ids": ["assistant-batch-1"]},
)
stale = coordination_repo.cleanup_stale_gallery_jobs(
gallery_common.AI_ANALYZE_JOB_KIND,
gallery_common.AI_ANALYZE_JOB_TTL_SECONDS,
)
assert [job["job_id"] for job in stale] == ["assistant-batch-stale-success"]
assert coordination_repo.get_gallery_job("ai_analyze", "assistant-batch-stale-success") is None