- 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.
753 lines
24 KiB
Python
Executable File
753 lines
24 KiB
Python
Executable File
from backend.tests.support.contract import * # noqa: F403
|
|
|
|
def test_import_archive(client):
|
|
resp = _post_import_archive(client, _import_archive_bytes())
|
|
assert resp.status_code == 202
|
|
finished = _wait_for_gallery_import_job(client, resp.json()["job_id"])
|
|
assert finished["status"] == "success"
|
|
assert finished["imported_count"] == 1
|
|
|
|
imported = gallery_queries.get_gallery_entry("import-1")
|
|
assert imported is not None
|
|
assert imported.bytes == len(PNG_BYTES)
|
|
assert imported.thumbnail_url.startswith("/api/thumb/")
|
|
|
|
|
|
def test_import_archive_truncates_long_image_filename(client):
|
|
long_stem = "x" * 320
|
|
long_name = f"images/{long_stem}.png"
|
|
|
|
resp = _post_import_archive(
|
|
client,
|
|
_import_archive_bytes(image_name=long_name),
|
|
)
|
|
|
|
assert resp.status_code == 202
|
|
finished = _wait_for_gallery_import_job(client, resp.json()["job_id"])
|
|
assert finished["status"] == "success"
|
|
imported = gallery_queries.get_gallery_entry("import-1")
|
|
assert imported is not None
|
|
assert imported.filename.endswith(".png")
|
|
assert len(imported.filename.encode("utf-8")) <= 240
|
|
assert len(Path(imported.filename).stem) < len(long_stem)
|
|
path = image_files.safe_image_path(imported.filename)
|
|
assert path is not None
|
|
assert path.exists()
|
|
|
|
|
|
def test_import_archive_async_job_reports_progress_and_terminal_sse(client):
|
|
resp = client.post(
|
|
"/api/import?async_job=true",
|
|
files={"archive": ("archive.zip", _import_archive_bytes(), "application/zip")},
|
|
)
|
|
assert resp.status_code == 202
|
|
job = resp.json()
|
|
assert job["status"] == "queued"
|
|
assert job["requested_count"] == 1
|
|
|
|
finished = _wait_for_gallery_import_job(client, job["job_id"])
|
|
assert finished["status"] == "success"
|
|
assert finished["progress"] == 100
|
|
assert finished["imported_count"] == 1
|
|
assert finished["skipped_count"] == 0
|
|
assert gallery_queries.get_gallery_entry("import-1") is not None
|
|
|
|
events = client.get(f"/api/gallery/import-jobs/{job['job_id']}/events")
|
|
assert events.status_code == 200
|
|
assert events.headers["content-type"].startswith("text/event-stream")
|
|
assert "event: import" in events.text
|
|
assert job["job_id"] in events.text
|
|
|
|
|
|
def test_gallery_import_job_rejects_untrusted_db_path(client):
|
|
outside_path = Path(config.DATA_DIR).parent / "outside-import.zip"
|
|
outside_path.write_bytes(_import_archive_bytes())
|
|
now = "2026-01-01T00:00:00Z"
|
|
coordination_repo.create_gallery_job(
|
|
job_id="polluted-import-path",
|
|
kind="import",
|
|
status="queued",
|
|
stage="queued",
|
|
message="Queued gallery ZIP import",
|
|
progress=0,
|
|
path=str(outside_path),
|
|
requested_count=1,
|
|
processed_count=0,
|
|
exported_count=0,
|
|
missing_count=0,
|
|
created_at=now,
|
|
updated_at=now,
|
|
payload={},
|
|
)
|
|
|
|
asyncio.run(gallery_jobs._run_gallery_import_job(coordination_repo.get_gallery_job("import", "polluted-import-path")))
|
|
|
|
assert outside_path.exists()
|
|
assert gallery_queries.get_gallery_entry("import-1") is None
|
|
job = coordination_repo.get_gallery_job("import", "polluted-import-path")
|
|
assert job["status"] == "error"
|
|
assert job["error"] == "Import archive path is invalid"
|
|
|
|
|
|
def test_import_gallery_entries_dedupes_existing_rows_at_commit(client):
|
|
_fake_gallery_entry("import-1", "existing", "1024x1024", "import-1.png")
|
|
|
|
imported_count = gallery_mutations.import_gallery_entries(
|
|
[
|
|
(
|
|
PNG_BYTES,
|
|
{
|
|
"id": "import-1",
|
|
"prompt": "late import",
|
|
"size": "1024x1024",
|
|
"filename": "import-1.png",
|
|
"created_at": "2026-01-02T00:00:00Z",
|
|
},
|
|
)
|
|
]
|
|
)
|
|
|
|
assert imported_count == 1
|
|
existing = gallery_queries.get_gallery_entry("import-1")
|
|
assert existing.prompt == "existing"
|
|
|
|
imported = next(
|
|
entry for entry in gallery_queries.get_gallery() if entry.prompt == "late import"
|
|
)
|
|
assert imported.id != "import-1"
|
|
assert imported.filename == "import-1_1.png"
|
|
assert imported.thumbnail_filename is None
|
|
assert imported.thumbnail_url == "/api/thumb/import-1_1.png"
|
|
|
|
thumb = client.get("/api/thumb/import-1_1.png")
|
|
assert thumb.status_code == 404
|
|
assert thumbnail_jobs_repo.generate_thumbnail_for_image("import-1_1.png")
|
|
thumb = client.get("/api/thumb/import-1_1.png")
|
|
assert thumb.status_code == 200
|
|
@pytest.mark.parametrize(
|
|
("archive_bytes", "expected_detail", "config_updates"),
|
|
[
|
|
(
|
|
lambda: _import_archive_bytes(metadata=None),
|
|
"metadata.json is required",
|
|
{},
|
|
),
|
|
(
|
|
lambda: _import_archive_bytes(extra_files=2),
|
|
"Import archive contains too many files",
|
|
{"IMPORT_MAX_FILES": 2},
|
|
),
|
|
(
|
|
lambda: _import_archive_bytes(image_bytes=b"x" * 2048),
|
|
"Imported image is too large",
|
|
{"MAX_FILE_SIZE_MB": 0},
|
|
),
|
|
(
|
|
lambda: _import_archive_bytes(image_name="../evil.png"),
|
|
"Import archive contains unsafe paths",
|
|
{},
|
|
),
|
|
(
|
|
lambda: _import_archive_bytes(image_name="/evil.png"),
|
|
"Import archive contains unsafe paths",
|
|
{},
|
|
),
|
|
(
|
|
lambda: _import_archive_bytes(image_name="images\\evil.png"),
|
|
"Import archive contains unsafe paths",
|
|
{},
|
|
),
|
|
(
|
|
lambda: _import_archive_bytes(
|
|
image_name="images/import-1.svg",
|
|
image_bytes=b"<svg></svg>",
|
|
),
|
|
"No importable images found",
|
|
{},
|
|
),
|
|
],
|
|
)
|
|
def test_import_archive_rejects_invalid_content(
|
|
client,
|
|
archive_bytes,
|
|
expected_detail,
|
|
config_updates,
|
|
):
|
|
for name, value in config_updates.items():
|
|
setattr(config, name, value)
|
|
|
|
resp = _post_import_archive(client, archive_bytes())
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json()["detail"] == expected_detail
|
|
|
|
|
|
def test_import_archive_rejects_uncompressed_size_limit(client):
|
|
config.IMPORT_MAX_UNCOMPRESSED_MB = 0
|
|
|
|
resp = _post_import_archive(client, _import_archive_bytes())
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json()["detail"] == "Import archive uncompressed size exceeds limit"
|
|
|
|
|
|
def test_import_archive_rejects_large_metadata(client):
|
|
config.IMPORT_MAX_METADATA_BYTES = 10
|
|
|
|
resp = _post_import_archive(client, _import_archive_bytes())
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json()["detail"] == "metadata.json is too large"
|
|
|
|
|
|
def test_import_archive_rejects_duplicate_metadata_member(client):
|
|
metadata = {
|
|
"schema_version": 1,
|
|
"images": [
|
|
{"id": "one", "filename": "images/import-1.png", "prompt": "one", "size": "auto"},
|
|
{"id": "two", "filename": "images/import-1.png", "prompt": "two", "size": "auto"},
|
|
],
|
|
}
|
|
resp = _post_import_archive(client, _import_archive_bytes(metadata=metadata))
|
|
assert resp.status_code == 400
|
|
assert resp.json()["detail"] == "Import metadata references an image more than once"
|
|
|
|
|
|
def test_import_archive_rejects_metadata_entry_limit(client):
|
|
config.IMPORT_MAX_ENTRIES = 1
|
|
metadata = {
|
|
"schema_version": 1,
|
|
"images": [
|
|
{"id": "one", "filename": "images/import-1.png", "prompt": "one", "size": "auto"},
|
|
{"id": "two", "filename": "images/import-1.png", "prompt": "two", "size": "auto"},
|
|
],
|
|
}
|
|
resp = _post_import_archive(client, _import_archive_bytes(metadata=metadata))
|
|
assert resp.status_code == 400
|
|
assert resp.json()["detail"] == "Import metadata contains too many entries"
|
|
def test_import_archive_rejects_uploaded_archive_size_limit(client):
|
|
config.IMPORT_ARCHIVE_MAX_MB = 0
|
|
|
|
resp = _post_import_archive(client, _import_archive_bytes())
|
|
|
|
assert resp.status_code == 413
|
|
assert resp.json()["detail"] == "Request body too large"
|
|
|
|
|
|
def test_import_archive_rejects_high_compression_ratio(client):
|
|
config.IMPORT_MAX_COMPRESSION_RATIO = 1
|
|
|
|
resp = _post_import_archive(client, _import_archive_bytes(image_bytes=b"0" * 1024))
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json()["detail"] == "Import archive compression ratio exceeds limit"
|
|
|
|
|
|
def test_upload_rejects_svg(client):
|
|
resp = client.post(
|
|
"/api/edits",
|
|
data={
|
|
"prompt": "no svg",
|
|
"model": "gpt-image-2",
|
|
"n": 1,
|
|
"quality": "auto",
|
|
"output_format": "png",
|
|
},
|
|
files={"image": ("input.svg", b"<svg></svg>", "image/svg+xml")},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json()["detail"] == "Upload must be an image file."
|
|
|
|
|
|
|
|
|
|
def test_upload_rejects_mismatched_png_content(client):
|
|
resp = client.post(
|
|
"/api/edits",
|
|
data={
|
|
"prompt": "fake png",
|
|
"model": "gpt-image-2",
|
|
"n": 1,
|
|
"quality": "auto",
|
|
"output_format": "png",
|
|
},
|
|
files={"image": ("input.png", b"<svg></svg>", "image/png")},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json()["detail"] == "Image data must be a supported raster image format"
|
|
|
|
|
|
def test_import_archive_skips_mismatched_png_content(client):
|
|
resp = _post_import_archive(
|
|
client,
|
|
_import_archive_bytes(image_name="images/fake.png", image_bytes=b"<svg></svg>"),
|
|
)
|
|
|
|
assert resp.status_code == 202
|
|
finished = _wait_for_gallery_import_job(client, resp.json()["job_id"])
|
|
assert finished["status"] == "error"
|
|
assert finished["error"] == "No importable images found"
|
|
|
|
|
|
def test_safe_image_paths_reject_traversal(client):
|
|
assert image_files.safe_image_path("gallery-zip.png") is not None
|
|
assert image_files.safe_image_path("../secret.png") is None
|
|
assert image_files.safe_image_path("nested/secret.png") is None
|
|
|
|
image = client.get("/api/image/..%2Fsecret.png")
|
|
thumb = client.get("/api/thumb/..%2Fsecret.png")
|
|
download = client.get("/api/download/..%2Fsecret.png")
|
|
|
|
assert image.status_code == 404
|
|
assert thumb.status_code == 404
|
|
assert download.status_code == 404
|
|
|
|
|
|
def test_image_validation_rejects_magic_only_truncated_image(client):
|
|
with pytest.raises(ValueError, match="fully decodable"):
|
|
image_files.validate_image_bytes(b"\xff\xd8\xff\xd9", filename="truncated.jpg")
|
|
|
|
|
|
def test_download_all_skips_polluted_gallery_filename(client):
|
|
_fake_gallery_entry("safe", "safe", "1024x1024", "safe.png")
|
|
gallery_mutations.add_to_gallery_sync(
|
|
image_id="polluted",
|
|
prompt="polluted",
|
|
size="1024x1024",
|
|
filename="../secret.png",
|
|
image_bytes=None,
|
|
)
|
|
|
|
created = client.post("/api/gallery/direct-export-jobs")
|
|
assert created.status_code == 202
|
|
job = created.json()
|
|
archive = client.get(job["download_url"])
|
|
|
|
assert archive.status_code == 200
|
|
with zipfile.ZipFile(io.BytesIO(archive.content)) as zf:
|
|
assert "images/safe.png" in zf.namelist()
|
|
assert all("secret" not in name for name in zf.namelist())
|
|
|
|
|
|
def test_edit_from_gallery_rejects_polluted_filename(client):
|
|
gallery_mutations.add_to_gallery_sync(
|
|
image_id="polluted",
|
|
prompt="polluted",
|
|
size="1024x1024",
|
|
filename="../secret.png",
|
|
image_bytes=None,
|
|
)
|
|
|
|
resp = client.post(
|
|
"/api/edits/from-gallery/polluted",
|
|
data={
|
|
"prompt": "edit polluted",
|
|
"model": "gpt-image-2",
|
|
"n": 1,
|
|
"quality": "auto",
|
|
"output_format": "png",
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Gallery image file not found"
|
|
def test_image_url_download_disables_redirects_and_validates_redirect_target(client):
|
|
session = _FakeSession(
|
|
[
|
|
_FakeResponse(302, {"Location": "http://127.0.0.1/secret.png"}),
|
|
]
|
|
)
|
|
|
|
with pytest.raises(ValueError):
|
|
asyncio.run(_download_with_fake_session(session, "https://example.com/image.png"))
|
|
|
|
assert session.requested_urls == ["https://example.com/image.png"]
|
|
assert session.allow_redirects_values == [False]
|
|
|
|
|
|
def test_image_url_download_rejects_plain_http(client):
|
|
session = _FakeSession(
|
|
[
|
|
_FakeResponse(200, {}, [PNG_BYTES], peer_ip="93.184.216.34"),
|
|
]
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Only HTTPS URLs are allowed"):
|
|
asyncio.run(_download_with_fake_session(session, "http://example.com/image.png"))
|
|
|
|
assert session.requested_urls == []
|
|
|
|
|
|
def test_image_url_download_rejects_large_content_length(client):
|
|
config.MAX_FILE_SIZE_MB = 0
|
|
session = _FakeSession(
|
|
[
|
|
_FakeResponse(200, {"Content-Length": "1"}, [b"x"]),
|
|
]
|
|
)
|
|
|
|
with pytest.raises(Exception, match="Image too large"):
|
|
asyncio.run(_download_with_fake_session(session, "https://example.com/image.png"))
|
|
|
|
assert session.allow_redirects_values == [False]
|
|
|
|
|
|
def test_image_url_download_rejects_stream_over_limit(client):
|
|
config.MAX_FILE_SIZE_MB = 0
|
|
session = _FakeSession(
|
|
[
|
|
_FakeResponse(200, {}, [b"x"]),
|
|
]
|
|
)
|
|
|
|
with pytest.raises(Exception, match="Image too large"):
|
|
asyncio.run(_download_with_fake_session(session, "https://example.com/image.png"))
|
|
|
|
|
|
def test_upstream_json_response_rejects_stream_over_limit(tmp_path):
|
|
_configure_runtime(tmp_path)
|
|
from backend.app.integrations.upstream import generation as upstream_client
|
|
|
|
config.MAX_UPSTREAM_JSON_MB = 1
|
|
too_large_json = b'{"data":"' + (b"x" * (1024 * 1024)) + b'"}'
|
|
resp = _FakeResponse(
|
|
200,
|
|
{"Content-Type": "application/json"},
|
|
[too_large_json],
|
|
)
|
|
|
|
with pytest.raises(
|
|
upstream_client.UpstreamApiError,
|
|
match="Upstream JSON response too large",
|
|
):
|
|
asyncio.run(
|
|
upstream_client.parse_upstream_json_response(
|
|
resp,
|
|
"/v1/images/generations",
|
|
None,
|
|
)
|
|
)
|
|
|
|
|
|
def test_upstream_json_response_accepts_json_body_with_wrong_content_type(tmp_path):
|
|
_configure_runtime(tmp_path)
|
|
from backend.app.integrations.upstream import generation as upstream_client
|
|
|
|
image_b64 = base64.b64encode(PNG_BYTES).decode("ascii")
|
|
response_body = json.dumps(
|
|
{"created": 1779943365, "data": [{"b64_json": image_b64}]}
|
|
).encode("utf-8")
|
|
resp = _FakeResponse(
|
|
200,
|
|
{"Content-Type": "text/plain; charset=utf-8"},
|
|
[response_body],
|
|
)
|
|
|
|
result, response_text = asyncio.run(
|
|
upstream_client.parse_upstream_json_response(
|
|
resp,
|
|
"/v1/images/generations",
|
|
None,
|
|
)
|
|
)
|
|
|
|
assert result["data"][0]["b64_json"] == image_b64
|
|
assert response_text.startswith('{"created":')
|
|
|
|
|
|
def test_upstream_chat_response_accepts_json_body_with_wrong_content_type(tmp_path):
|
|
_configure_runtime(tmp_path)
|
|
from backend.app.integrations.upstream import generation as upstream_client
|
|
|
|
response_body = json.dumps(
|
|
{"choices": [{"message": {"content": "https://example.com/generated.png"}}]}
|
|
).encode("utf-8")
|
|
resp = _FakeResponse(
|
|
200,
|
|
{"Content-Type": "text/plain"},
|
|
[response_body],
|
|
)
|
|
|
|
result, _response_text = asyncio.run(
|
|
upstream_client.parse_upstream_chat_completion_response(
|
|
resp,
|
|
"/v1/chat/completions",
|
|
None,
|
|
)
|
|
)
|
|
|
|
assert result["choices"][0]["message"]["content"] == "https://example.com/generated.png"
|
|
|
|
|
|
def test_image_url_download_rejects_private_peer_ip(client):
|
|
session = _FakeSession(
|
|
[
|
|
_FakeResponse(200, {}, [PNG_BYTES], peer_ip="127.0.0.1"),
|
|
]
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="private/internal IP"):
|
|
asyncio.run(_download_with_fake_session(session, "https://example.com/image.png"))
|
|
|
|
|
|
def test_socks5_upstream_private_dns_logs_trust_boundary_warning(tmp_path, monkeypatch, caplog):
|
|
_configure_runtime(tmp_path)
|
|
from backend.app.integrations.upstream import generation as upstream_client
|
|
from backend.app.schemas.generation import GenerateRequest
|
|
|
|
monkeypatch.setattr(
|
|
upstream_client.ssrf,
|
|
"resolve_hostname",
|
|
lambda hostname: (hostname, ["10.0.0.5"]),
|
|
)
|
|
|
|
caplog.set_level(logging.WARNING, logger="backend.app.integrations.upstream.errors")
|
|
with pytest.raises(ValueError, match="private/internal IP"):
|
|
asyncio.run(
|
|
ORIGINAL_CALL_IMAGE_GENERATION_API(
|
|
"https://api.example.com",
|
|
"key",
|
|
"/v1/images/generations",
|
|
GenerateRequest(prompt="private dns"),
|
|
socks5_proxy="socks5://127.0.0.1:1080",
|
|
)
|
|
)
|
|
|
|
assert "SOCKS5 proxy is enabled" in caplog.text
|
|
assert "proxy is the trust boundary" in caplog.text
|
|
|
|
|
|
def test_prepare_upstream_request_centralizes_security_headers_and_peer_check(tmp_path, monkeypatch):
|
|
_configure_runtime(tmp_path)
|
|
from backend.app.integrations.upstream import generation as upstream_client
|
|
|
|
calls: list[tuple] = []
|
|
|
|
async def fake_warn(upstream_url, socks5_proxy):
|
|
calls.append(("warn", upstream_url, socks5_proxy))
|
|
|
|
async def fake_validate(upstream_url, allowlist):
|
|
calls.append(("validate_url", upstream_url, allowlist))
|
|
|
|
def fake_peer(resp, label):
|
|
calls.append(("peer", label))
|
|
|
|
class FakeResponse:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class FakeSession:
|
|
def post(self, url, **kwargs):
|
|
calls.append(("post", url, kwargs))
|
|
return FakeResponse()
|
|
|
|
class FakePool:
|
|
def get(self, timeout_kind="upstream", socks5_proxy=None):
|
|
calls.append(("pool", timeout_kind, socks5_proxy))
|
|
return FakeSession()
|
|
|
|
monkeypatch.setattr(upstream_client, "_warn_if_socks5_upstream_resolves_private", fake_warn)
|
|
monkeypatch.setattr(upstream_client.ssrf, "validate_upstream_url_async", fake_validate)
|
|
monkeypatch.setattr(upstream_client.ssrf, "validate_response_peer_ip", fake_peer)
|
|
monkeypatch.setattr(upstream_client, "get_pool", lambda: FakePool())
|
|
|
|
prepared = asyncio.run(
|
|
upstream_client._prepare_upstream_request(
|
|
api_url="https://api.example.com",
|
|
api_key="key",
|
|
api_path="/v1/images/generations",
|
|
socks5_proxy=None,
|
|
)
|
|
)
|
|
assert prepared.upstream_url == "https://api.example.com/v1/images/generations"
|
|
assert prepared.headers == {
|
|
"Authorization": "Bearer key",
|
|
"User-Agent": "opencode",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
async def post_once():
|
|
async with prepared.post(json={"prompt": "hi"}):
|
|
pass
|
|
|
|
asyncio.run(post_once())
|
|
|
|
assert ("warn", "https://api.example.com/v1/images/generations", None) in calls
|
|
assert any(call[0] == "validate_url" for call in calls)
|
|
assert ("pool", "upstream", None) in calls
|
|
assert any(call[0] == "post" and call[2]["allow_redirects"] is False for call in calls)
|
|
assert ("peer", "Upstream API") in calls
|
|
|
|
|
|
def test_upstream_returned_image_url_download_stays_direct(tmp_path, monkeypatch):
|
|
_configure_runtime(tmp_path)
|
|
import importlib
|
|
from backend.app.integrations.upstream import generation as upstream_client_module
|
|
from backend.app.schemas.generation import GenerateRequest
|
|
|
|
upstream_client = importlib.reload(upstream_client_module)
|
|
created_sessions: list[str] = []
|
|
session_events: list[tuple[str, str, str]] = []
|
|
|
|
class FakeJsonResponse:
|
|
status = 200
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
async def text(self):
|
|
return json.dumps(
|
|
{"data": [{"url": "https://example.com/generated.png"}]}
|
|
)
|
|
|
|
class FakeApiSession:
|
|
def __init__(self, proxy_url: str):
|
|
self.proxy_url = proxy_url
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def post(self, url, **kwargs):
|
|
session_events.append(("post", self.proxy_url, url))
|
|
return FakeJsonResponse()
|
|
|
|
class FakeDownloadSession:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def get(self, url, **kwargs):
|
|
session_events.append(("get", "", url))
|
|
return _FakeResponse(200, {}, [PNG_BYTES], peer_ip="93.184.216.34")
|
|
|
|
class FakePool:
|
|
def get(self, timeout_kind="upstream", socks5_proxy=None):
|
|
proxy_url = socks5_proxy or ""
|
|
created_sessions.append(proxy_url)
|
|
if proxy_url:
|
|
return FakeApiSession(proxy_url)
|
|
return FakeDownloadSession()
|
|
|
|
monkeypatch.setattr(upstream_client, "get_pool", lambda: FakePool())
|
|
|
|
entries = asyncio.run(
|
|
upstream_client.call_image_generation_api(
|
|
"https://api.example.com",
|
|
"key",
|
|
"/v1/images/generations",
|
|
GenerateRequest(prompt="url result"),
|
|
socks5_proxy="socks5://127.0.0.1:1080",
|
|
)
|
|
)
|
|
|
|
assert entries
|
|
assert created_sessions == ["socks5://127.0.0.1:1080", ""]
|
|
assert session_events[0] == (
|
|
"post",
|
|
"socks5://127.0.0.1:1080",
|
|
"https://api.example.com/v1/images/generations",
|
|
)
|
|
assert session_events[1] == ("get", "", "https://example.com/generated.png")
|
|
|
|
|
|
def test_chat_completions_sse_markdown_image_url_is_saved(tmp_path, monkeypatch):
|
|
_configure_runtime(tmp_path)
|
|
import importlib
|
|
from backend.app.integrations.upstream import generation as upstream_client_module
|
|
from backend.app.schemas.generation import GenerateRequest
|
|
|
|
upstream_client = importlib.reload(upstream_client_module)
|
|
session_events: list[tuple[str, str, dict | None]] = []
|
|
|
|
class FakeSseResponse:
|
|
status = 200
|
|
headers = {"Content-Type": "text/event-stream"}
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
async def text(self):
|
|
return (
|
|
'data: {"choices":[{"index":0,"delta":{"role":"assistant",'
|
|
'"reasoning_content":"image generating"}}]}\n\n'
|
|
'data: {"choices":[{"index":0,"delta":{"role":"assistant",'
|
|
'"content":""}}]}\n\n'
|
|
"data: [DONE]\n\n"
|
|
)
|
|
|
|
class FakeApiSession:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def post(self, url, **kwargs):
|
|
session_events.append(("post", url, kwargs.get("json")))
|
|
return FakeSseResponse()
|
|
|
|
class FakeDownloadSession:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def get(self, url, **kwargs):
|
|
session_events.append(("get", url, None))
|
|
return _FakeResponse(200, {}, [JPEG_BYTES], peer_ip="93.184.216.34")
|
|
|
|
class FakeCombinedSession(FakeApiSession, FakeDownloadSession):
|
|
pass
|
|
|
|
fake_session = FakeCombinedSession()
|
|
|
|
class FakePool:
|
|
def get(self, timeout_kind="upstream", socks5_proxy=None):
|
|
return fake_session
|
|
|
|
monkeypatch.setattr(upstream_client, "get_pool", lambda: FakePool())
|
|
|
|
entries = asyncio.run(
|
|
upstream_client.call_image_generation_api(
|
|
"https://api.example.com",
|
|
"key",
|
|
"/v1/chat/completions",
|
|
GenerateRequest(
|
|
prompt="draw a red square",
|
|
model="grok-imagine-image-lite",
|
|
),
|
|
)
|
|
)
|
|
|
|
assert entries
|
|
assert entries[0].filename.endswith(".jpg")
|
|
assert entries[0].output_format == "jpeg"
|
|
assert session_events[0] == (
|
|
"post",
|
|
"https://api.example.com/v1/chat/completions",
|
|
{
|
|
"model": "grok-imagine-image-lite",
|
|
"messages": [{"role": "user", "content": "draw a red square"}],
|
|
"stream": False,
|
|
},
|
|
)
|
|
assert session_events[1] == ("get", "https://example.com/generated.jpg", None)
|