From 80761f09411baa83efc2ee493e8a2e95266a769d Mon Sep 17 00:00:00 2001 From: Minor Date: Sat, 11 Jul 2026 21:40:25 +0000 Subject: [PATCH] fix: handle nested image count errors safely --- README.md | 16 ++--- docker-compose.yml | 2 +- pyproject.toml | 2 +- scripts/docker-build-push.sh | 2 +- src/picgen/routes.py | 90 +++++++++++++++++--------- static/app.js | 4 +- static/index.html | 6 +- tests/test_api.py | 119 +++++++++++++++++++++++++++++++---- tests/test_static_assets.py | 4 +- uv.lock | 2 +- 10 files changed, 188 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index b714f5a..9a343b6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PicGen Console -一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.59**。它把 +一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.60**。它把 `/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具) 包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。 @@ -14,7 +14,9 @@ ![PicGen Console 主程序界面](demo1.png) -## 0.1.59 主要特性 +## 0.1.60 主要特性 + +- **多候选兼容参数路径**:上游若以 `tools[0].n` 等嵌套参数路径拒绝候选数量,自动去掉 `n` 并并发补齐候选;结构化识别只针对候选数字段,不会误重试内容审核错误。 - **上游参数错误不再误报审核**:`invalid_mask_image_format` 会提示客户改用像素尺寸一致的标准 PNG,不再仅因通用 `image_generation_user_error` 类型就提示“内容审核未通过”;明确的内容政策 code 和文案仍会正确分类。 @@ -111,10 +113,10 @@ PICGEN_LOG_FORMAT=json \ ### Docker ```bash -docker build -t minorli/picgen:0.1.59 . +docker build -t minorli/picgen:0.1.60 . docker run --rm -p 8000:8000 \ -v picgen-data:/app/data \ - minorli/picgen:0.1.59 + minorli/picgen:0.1.60 ``` 或: @@ -129,10 +131,10 @@ docker compose up -d ./scripts/docker-build-push.sh ``` -默认会构建并推送 `minorli/picgen:0.1.59`。也可以覆盖: +默认会构建并推送 `minorli/picgen:0.1.60`。也可以覆盖: ```bash -IMAGE=minorli/picgen VERSION=0.1.59 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh +IMAGE=minorli/picgen VERSION=0.1.60 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh ``` 镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root @@ -245,7 +247,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到 ## 图像通道 -PicGen 0.1.59 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: +PicGen 0.1.60 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: | 用户操作 | 默认接口 | 默认模型 | | --- | --- | --- | diff --git a/docker-compose.yml b/docker-compose.yml index bfa56fe..e84978d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: picgen: - image: minorli/picgen:0.1.59 + image: minorli/picgen:0.1.60 build: context: . ports: diff --git a/pyproject.toml b/pyproject.toml index c41f1c5..2739a4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "picgen" -version = "0.1.59" +version = "0.1.60" 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 f6427c3..d4b77b9 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.59}" +VERSION="${VERSION:-0.1.60}" PLATFORM="${PLATFORM:-linux/amd64}" docker buildx build \ diff --git a/src/picgen/routes.py b/src/picgen/routes.py index 23fa318..c1e83d6 100644 --- a/src/picgen/routes.py +++ b/src/picgen/routes.py @@ -915,44 +915,74 @@ def _extend_candidate_items(target: dict[str, Any], source: dict[str, Any], limi target_data.append(item) +def _decode_trailing_error_json(details: str) -> object | None: + starts = (0, *(match.start() for match in re.finditer(r"[\[{]", details))) + for start in dict.fromkeys(starts): + try: + return json.loads(details[start:]) + except (TypeError, ValueError): + continue + return None + + +def _structured_error_parameters(details: str | None) -> tuple[str, ...]: + if not details: + return () + payload = _decode_trailing_error_json(details) + if payload is None: + return () + + def _walk(value: object) -> tuple[str, ...]: + if isinstance(value, dict): + direct = tuple( + child + for key, child in value.items() + if key.lower() in {"param", "parameter"} and isinstance(child, str) + ) + nested = tuple(parameter for child in value.values() for parameter in _walk(child)) + return (*direct, *nested) + if isinstance(value, list): + return tuple(parameter for child in value for parameter in _walk(child)) + return () + + return _walk(payload) + + +def _is_sample_count_parameter(parameter: str) -> bool: + path_parts = tuple( + part for part in re.split(r"[.\[\]'\"\s]+", parameter.strip().lower()) if part + ) + return bool( + path_parts + and path_parts[-1] + in {"n", "sample_count", "best_of", "num_images", "n_images", "samples"} + ) + + def _error_mentions_sample_count(exc: APIError) -> bool: + structured_parameters = _structured_error_parameters(exc.details) + if structured_parameters: + return any(_is_sample_count_parameter(value) for value in structured_parameters) haystack = f"{exc.message}\n{exc.details or ''}".lower() - # Be specific: a bare " n" substring matches ordinary prose ("is not - # allowed", "no credit") and would wrongly strip `n` on, e.g., a content - # moderation 400. Only react to errors that actually name the sample-count - # parameter. - needles = ( - '"n"', - "'n'", - "parameter n", - "param n", - "value of n", - "n must", - # 裸 "sample" 会命中 "flagged sample" 这类审核文案,触发一次注定 - # 失败的全价重试;只认真正指参数名的写法。 - "sample_count", - "sample count", - "samples", - "best_of", - "num_images", - "n_images", + parameter_path = r"(?:[\w-]+(?:\[\d+\])?\.)*n" + patterns = ( + rf"\b(?:parameter|param)\s*[:=]?\s*[\"'`]?{parameter_path}(?:[\"'`]|\b)", + r"\bvalue\s+of\s+[\"'`]?n(?:[\"'`]|\b)", + r"(? bool: if sample_count <= 1: return False - if exc.status == HTTPStatus.BAD_REQUEST: - return _error_mentions_sample_count(exc) - # 502/503 from gateways that cannot handle n>1 usually mean the request - # was rejected outright, so a retry without n is safe. A 504 means the - # upstream may STILL be generating — re-POSTing would double the billing - # for a generation that eventually completes. - return exc.status in { - HTTPStatus.BAD_GATEWAY, - HTTPStatus.SERVICE_UNAVAILABLE, - } + # Only an explicit 400 parameter rejection proves the non-idempotent image + # request was not accepted. Gateway 5xx responses are ambiguous and must + # not be retried automatically because the upstream may still bill them. + return exc.status == HTTPStatus.BAD_REQUEST and _error_mentions_sample_count(exc) async def _fill_candidates( diff --git a/static/app.js b/static/app.js index 83f8485..05e369f 100644 --- a/static/app.js +++ b/static/app.js @@ -1,11 +1,11 @@ -import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.59" +import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.60" import { DEFAULT_RESPONSES_MODEL, RESPONSES_MODEL_STORAGE_VERSION, RESPONSES_REASONING_STORAGE_VERSION, migrateStoredResponsesReasoningSettings, migrateStoredResponsesSettings, -} from "./responses-settings.mjs?v=0.1.59" +} from "./responses-settings.mjs?v=0.1.60" 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 954d87d..7e91a03 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 5654b0c..00b3d2a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1988,14 +1988,11 @@ def test_generate_fans_out_when_upstream_returns_fewer_candidates(make_client, s assert "n" not in fake.run_json.await_args_list[1].args[2] -def test_generate_retries_without_sample_count_after_502(make_client, settings_factory): +def test_generate_does_not_retry_ambiguous_502(make_client, settings_factory): settings = settings_factory(default_api_key="sk-test") client, fake, _ = make_client(settings=settings) fake.run_json.side_effect = [ APIError(502, "Upstream request failed", code="upstream_error"), - {"data": [{"b64_json": TINY_PNG_B64}], "created": 1}, - {"data": [{"b64_json": TINY_PNG_B64}], "created": 2}, - {"data": [{"b64_json": TINY_PNG_B64}], "created": 3}, ] response = client.post( @@ -2009,11 +2006,8 @@ def test_generate_retries_without_sample_count_after_502(make_client, settings_f }, ) - assert response.status_code == 200 - assert response.json()["candidate_count"] == 3 - assert fake.run_json.await_count == 4 - assert fake.run_json.await_args_list[0].args[2]["n"] == 3 - assert "n" not in fake.run_json.await_args_list[1].args[2] + assert response.status_code == 502 + assert fake.run_json.await_count == 1 def test_generate_does_not_retry_on_content_moderation_400(make_client, settings_factory): @@ -2040,6 +2034,66 @@ def test_generate_does_not_retry_on_content_moderation_400(make_client, settings assert fake.run_json.await_count == 1 +def test_generate_does_not_retry_plural_moderation_sample_message( + make_client, settings_factory +): + settings = settings_factory(default_api_key="sk-test") + client, fake, _ = make_client(settings=settings) + fake.run_json.side_effect = [ + APIError( + 400, + "Image request rejected", + "上游状态码:400\n" + "上游错误:One or more samples were flagged by moderation.\n\n" + '{"code":"moderation_blocked","param":"prompt"}', + code="upstream_content_policy", + ), + ] + + response = client.post( + "/api/generate", + json={ + "api_key": "sk-test", + "endpoint_url": "https://api.openai.com/v1/images/generations", + "prompt": "生成两张海报", + "model": "gpt-image-2", + "sample_count": 2, + }, + ) + + assert response.status_code == 400 + assert fake.run_json.await_count == 1 + + +@pytest.mark.parametrize( + "message", + [ + "Unknown parameter name is invalid", + "Image generation must be retried manually", + ], +) +def test_generate_does_not_retry_ambiguous_plaintext_n_substrings( + make_client, settings_factory, message +): + settings = settings_factory(default_api_key="sk-test") + client, fake, _ = make_client(settings=settings) + fake.run_json.side_effect = [APIError(400, message, code="upstream_error")] + + response = client.post( + "/api/generate", + json={ + "api_key": "sk-test", + "endpoint_url": "https://api.openai.com/v1/images/generations", + "prompt": "生成两张海报", + "model": "gpt-image-2", + "sample_count": 2, + }, + ) + + assert response.status_code == 400 + assert fake.run_json.await_count == 1 + + def test_generate_upstream_rate_limit_returns_friendly_redacted_error(make_client, settings_factory): settings = settings_factory(default_api_key="sk-test") client, fake, _ = make_client(settings=settings) @@ -2376,11 +2430,54 @@ def test_edit_fans_out_when_upstream_returns_fewer_candidates(make_client, setti assert "n" not in second_fields -def test_edit_retries_without_sample_count_after_502(make_client, settings_factory): +def test_edit_does_not_retry_ambiguous_502(make_client, settings_factory): settings = settings_factory(default_api_key="sk-test") client, fake, _ = make_client(settings=settings) fake.run_multipart.side_effect = [ APIError(502, "Upstream request failed", code="upstream_error"), + ] + + response = client.post( + "/api/edit", + json={ + "api_key": "sk-test", + "endpoint_url": "https://api.openai.com/v1/images/edits", + "prompt": "生成三张候选", + "model": "gpt-image-2", + "sample_count": 3, + "images": [ + { + "name": "style.png", + "type": "image/png", + "data_url": f"data:image/png;base64,{TINY_PNG_B64}", + }, + { + "name": "material.png", + "type": "image/png", + "data_url": f"data:image/png;base64,{TINY_PNG_B64}", + }, + ], + }, + ) + + assert response.status_code == 502 + assert fake.run_multipart.await_count == 1 + + +def test_edit_retries_when_upstream_rejects_nested_sample_count_parameter( + 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, + "Upstream request failed", + "上游状态码:400\n" + "上游错误:Unknown parameter: 'tools[0].n'.\n\n" + '{"code":"unknown_parameter","param":"tools[0].n"}', + code="upstream_error", + ), {"data": [{"b64_json": TINY_PNG_B64}], "created": 1}, {"data": [{"b64_json": TINY_PNG_B64}], "created": 2}, {"data": [{"b64_json": TINY_PNG_B64}], "created": 3}, @@ -2413,7 +2510,7 @@ def test_edit_retries_without_sample_count_after_502(make_client, settings_facto assert response.json()["candidate_count"] == 3 assert fake.run_multipart.await_count == 4 assert fake.run_multipart.await_args_list[0].args[2]["n"] == 3 - assert "n" not in fake.run_multipart.await_args_list[1].args[2] + assert all("n" not in call.args[2] for call in fake.run_multipart.await_args_list[1:]) def test_edit_passes_mask_and_options(make_client, settings_factory): diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index 5ece657..c1632cd 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.59"' in app_js + assert 'from "./responses-settings.mjs?v=0.1.60"' 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.59"' in app_js + assert 'from "./logo-placement.mjs?v=0.1.60"' in app_js assert "chooseLogoPlacement" in app_js assert "calculateLogoPlacementScore" in app_js assert "expandLogoSafetyRegion" in placement_js diff --git a/uv.lock b/uv.lock index 2696392..5272fff 100644 --- a/uv.lock +++ b/uv.lock @@ -299,7 +299,7 @@ wheels = [ [[package]] name = "picgen" -version = "0.1.59" +version = "0.1.60" source = { editable = "." } dependencies = [ { name = "anyio" },