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 @@  -## 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 @@