Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 控制台。

Expand All @@ -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 和文案仍会正确分类。

Expand Down Expand Up @@ -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
```

或:
Expand All @@ -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
Expand Down Expand Up @@ -245,7 +247,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到

## 图像通道

PicGen 0.1.59 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:
PicGen 0.1.60 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:

| 用户操作 | 默认接口 | 默认模型 |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
picgen:
image: minorli/picgen:0.1.59
image: minorli/picgen:0.1.60
build:
context: .
ports:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion scripts/docker-build-push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
90 changes: 60 additions & 30 deletions src/picgen/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<!\w)n\s+must\b",
r"(?<!\w)[\"'`](?:n|samples)[\"'`](?!\w)",
r"(?<!\w)(?:sample_count|best_of|num_images|n_images)(?!\w)",
r"\bsample\s+count\b",
)
return any(needle in haystack for needle in needles)
return any(re.search(pattern, haystack) is not None for pattern in patterns)


def _should_retry_without_sample_count(exc: APIError, sample_count: int) -> 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(
Expand Down
4 changes: 2 additions & 2 deletions static/app.js
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 3 additions & 3 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PicGen Console</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23108566'/%3E%3Cpath d='M32 13l16 9v20l-16 9-16-9V22l16-9z' fill='%23ecfdf5'/%3E%3Ccircle cx='32' cy='32' r='8' fill='%23108566'/%3E%3C/svg%3E">
<link rel="stylesheet" href="styles.css?v=0.1.59">
<link rel="stylesheet" href="styles.css?v=0.1.60">
</head>
<body class="auth-gate">
<div id="authOverlay" class="auth-page" aria-hidden="false">
Expand Down Expand Up @@ -1046,7 +1046,7 @@ <h2>管理员高级设置</h2>
</div>

<footer class="system-footer">
<span>PicGen Console v0.1.59</span>
<span>PicGen Console v0.1.60</span>
<span>本地代理保存结果到 <strong>data/outputs</strong></span>
</footer>
</div>
Expand Down Expand Up @@ -1356,6 +1356,6 @@ <h2 id="previewModalTitle">图片预览</h2>
<span id="toastMessageText"></span>
</div>

<script src="app.js?v=0.1.59" type="module"></script>
<script src="app.js?v=0.1.60" type="module"></script>
</body>
</html>
119 changes: 108 additions & 11 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading