From bd53e3b6733e2e2a47bc400364bc3d5cdb5b7935 Mon Sep 17 00:00:00 2001 From: Minor Date: Sat, 11 Jul 2026 20:50:53 +0000 Subject: [PATCH] fix: classify invalid image inputs --- README.md | 16 +++++----- docker-compose.yml | 2 +- pyproject.toml | 2 +- scripts/docker-build-push.sh | 2 +- src/picgen/upstream/errors.py | 5 ++- static/app.js | 4 +-- static/index.html | 6 ++-- tests/test_api.py | 37 +++++++++++++++++++++++ tests/test_static_assets.py | 4 +-- tests/test_upstream_client.py | 57 +++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 11 files changed, 118 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index aece2b2..b714f5a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PicGen Console -一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.58**。它把 +一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.59**。它把 `/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具) 包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。 @@ -14,7 +14,9 @@ ![PicGen Console 主程序界面](demo1.png) -## 0.1.58 主要特性 +## 0.1.59 主要特性 + +- **上游参数错误不再误报审核**:`invalid_mask_image_format` 会提示客户改用像素尺寸一致的标准 PNG,不再仅因通用 `image_generation_user_error` 类型就提示“内容审核未通过”;明确的内容政策 code 和文案仍会正确分类。 - **Responses 候选数量收敛**:强制使用图像工具、关闭并行工具调用并把请求数量写入提示词;上游仍越界时在落盘前按 `sample_count` 截断,避免单图任务意外生成 6 张候选并拖到数百秒,同时不再为非官方 `n` 参数自动重发付费请求。 @@ -109,10 +111,10 @@ PICGEN_LOG_FORMAT=json \ ### Docker ```bash -docker build -t minorli/picgen:0.1.58 . +docker build -t minorli/picgen:0.1.59 . docker run --rm -p 8000:8000 \ -v picgen-data:/app/data \ - minorli/picgen:0.1.58 + minorli/picgen:0.1.59 ``` 或: @@ -127,10 +129,10 @@ docker compose up -d ./scripts/docker-build-push.sh ``` -默认会构建并推送 `minorli/picgen:0.1.58`。也可以覆盖: +默认会构建并推送 `minorli/picgen:0.1.59`。也可以覆盖: ```bash -IMAGE=minorli/picgen VERSION=0.1.58 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh +IMAGE=minorli/picgen VERSION=0.1.59 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh ``` 镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root @@ -243,7 +245,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到 ## 图像通道 -PicGen 0.1.58 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: +PicGen 0.1.59 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: | 用户操作 | 默认接口 | 默认模型 | | --- | --- | --- | diff --git a/docker-compose.yml b/docker-compose.yml index 4059963..bfa56fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: picgen: - image: minorli/picgen:0.1.58 + image: minorli/picgen:0.1.59 build: context: . ports: diff --git a/pyproject.toml b/pyproject.toml index 2e4eeef..c41f1c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "picgen" -version = "0.1.58" +version = "0.1.59" description = "Enterprise-grade local web console for OpenAI-compatible image generation and editing APIs." readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/docker-build-push.sh b/scripts/docker-build-push.sh index 9fb62a5..f6427c3 100755 --- a/scripts/docker-build-push.sh +++ b/scripts/docker-build-push.sh @@ -2,7 +2,7 @@ set -euo pipefail IMAGE="${IMAGE:-minorli/picgen}" -VERSION="${VERSION:-0.1.58}" +VERSION="${VERSION:-0.1.59}" PLATFORM="${PLATFORM:-linux/amd64}" docker buildx build \ diff --git a/src/picgen/upstream/errors.py b/src/picgen/upstream/errors.py index 0202ce4..a6ad27e 100644 --- a/src/picgen/upstream/errors.py +++ b/src/picgen/upstream/errors.py @@ -60,6 +60,8 @@ def classify_upstream_error(status: int, message: str, details: str | None = Non haystack = f"{message}\n{details or ''}".lower() if status == 429 or "rate limit" in haystack or "rate_limit" in haystack: return "upstream_rate_limited" + if "invalid_mask_image_format" in _error_values(details): + return "upstream_invalid_image_input" if _looks_like_content_policy_error(status, message, details): return "upstream_content_policy" if "cloudflare" in haystack or "error 1010" in haystack: @@ -76,7 +78,6 @@ def _looks_like_content_policy_error(status: int, message: str, details: str | N "policy_violation", "safety_policy_violation", "moderation_blocked", - "image_generation_user_error", } for value in explicit_values ): @@ -120,6 +121,8 @@ def _error_values(details: str | None) -> set[str]: def public_upstream_error_message(status: int, code: str, action: str) -> str: if code == "upstream_rate_limited": return "图片生成服务当前请求较多,请稍后再试。" + if code == "upstream_invalid_image_input": + return "上游不接受当前输入图或蒙版的格式/尺寸,请改用像素尺寸一致的标准 PNG 后重试。" if code == "upstream_content_policy": return "这次提示词没有通过上游内容审核,请调整描述后重试。" if code == "upstream_blocked": diff --git a/static/app.js b/static/app.js index 91ec3e7..83f8485 100644 --- a/static/app.js +++ b/static/app.js @@ -1,11 +1,11 @@ -import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.58" +import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.59" import { DEFAULT_RESPONSES_MODEL, RESPONSES_MODEL_STORAGE_VERSION, RESPONSES_REASONING_STORAGE_VERSION, migrateStoredResponsesReasoningSettings, migrateStoredResponsesSettings, -} from "./responses-settings.mjs?v=0.1.58" +} from "./responses-settings.mjs?v=0.1.59" const RESPONSES_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max", "ultra"]) const DEFAULT_RESPONSES_REASONING_EFFORT = "xhigh" diff --git a/static/index.html b/static/index.html index 79b7871..954d87d 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,7 @@ PicGen Console - +
@@ -1046,7 +1046,7 @@

管理员高级设置

@@ -1356,6 +1356,6 @@

图片预览

- + diff --git a/tests/test_api.py b/tests/test_api.py index 206202c..5654b0c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2454,6 +2454,43 @@ def test_edit_passes_mask_and_options(make_client, settings_factory): assert field_names == ["image", "mask"] +def test_edit_invalid_mask_returns_actionable_input_error(make_client, settings_factory): + settings = settings_factory(default_api_key="sk-test") + client, fake, _ = make_client(settings=settings) + fake.run_multipart.side_effect = APIError( + 400, + "上游不接受当前输入图或蒙版的格式/尺寸,请改用像素尺寸一致的标准 PNG 后重试。", + "Invalid mask image format - mask size does not match image size", + code="upstream_invalid_image_input", + ) + + response = client.post( + "/api/edit", + json={ + "api_key": "sk-test", + "prompt": "只修改蒙版透明区域", + "model": "gpt-image-2", + "image": { + "name": "source.png", + "type": "image/png", + "data_url": f"data:image/png;base64,{TINY_PNG_B64}", + }, + "mask": { + "name": "mask.png", + "type": "image/png", + "data_url": f"data:image/png;base64,{TINY_PNG_B64}", + }, + }, + ) + + assert response.status_code == 400 + payload = response.json() + assert payload["code"] == "upstream_invalid_image_input" + assert "像素尺寸一致" in payload["error"] + assert "内容审核" not in payload["error"] + fake.run_multipart.assert_awaited_once() + + def test_responses_image_requires_prompt(make_client): client, _, _ = make_client() response = client.post("/api/responses-image", json={"api_key": "sk-test"}) diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index 9696ef7..5ece657 100644 --- a/tests/test_static_assets.py +++ b/tests/test_static_assets.py @@ -13,7 +13,7 @@ def test_legacy_responses_model_storage_is_migrated_once() -> None: settings_js = (ROOT_DIR / "static" / "responses-settings.mjs").read_text(encoding="utf-8") assert 'const DEPRECATED_RESPONSES_MODELS = new Set(["gpt-5.4"])' in app_js - assert 'from "./responses-settings.mjs?v=0.1.58"' in app_js + assert 'from "./responses-settings.mjs?v=0.1.59"' in app_js assert 'const LEGACY_DEFAULT_RESPONSES_MODEL = "gpt-5.5"' in settings_js assert "const RESPONSES_MODEL_STORAGE_VERSION = 4" in settings_js assert "function migrateStoredResponsesSettings" in settings_js @@ -38,7 +38,7 @@ def test_logo_overlay_uses_uploaded_asset_without_ai_guidance() -> None: assert 'const COMPANY_LOGO_URL = "6renyou.png"' in app_js assert "composeLogoOverlayForCandidates" in app_js assert "createOfficialLogoCanvas" in app_js - assert 'from "./logo-placement.mjs?v=0.1.58"' in app_js + assert 'from "./logo-placement.mjs?v=0.1.59"' in app_js assert "chooseLogoPlacement" in app_js assert "calculateLogoPlacementScore" in app_js assert "expandLogoSafetyRegion" in placement_js diff --git a/tests/test_upstream_client.py b/tests/test_upstream_client.py index 71ae2b0..4fa47da 100644 --- a/tests/test_upstream_client.py +++ b/tests/test_upstream_client.py @@ -66,6 +66,63 @@ async def test_run_json_reports_retry_exhaustion_to_user() -> None: await client.aclose() +async def test_run_multipart_invalid_mask_is_not_misclassified_as_content_policy() -> None: + body = json.dumps( + { + "error": { + "code": "invalid_mask_image_format", + "message": "Invalid mask image format - mask size does not match image size", + "type": "image_generation_user_error", + } + } + ) + transport = httpx.MockTransport(lambda request: httpx.Response(400, text=body)) + client = await _build_client(transport, max_retries=0) + try: + with pytest.raises(APIError) as info: + await client.run_multipart( + "https://upstream.test/images/edits", + "sk-test", + {"prompt": "add a lamp"}, + [], + "UA", + ) + assert info.value.status == 400 + assert info.value.code == "upstream_invalid_image_input" + assert "内容审核" not in info.value.message + assert "像素尺寸一致" in info.value.message + assert "mask size does not match image size" in (info.value.details or "") + finally: + await client.aclose() + + +async def test_run_multipart_explicit_content_policy_code_is_still_classified() -> None: + body = json.dumps( + { + "error": { + "code": "content_policy_violation", + "message": "The request violates content policy", + "type": "image_generation_user_error", + } + } + ) + transport = httpx.MockTransport(lambda request: httpx.Response(400, text=body)) + client = await _build_client(transport, max_retries=0) + try: + with pytest.raises(APIError) as info: + await client.run_multipart( + "https://upstream.test/images/edits", + "sk-test", + {"prompt": "disallowed"}, + [], + "UA", + ) + assert info.value.code == "upstream_content_policy" + assert "内容审核" in info.value.message + finally: + await client.aclose() + + async def test_run_json_translates_timeout() -> None: calls = {"count": 0} diff --git a/uv.lock b/uv.lock index 2d09d23..2696392 100644 --- a/uv.lock +++ b/uv.lock @@ -299,7 +299,7 @@ wheels = [ [[package]] name = "picgen" -version = "0.1.58" +version = "0.1.59" source = { editable = "." } dependencies = [ { name = "anyio" },