From 84eccf06ac8df2144c83d3d15903244caf9f0795 Mon Sep 17 00:00:00 2001 From: Minor Date: Sat, 11 Jul 2026 19:40:07 +0000 Subject: [PATCH] fix: cap Responses image candidates --- README.md | 16 ++++--- docker-compose.yml | 2 +- pyproject.toml | 2 +- scripts/docker-build-push.sh | 2 +- src/picgen/routes.py | 45 +++++++----------- src/picgen/upstream/payload.py | 12 +++++ static/app.js | 4 +- static/index.html | 6 +-- tests/test_api.py | 87 +++++++++++++++++++++++++++++++++- tests/test_regressions.py | 4 +- tests/test_static_assets.py | 4 +- uv.lock | 2 +- 12 files changed, 136 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 433e389..aece2b2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PicGen Console -一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.57**。它把 +一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.58**。它把 `/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具) 包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。 @@ -14,7 +14,9 @@ ![PicGen Console 主程序界面](demo1.png) -## 0.1.57 主要特性 +## 0.1.58 主要特性 + +- **Responses 候选数量收敛**:强制使用图像工具、关闭并行工具调用并把请求数量写入提示词;上游仍越界时在落盘前按 `sample_count` 截断,避免单图任务意外生成 6 张候选并拖到数百秒,同时不再为非官方 `n` 参数自动重发付费请求。 - **简洁模式尺寸提示**:上游返回不同画幅时继续保留完整原图,并在结果按钮上方直接显示请求尺寸、实际尺寸和未缩放说明;提示随结果持久化,刷新后仍可见。 @@ -107,10 +109,10 @@ PICGEN_LOG_FORMAT=json \ ### Docker ```bash -docker build -t minorli/picgen:0.1.57 . +docker build -t minorli/picgen:0.1.58 . docker run --rm -p 8000:8000 \ -v picgen-data:/app/data \ - minorli/picgen:0.1.57 + minorli/picgen:0.1.58 ``` 或: @@ -125,10 +127,10 @@ docker compose up -d ./scripts/docker-build-push.sh ``` -默认会构建并推送 `minorli/picgen:0.1.57`。也可以覆盖: +默认会构建并推送 `minorli/picgen:0.1.58`。也可以覆盖: ```bash -IMAGE=minorli/picgen VERSION=0.1.57 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh +IMAGE=minorli/picgen VERSION=0.1.58 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh ``` 镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root @@ -241,7 +243,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到 ## 图像通道 -PicGen 0.1.57 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: +PicGen 0.1.58 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: | 用户操作 | 默认接口 | 默认模型 | | --- | --- | --- | diff --git a/docker-compose.yml b/docker-compose.yml index 2db8800..4059963 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: picgen: - image: minorli/picgen:0.1.57 + image: minorli/picgen:0.1.58 build: context: . ports: diff --git a/pyproject.toml b/pyproject.toml index 7538e58..2e4eeef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "picgen" -version = "0.1.57" +version = "0.1.58" 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 5c7cc3d..9fb62a5 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.57}" +VERSION="${VERSION:-0.1.58}" PLATFORM="${PLATFORM:-linux/amd64}" docker buildx build \ diff --git a/src/picgen/routes.py b/src/picgen/routes.py index be8c65c..23fa318 100644 --- a/src/picgen/routes.py +++ b/src/picgen/routes.py @@ -1307,6 +1307,8 @@ async def _generate_itinerary_artwork( "instructions": ITINERARY_ARTWORK_INSTRUCTIONS, **_responses_reasoning_options(model, settings.default_responses_reasoning_effort), "stream": True, + "parallel_tool_calls": False, + "tool_choice": {"type": "image_generation"}, "input": [{"role": "user", "content": content}], "tools": [tool], } @@ -4925,8 +4927,6 @@ async def handle_responses_image( tool: dict[str, Any] = {"type": "image_generation"} if size and size != "auto": tool["size"] = size - if parsed.sample_count > 1: - tool["n"] = parsed.sample_count for key in ("quality", "background", "output_format", "output_compression", "moderation"): if key in image_options: tool[key] = image_options[key] @@ -4937,6 +4937,12 @@ async def handle_responses_image( tool["input_image_mask"] = {"image_url": f"data:{mask_mime};base64,{mask_b64}"} guarded_prompt = append_restricted_destination_guard(effective_prompt) + guarded_prompt = ( + f"{guarded_prompt.rstrip()}\n\n" + f"图像数量要求:只调用 image_generation 工具 {parsed.sample_count} 次," + f"每次只生成一张,最终恰好返回 {parsed.sample_count} 张图片;" + "不要额外生成备选方案、变体、草稿或候选图。" + ) if strict_size and size and size != "auto": # The image_generation tool's `size` param is treated as advisory by # some gateways (they "lazily" return a downscaled canvas). Restating @@ -4951,6 +4957,11 @@ async def handle_responses_image( "instructions": RESPONSES_IMAGE_INSTRUCTIONS, **reasoning_options, "stream": True, + # The default compatible gateway rejects the official max_tool_calls + # field. Serial forced calls plus the explicit count instruction is the + # strongest control it accepts; the finalize path also caps overflow. + "parallel_tool_calls": False, + "tool_choice": {"type": "image_generation"}, "input": [ { "role": "user", @@ -4965,33 +4976,9 @@ async def handle_responses_image( } async def _run_responses_upstream() -> dict[str, Any]: - try: - return await client.run_responses( - endpoint_url, api_key, upstream_payload, settings.upstream_user_agent - ) - except APIError as exc: - # `n` is not part of the official image_generation tool schema; strict - # upstreams reject the whole request. Mirror the Images-API fallback: - # retry once without it when the error clearly points at the field. - if ( - "n" in tool - and exc.status == HTTPStatus.BAD_REQUEST - and _error_mentions_sample_count(exc) - ): - log_event( - logger, - logging.WARNING, - "responses_image_sample_count_fallback", - endpoint_url=endpoint_url, - sample_count=parsed.sample_count, - status=exc.status, - message=exc.message, - ) - tool.pop("n", None) - return await client.run_responses( - endpoint_url, api_key, upstream_payload, settings.upstream_user_agent - ) - raise + return await client.run_responses( + endpoint_url, api_key, upstream_payload, settings.upstream_user_agent + ) metadata = request_metadata({**payload, **image_options}, size=size) if itinerary_id: diff --git a/src/picgen/upstream/payload.py b/src/picgen/upstream/payload.py index da7eef8..d6853d8 100644 --- a/src/picgen/upstream/payload.py +++ b/src/picgen/upstream/payload.py @@ -380,6 +380,8 @@ def prepare_image_payload( data_items = upstream.get("data") candidate_items = [item for item in data_items if isinstance(item, dict)] if isinstance(data_items, list) else [] + requested_count = save_context.get("sample_count") + candidate_limit = max(1, int(requested_count)) if requested_count is not None else None # Process candidates independently: a single sample that fails to download or # save must not discard the others. Only surface an error if every candidate # failed (otherwise the customer silently loses good images they asked for). @@ -411,6 +413,16 @@ def prepare_image_payload( ): continue images.append(payload) + if candidate_limit is not None and len(images) >= candidate_limit: + if index + 1 < len(candidate_items): + log_event( + logger, + logging.WARNING, + "upstream_candidate_count_capped", + requested_count=candidate_limit, + upstream_count=len(candidate_items), + ) + break if not images and candidate_errors: raise candidate_errors[0] first_image = images[0] if images else {} diff --git a/static/app.js b/static/app.js index c677133..91ec3e7 100644 --- a/static/app.js +++ b/static/app.js @@ -1,11 +1,11 @@ -import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.57" +import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.58" import { DEFAULT_RESPONSES_MODEL, RESPONSES_MODEL_STORAGE_VERSION, RESPONSES_REASONING_STORAGE_VERSION, migrateStoredResponsesReasoningSettings, migrateStoredResponsesSettings, -} from "./responses-settings.mjs?v=0.1.57" +} from "./responses-settings.mjs?v=0.1.58" 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 5b18a54..79b7871 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 74538bc..206202c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1101,6 +1101,9 @@ def test_itinerary_map_render_saves_integrated_ai_artwork_with_geometry_control( assert fake.run_responses.await_args.args[1] == "sk-test" assert upstream_payload["model"] == "gpt-5.6-sol" assert upstream_payload["reasoning"] == {"effort": "xhigh"} + assert upstream_payload["parallel_tool_calls"] is False + assert "max_tool_calls" not in upstream_payload + assert upstream_payload["tool_choice"] == {"type": "image_generation"} assert upstream_payload["tools"][0]["size"] == "1792x1792" content = upstream_payload["input"][0]["content"] assert content[0]["type"] == "input_text" @@ -2522,9 +2525,77 @@ def test_responses_image_v4_legacy_model_is_normalized_and_saves_streamed_image( assert "图像生成助手" in upstream_payload["instructions"] assert upstream_payload["stream"] is True assert upstream_payload["reasoning"] == {"effort": "xhigh"} + assert upstream_payload["parallel_tool_calls"] is False + assert "max_tool_calls" not in upstream_payload + assert upstream_payload["tool_choice"] == {"type": "image_generation"} assert upstream_payload["tools"] == [{"type": "image_generation", "size": "1088x2240", "quality": "high"}] +def test_responses_image_caps_unexpected_extra_upstream_candidates(make_client, settings_factory): + settings = settings_factory(default_api_key="sk-test") + client, fake, resolved = make_client(settings=settings) + fake.run_responses.return_value = { + "data": [ + {"b64_json": valid_png_b64(10 + index, 20 + index), "revised_prompt": f"candidate-{index}"} + for index in range(6) + ], + "created": 1, + } + + response = client.post( + "/api/responses-image", + json={ + "api_key": "sk-test", + "endpoint_url": "https://sub.tidba.com/v1/responses", + "prompt": "只生成一张旅行海报", + "model": "gpt-5.6-sol", + "mode": "generate", + "sample_count": 1, + "size": "20x40", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["candidate_count"] == 1 + assert len(payload["images"]) == 1 + assert payload["images"][0]["revised_prompt"] == "candidate-0" + assert len([path for path in resolved.outputs_dir.rglob("*") if path.is_file()]) == 1 + upstream_payload = fake.run_responses.await_args.args[2] + assert upstream_payload["parallel_tool_calls"] is False + assert "max_tool_calls" not in upstream_payload + assert upstream_payload["tool_choice"] == {"type": "image_generation"} + assert "n" not in upstream_payload["tools"][0] + assert "只调用 image_generation 工具 1 次" in upstream_payload["input"][0]["content"][0]["text"] + + +def test_responses_image_does_not_retry_rejected_legacy_sample_count_parameter( + make_client, settings_factory +): + settings = settings_factory(default_api_key="sk-test") + client, fake, _ = make_client(settings=settings) + fake.run_responses.side_effect = APIError( + 400, + 'image_generation parameter "n" is not supported', + code="upstream_error", + ) + + response = client.post( + "/api/responses-image", + json={ + "api_key": "sk-test", + "endpoint_url": "https://sub.tidba.com/v1/responses", + "prompt": "只生成一张旅行海报", + "model": "gpt-5.6-sol", + "sample_count": 3, + }, + ) + + assert response.status_code == 400 + assert response.json()["code"] == "upstream_error" + fake.run_responses.assert_awaited_once() + + def test_responses_image_uploads_input_file_and_uses_file_id(make_client, settings_factory): settings = settings_factory(default_api_key="sk-test") client, fake, _ = make_client(settings=settings) @@ -2567,7 +2638,13 @@ def test_responses_image_uploads_ordered_reference_images(make_client, settings_ settings = settings_factory(default_api_key="sk-test") client, fake, _ = make_client(settings=settings) fake.run_file_upload.side_effect = [{"id": "file_style"}, {"id": "file_material"}] - fake.run_responses.return_value = {"data": [{"b64_json": TINY_PNG_B64}], "created": 1} + fake.run_responses.return_value = { + "data": [ + {"b64_json": valid_png_b64(20 + index, 40 + index), "revised_prompt": f"candidate-{index}"} + for index in range(4) + ], + "created": 1, + } response = client.post( "/api/responses-image", @@ -2599,15 +2676,21 @@ def test_responses_image_uploads_ordered_reference_images(make_client, settings_ payload = response.json() assert payload["source_file_ids"] == ["file_style", "file_material"] assert payload["source_image_names"] == ["style.png", "material.png"] + assert payload["candidate_count"] == 3 + assert len(payload["images"]) == 3 assert fake.run_file_upload.await_count == 2 uploaded = [call.args[2]["filename"] for call in fake.run_file_upload.await_args_list] assert uploaded == ["style.png", "material.png"] upstream_payload = fake.run_responses.await_args.args[2] - assert upstream_payload["tools"][0]["n"] == 3 + assert upstream_payload["parallel_tool_calls"] is False + assert "max_tool_calls" not in upstream_payload + assert upstream_payload["tool_choice"] == {"type": "image_generation"} + assert "n" not in upstream_payload["tools"][0] content = upstream_payload["input"][0]["content"] assert content[0]["type"] == "input_text" assert content[0]["text"].startswith("把素材做成模板风格") + assert "只调用 image_generation 工具 3 次" in content[0]["text"] assert "硬性目的地限制" in content[0]["text"] assert content[1:] == [ {"type": "input_image", "file_id": "file_style"}, diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 01ef01c..d32e801 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -232,6 +232,7 @@ def test_placeholder_candidates_are_dropped_and_index_preserved(tmp_path: Path) "data": [ {"revised_prompt": "moderated away"}, # no image at all {"b64_json": TINY_PNG_B64}, + {"b64_json": TINY_PNG_B64, "revised_prompt": "extra valid candidate"}, ], } payload = prepare_image_payload( @@ -239,11 +240,12 @@ def test_placeholder_candidates_are_dropped_and_index_preserved(tmp_path: Path) data_dir=tmp_path, outputs_dir=tmp_path / "outputs", user_agent="UA", - save_context={"mode": "generate"}, + save_context={"mode": "generate", "sample_count": 1}, fetch_remote=None, ) assert payload["candidate_count"] == 1 assert payload["images"][0]["candidate_index"] == 1 + assert len([path for path in (tmp_path / "outputs").rglob("*") if path.is_file()]) == 1 def test_all_placeholder_candidates_yield_zero_count(tmp_path: Path) -> None: diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index 459bbc8..9696ef7 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.57"' in app_js + assert 'from "./responses-settings.mjs?v=0.1.58"' 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.57"' in app_js + assert 'from "./logo-placement.mjs?v=0.1.58"' 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 efe2905..2d09d23 100644 --- a/uv.lock +++ b/uv.lock @@ -299,7 +299,7 @@ wheels = [ [[package]] name = "picgen" -version = "0.1.57" +version = "0.1.58" source = { editable = "." } dependencies = [ { name = "anyio" },