From 0dbc34d13c2ee39e97772e0ad4ad4fa758554379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 16:01:32 +0800 Subject: [PATCH 01/80] =?UTF-8?q?=E6=98=8E=E7=A1=AE=20GPT=20Image=202.5=20?= =?UTF-8?q?provider=20=E8=BE=B9=E7=95=8C=E4=B8=8E=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新业务模型、具体模型与 provider client 领域术语 记录 Tiantoken/VectorEngine 双 client 启动约束 同步开发运维文档中的图片路由与环境变量要求 --- CONTEXT.md | 29 +++++++++++ docs/README.md | 1 + ... Image 2.5模型路由与历史值兼容-2026-09-18.md | 17 ++++++ ...’】GPT Image 2.5 provider边界重构-2026-09-18.md | 31 +++++++++++ ...】GPT Image 2.5 provider边界重构-2026-09-18.md | 52 +++++++++++++++++++ .../shared-memory/decision-log.md | 17 ++++++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- 7 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md create mode 100644 docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md create mode 100644 docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md diff --git a/CONTEXT.md b/CONTEXT.md index 1351b1033..b8e7a8793 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -44,6 +44,35 @@ _Avoid_: 把同一资源的全局元数据和某一次摆放坐标混在同一 由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。 _Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果 +**图片模型历史值与使用端解析**: +图片资源中已持久化的 `gpt-image-2` 是历史业务事实,读回时保持原值;新任务使用业务模型值 `gpt-image-2.5`。当用户基于历史资源再次发起生成或编辑任务时,服务端只在新任务的使用端把历史值解析为当前业务模型,不改写历史资源。provider route 属于服务端执行与审计边界,前端不接收、不持久化、不展示,也不据此分支。 +_Avoid_: 读取数据库时改写历史模型值、把 provider route 暴露为前端模型选项或公开 DTO + +**图片 provider 显式路由**: +api-server 在任务入口按业务语义显式选择具体 provider model name(生成或编辑),并把同一具体名传给图片平台适配器和后台定价解析;图片平台适配器不从参考图数量或前端字段猜测任务。具体 provider model name 只存在于服务端调用、定价配置和审计边界。 +后台管理 Web/API 是明确例外,可以查看和编辑两个具体定价 key;主站普通前端与公开定价 API 不接收这些 key。 +_Avoid_: 让图片适配器隐式猜路由、让主站前端携带 provider model name + +**业务模型**: +面向任务与产品契约的稳定模型值;当前 GPT 图片新任务的业务模型是 `gpt-image-2.5`。业务模型不等同于 provider 的具体计费/请求 model,也不暴露 provider 凭证或 endpoint。 +_Avoid_: 把 provider concrete model 当作前端业务选项、用业务模型值直接推断 provider 凭证 + +**具体模型**: +服务端发送请求和定价使用的 concrete model name。GPT Image 2.5 生成与编辑分别是 `gpt-image-2.5-flare-c` 和 `gpt-image-2.5-sunburst-c`;nanobanana 仍使用 `gemini-3.1-flash-image-preview`。具体模型只在服务端执行、定价和审计边界出现。 +_Avoid_: 把具体模型写入普通前端 DTO、让未知字符串自动选择 provider + +**provider client**: +按具体模型选出的外部图片 provider 连接配置,包含 provider identity、base URL 和 API key;VectorEngine 与 Tiantoken client 共享图片协议执行器,不复制请求/响应业务逻辑。两套 required client 在 api-server 启动时构造。 +_Avoid_: 在首次请求时才创建 client、在 provider client 中复制尺寸/重试/审计逻辑、跨 provider credential fallback + +**历史模型值**: +已持久化的 `gpt-image-2` 或 `gpt-image-2-c` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。 +_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端 + +**GPT Image 2.5 新生成展示名**: +`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。 +_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文 + **系列素材图集生成**: 一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。 _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO diff --git a/docs/README.md b/docs/README.md index 3de2b212c..dfa754898 100644 --- a/docs/README.md +++ b/docs/README.md @@ -70,6 +70,7 @@ - [画板音乐生成入口](./【编辑器】画板音乐生成入口设计-2026-06-18.md):BGM/SFX 共享视图、独立业务规则和当前发布门禁。 - [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md) - [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md) +- [GPT Image 2.5 模型路由与历史值兼容](./adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md) - [编辑器模型定价配置](./【编辑器】模型定价配置管理方案-2026-06-22.md) ## 后端、运维与测试 diff --git a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md new file mode 100644 index 000000000..e87f9338b --- /dev/null +++ b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md @@ -0,0 +1,17 @@ +# GPT Image 2.5 模型路由与历史值兼容 + +状态:accepted + +新任务使用业务模型值 `gpt-image-2.5`,api-server 按任务显式选择具体 model:生成使用 `gpt-image-2.5-flare-c`,编辑使用 `gpt-image-2.5-sunburst-c`。这两个 GPT Image 2.5 model 必须通过启动时构造的 Tiantoken client 发送;Tiantoken client 只读取显式配置的 `TIANTOKEN_BASE_URL`(部署值由环境设置为 `https://api.tiantoken.com`)和独立 `TIANTOKEN_API_KEY`,缺失即阻止 api-server 启动,不得回退到 VectorEngine 或其 API key。 + +图片协议执行逻辑保持 provider-neutral:请求 body、multipart、尺寸约束、重试、响应解码和审计由共享 image executor 承担;VectorEngine 与 Tiantoken client 只提供相同协议所需的 base URL、API key 和 provider identity。platform-image 根据 concrete model 做严格白名单路由:`gpt-image-2.5-flare-c` 与 `gpt-image-2.5-sunburst-c` 走 Tiantoken,`gemini-3.1-flash-image-preview`(nanobanana)走 VectorEngine;未知 model 直接拒绝。已持久化的 `gpt-image-2` / `gpt-image-2-c` 只在新任务提交边界按兼容规则解析为当前 GPT Image 2.5 任务,不改写历史资源,也不进入旧 VectorEngine 图片路由。 + +普通主站前端只接触业务模型和新生成展示名 `GPT Image 2.5`,admin Web/API 可以查看和编辑两个具体定价 key;普通生成即使因参考图使用 edits multipart,仍按生成 concrete model。旧 `gpt-image-2-c` 审计记录原样保留,新代码不再跨模型或跨 provider fallback。 + +## Consequences + +- 定价配置的活动 key 是两个具体 provider model;旧单 key 配置只允许受控 backfill,并留下兼容 TODO。 +- 新任务的同模型重试固定使用 api-server dispatch 的具体 model,不切换到另一个 model。 +- 两套 provider client 在 api-server 启动阶段同时构造;任一 required provider 配置缺失,启动失败而不是延迟到首次图片请求。 +- provider routing 只依据 concrete model 的白名单;provider client 不复制共享协议执行逻辑。 +- 公开资源、`generationInputs` 和普通前端契约不包含具体 provider key;admin 定价管理是明确例外。 diff --git a/docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md b/docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md new file mode 100644 index 000000000..d342b69f5 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md @@ -0,0 +1,31 @@ +# GPT Image 2.5 provider 边界重构实施计划 + +- Version: 2 +- Status: active +- Date: 2026-09-18 +- Parent Milestone: `docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md` + +## 实施边界 + +1. 先抽象 provider-neutral settings/client 与共享图片执行器接口;保留一套 body、multipart、尺寸、retry、响应和 audit 逻辑。 +2. 在 api-server 配置/state 初始化阶段分别构造 VectorEngine 与 Tiantoken client;删除 Tiantoken 对 VectorEngine URL/key 的任何 fallback。 +3. 在 platform-image 建立 concrete model 白名单路由:GPT Image 2.5 → Tiantoken,nanobanana → VectorEngine;legacy/unknown 按合同处理。 +4. 重命名 provider-specific client/build/transport 符号,避免共享逻辑继续伪装成 `vector_engine_*`;仅保留确有 VectorEngine 语义的名称。 +5. 迁移 api-server、Agent、raw edit、角色/图标/UI 入口和测试;核对 pricing/admin/public DTO 可见性。 +6. 删除跨模型/跨 provider fallback 分支,保留同 concrete model retry。 + +## 验证命令 + +- `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` +- `cargo test -p platform-image` +- `cargo test -p platform-editor-agent` +- api-server 定向测试/`cargo check -p api-server` +- `npm run typecheck` +- `npm run check:doc-index` +- `npm run check:encoding` +- `git diff --check` + +## 风险与回滚 + +- 风险:启动阶段依赖变化、历史任务兼容解析遗漏、nanobanana 被误路由到 Tiantoken、provider key 泄露到公开 DTO。 +- 回滚:以 provider-neutral seam、启动配置、model route、调用方迁移四个局部提交边界回滚;不执行数据库历史迁移。 diff --git a/docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md b/docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md new file mode 100644 index 000000000..0e87c4e62 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md @@ -0,0 +1,52 @@ +# GPT Image 2.5 provider 边界重构 + +- Version: 2 +- Status: active +- Date: 2026-09-18 +- Parent Spec: `docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md` + +## 目标 + +在保持图片协议执行逻辑共享的前提下,建立明确的 provider client 边界:GPT Image 2.5 通过 Tiantoken,nanobanana 通过 VectorEngine;路由依据 concrete model 严格白名单决定;两个 client 在 api-server 启动阶段构造。 + +## 范围 + +- `platform-image`:provider-neutral 图片执行器、provider client 注入 seam、concrete model 路由和错误/审计 provider 标识。 +- `api-server`:启动时构造 VectorEngine/Tiantoken 两个 client,分别读取各自环境变量;任务提交边界的历史模型兼容解析。 +- 共享请求、multipart、尺寸、retry、响应和 audit 逻辑保持单一实现。 +- Agent 与其它 server-side 图片调用方迁移到业务模型/concrete model 合同。 +- 定价、公开 DTO、admin DTO 与 provider model 可见性保持既定 ADR 约束。 + +## 现役路由 + +| Concrete model | Provider client | 业务用途 | +| --- | --- | --- | +| `gpt-image-2.5-flare-c` | Tiantoken | Generate,包括带参考图的普通生成 | +| `gpt-image-2.5-sunburst-c` | Tiantoken | Edit,包括快速编辑、原位修改、raw edit | +| `gemini-3.1-flash-image-preview` | VectorEngine | nanobanana 生成/编辑能力 | + +`gpt-image-2` 与 `gpt-image-2-c` 只保留为历史持久化/审计字符串;新任务不得 dispatch 到旧 GPT Image 2 路由。未知 model 直接拒绝。 + +## 必须成立的行为 + +1. api-server 启动时同时构造两个 required provider client;任一对应环境变量缺失,启动失败。 +2. Tiantoken 只读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`;VectorEngine 只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,互不回退。 +3. platform-image 根据 concrete model 选择已注入 client;共享执行器不复制 provider 协议逻辑。 +4. 同 concrete model 可以 retry,但永不跨 concrete model 或跨 provider fallback。 +5. 历史值读取不改写;新任务提交边界将旧值兼容为 GPT Image 2.5 业务任务。 +6. 普通前端不接收 concrete provider model;admin 定价界面可查看和编辑两个具体 pricing key。 + +## 非目标 + +- 不复制两套完整图片 client。 +- 不新增 GPT Image 2 现役 VectorEngine 路由。 +- 不修改历史数据库记录或旧审计字符串。 +- 不把 provider client 选择下沉给普通前端。 + +## 验收证据 + +- provider routing 单元测试覆盖 flare/sunburst/nanobanana/legacy/unknown。 +- 启动配置测试证明两套 client 独立读取环境变量,缺失任一配置即失败且无 VectorEngine/Tiantoken 回退。 +- 请求审计测试证明 provider 与 concrete model 正确记录。 +- platform-image 与 Agent 定向测试通过。 +- api-server 类型/编译检查、前端类型检查、编码/文档/diff 门禁通过。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 01152bcba..0655b4a35 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8840,3 +8840,20 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:Deploy 阶段在远端 dev / release agent 执行,不受该上限约束。调整只动这两处:`systemctl set-property / revert jenkins.service`、`docker update --cpus= gitea-runner` 加同步 compose(备份 `/opt/gitea-stack/compose.yml.bak-<时间戳>`)。 - 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 + +## 2026-09-18 GPT Image 2.5 业务模型与具体 provider 定价路由 + +- **决策**:新任务使用业务模型值 `gpt-image-2.5`;api-server 按任务显式 dispatch 具体模型 `gpt-image-2.5-flare-c`(生成)或 `gpt-image-2.5-sunburst-c`(编辑),并把同一具体 key 交给 `platform-image` 与后台定价解析。普通生成即使因参考图使用 edits multipart,仍按生成 route;同模型重试不跨模型 fallback。 +- **历史兼容**:已持久化 `gpt-image-2` 读回原值不改写;基于旧资源发起新任务时,在提交边界解析为 `gpt-image-2.5`,新任务/新产物按新业务值和当前 task price 处理。旧 `gpt-image-2-c` 仅保留历史审计,不再作为 fallback 或业务模型。 +- **可见性**:普通主站前端和公开定价 API 不接收具体 provider key;新生成 UI label 为 `GPT Image 2.5`,历史资源/旧编辑上下文不扩散该 label。admin Web/API 是明确例外,可查看和编辑两个具体定价 key。旧单 key 定价配置允许受控 backfill,并加 compatibility TODO。 +- **关联 ADR**:[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)。 + +- **补充**:GPT Image 2.5 的两个具体模型通过显式 `TIANTOKEN_BASE_URL` 与独立 `TIANTOKEN_API_KEY` 发送;环境变量缺失时必须失败,禁止使用 VectorEngine 配置或 API key 回退。 + +## 2026-09-18 GPT Image 2.5 provider-neutral 执行器与双 client 启动边界 + +- **决策**:图片协议执行逻辑保持单一共享实现;只抽出 provider client 的 identity、base URL、API key 和 client 构造,通过依赖注入复用请求 body、multipart、尺寸、retry、响应和 audit。 +- **路由**:`gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 走 Tiantoken;`gemini-3.1-flash-image-preview`(nanobanana)走 VectorEngine;`gpt-image-2` / `gpt-image-2-c` 仅是历史字符串,新任务不再进入旧 GPT Image 2 路由;未知 model 拒绝。 +- **启动**:api-server 启动时同时构造 VectorEngine 与 Tiantoken 两个 required client;各自只读取自己的环境变量,任一配置缺失即启动失败,不延迟到首次请求。 +- **重试**:只在同一个 concrete model 内 retry,禁止跨 model、跨 provider fallback。 +- **关联文档**:[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`](../plans/【里程碑】GPT%20Image%202.5%20provider边界重构-2026-09-18.md)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0d1af4ab8..e50e5c74c 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -226,7 +226,7 @@ spacetime sql "SELECT * FROM runtime_setting LIMIT 1" --server http:/ 本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.8.3`,CLI / standalone commit 固定核对为 `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。若版本或 commit 错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;其它版本可执行 `spacetime version install && spacetime version use `,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.8.3 + 8e410d28...` 同时匹配;旧版本或旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。 -本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine 配置仅保留给 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 Tiantoken provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 +本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git;同时确认 VectorEngine 的 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 已配置,因为两个图片 client 都在 api-server 启动时构造,任一缺失都会阻止启动。VectorEngine 配置仍保留给 nanobanana 与 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。新生成任务使用业务模型 `gpt-image-2.5`,api-server 显式分派 `gpt-image-2.5-flare-c`(generate)或 `gpt-image-2.5-sunburst-c`(edit);已持久化的 `gpt-image-2` / `gpt-image-2-c` 只在提交边界按兼容规则解析,不改写历史值,也不回退到旧 GPT Image 2 路由。图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 编辑器 ElevenLabs 音效生成只从服务端读取 `ELEVENLABS_BASE_URL`、`ELEVENLABS_API_KEY` 和 `ELEVENLABS_REQUEST_TIMEOUT_MS`,timeout 默认 `180000ms`;base URL 或 Key 缺失时失败关闭,不回退 Vidu。生产 API 与 external-generation worker 通过共享 API env 取得同一配置,模板见 `deploy/env/api-server.env.example`;Key 不得进入 Web/Vite 环境、命令参数、日志、fixture 或仓库。普通测试只使用 loopback mock,禁止把真实付费请求作为 T3 自动验收。 -- 2.52.0 From c34d24c2c84c4a857ebd2b51d04e9a7ebf167ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 16:03:29 +0800 Subject: [PATCH 02/80] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=9B=BE=E7=89=87=20pr?= =?UTF-8?q?ovider=20=E4=B8=AD=E7=AB=8B=E6=89=A7=E8=A1=8C=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 platform-image 图片协议从 vector_engine 目录迁移到 image_provider 引入 ImageProviderClient 与 VectorEngine/Tiantoken provider 类型 按 concrete model 白名单拒绝未知模型并移除跨模型回退 --- .../audit.rs | 0 .../budget.rs | 2 +- .../client.rs | 431 +++++++----------- .../constants.rs | 8 +- .../curl_transport.rs | 54 +-- .../error.rs | 0 .../image_source.rs | 0 .../platform-image/src/image_provider/mod.rs | 44 ++ .../payload.rs | 0 .../raw_edit.rs | 30 +- .../request.rs | 65 ++- .../response.rs | 18 +- .../tests.rs | 22 +- .../src/image_provider/transport.rs | 30 ++ .../src/image_provider/types.rs | 85 ++++ .../{vector_engine => image_provider}/util.rs | 0 server-rs/crates/platform-image/src/lib.rs | 34 +- .../platform-image/src/vector_engine/mod.rs | 42 -- .../src/vector_engine/transport.rs | 19 - .../platform-image/src/vector_engine/types.rs | 31 -- .../{vector_engine.rs => image_provider.rs} | 250 +++++----- 21 files changed, 563 insertions(+), 602 deletions(-) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/audit.rs (100%) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/budget.rs (98%) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/client.rs (72%) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/constants.rs (56%) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/curl_transport.rs (88%) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/error.rs (100%) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/image_source.rs (100%) create mode 100644 server-rs/crates/platform-image/src/image_provider/mod.rs rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/payload.rs (100%) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/raw_edit.rs (95%) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/request.rs (84%) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/response.rs (93%) rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/tests.rs (92%) create mode 100644 server-rs/crates/platform-image/src/image_provider/transport.rs create mode 100644 server-rs/crates/platform-image/src/image_provider/types.rs rename server-rs/crates/platform-image/src/{vector_engine => image_provider}/util.rs (100%) delete mode 100644 server-rs/crates/platform-image/src/vector_engine/mod.rs delete mode 100644 server-rs/crates/platform-image/src/vector_engine/transport.rs delete mode 100644 server-rs/crates/platform-image/src/vector_engine/types.rs rename server-rs/crates/platform-image/tests/{vector_engine.rs => image_provider.rs} (72%) diff --git a/server-rs/crates/platform-image/src/vector_engine/audit.rs b/server-rs/crates/platform-image/src/image_provider/audit.rs similarity index 100% rename from server-rs/crates/platform-image/src/vector_engine/audit.rs rename to server-rs/crates/platform-image/src/image_provider/audit.rs diff --git a/server-rs/crates/platform-image/src/vector_engine/budget.rs b/server-rs/crates/platform-image/src/image_provider/budget.rs similarity index 98% rename from server-rs/crates/platform-image/src/vector_engine/budget.rs rename to server-rs/crates/platform-image/src/image_provider/budget.rs index 02f099344..75831c03b 100644 --- a/server-rs/crates/platform-image/src/vector_engine/budget.rs +++ b/server-rs/crates/platform-image/src/image_provider/budget.rs @@ -86,7 +86,7 @@ pub(crate) fn request_budget_exhausted_error( reference_image_count, image_model, operation, - "VectorEngine 图片请求执行预算已耗尽" + "ImageProvider 图片请求执行预算已耗尽" ); PlatformImageError::Request { provider: VECTOR_ENGINE_PROVIDER, diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/image_provider/client.rs similarity index 72% rename from server-rs/crates/platform-image/src/vector_engine/client.rs rename to server-rs/crates/platform-image/src/image_provider/client.rs index 24b10196a..9987f97bc 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/image_provider/client.rs @@ -10,31 +10,30 @@ use super::{ retry_delay_fits_request_deadline, }, constants::{ - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, + GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, + GPT_IMAGE_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER, }, curl_transport::{ - map_curl_error, send_vector_engine_json_request_with_curl, - send_vector_engine_multipart_edit_request_with_curl, + map_curl_error, send_image_json_request_with_curl, + send_image_multipart_edit_request_with_curl, }, error::PlatformImageError, image_source::resolve_reference_images, request::{ - build_vector_engine_image_edit_request_log_params, - build_vector_engine_image_request_body_with_model, - build_vector_engine_nanobanana_generate_content_request_body, is_gpt_image_2_family_model, - normalize_image_size_for_model, normalize_vector_engine_image_model, - vector_engine_images_edit_url, vector_engine_images_generation_url, - vector_engine_nanobanana_generate_content_url, + build_image_edit_request_log_params, build_image_request_body_with_model, + build_nanobanana_generate_content_request_body, images_edit_url, images_generation_url, + nanobanana_generate_content_url, normalize_image_model, normalize_image_size_for_model, + resolve_image_provider, }, - response::handle_vector_engine_response, - types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, + response::handle_image_response, + types::{GeneratedImages, ImageProviderSettings, ReferenceImage}, util::truncate_raw, }; -pub async fn create_vector_engine_image_generation( +pub async fn create_image_generation( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, @@ -42,10 +41,10 @@ pub async fn create_vector_engine_image_generation( reference_images: &[String], failure_context: &str, ) -> Result { - create_vector_engine_image_generation_with_model( + create_image_generation_with_model( http_client, settings, - GPT_IMAGE_2_MODEL, + GPT_IMAGE_2_5_GENERATION_MODEL, prompt, negative_prompt, size, @@ -57,9 +56,9 @@ pub async fn create_vector_engine_image_generation( } #[allow(clippy::too_many_arguments)] -pub async fn create_vector_engine_image_generation_with_model( +pub async fn create_image_generation_with_model( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, model: &str, prompt: &str, negative_prompt: Option<&str>, @@ -68,7 +67,13 @@ pub async fn create_vector_engine_image_generation_with_model( reference_images: &[String], failure_context: &str, ) -> Result { - let requested_model = normalize_vector_engine_image_model(model); + let requested_model = normalize_image_model(model); + resolve_image_provider(requested_model).map_err(|message| { + PlatformImageError::InvalidRequest { + provider: settings.provider.as_str(), + message: format!("{failure_context}:{message}:{requested_model}"), + } + })?; if !reference_images.is_empty() { let resolved_references = resolve_reference_images( http_client, @@ -77,7 +82,7 @@ pub async fn create_vector_engine_image_generation_with_model( settings.request_deadline, ) .await?; - return create_vector_engine_image_edit_with_references_and_model( + return create_image_edit_with_references_and_model( http_client, settings, requested_model, @@ -91,14 +96,14 @@ pub async fn create_vector_engine_image_generation_with_model( .await; } - let request_url = vector_engine_images_generation_url(settings); + let request_url = images_generation_url(settings); let normalized_size = normalize_image_size_for_model(requested_model, size); let started_at = std::time::Instant::now(); - let mut upstream_model = preferred_vector_engine_upstream_model(requested_model); + let mut upstream_model = preferred_image_upstream_model(requested_model); let mut recovered_failure_audits = Vec::new(); let mut attempt = 1; loop { - let request_body = build_vector_engine_image_request_body_with_model( + let request_body = build_image_request_body_with_model( upstream_model, prompt, negative_prompt, @@ -109,11 +114,11 @@ pub async fn create_vector_engine_image_generation_with_model( let Some(attempt_timeout_ms) = effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) else { - return Err(finish_vector_engine_model_fallback_error( + return Err(finish_image_model_fallback_error( request_budget_exhausted_error( request_url.as_str(), failure_context, - auditable_vector_engine_image_model(upstream_model), + auditable_image_model(upstream_model), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), Some(reference_images.len()), @@ -121,7 +126,7 @@ pub async fn create_vector_engine_image_generation_with_model( &mut recovered_failure_audits, )); }; - let response = match send_vector_engine_json_request_with_curl( + let response = match send_image_json_request_with_curl( request_url.as_str(), settings.api_key.as_str(), &request_body, @@ -130,18 +135,18 @@ pub async fn create_vector_engine_image_generation_with_model( .await { Ok(response) => { - if should_retry_vector_engine_upstream_response( + if should_retry_image_upstream_response( response.status, response.body.as_str(), attempt, ) { let primary_error = if upstream_model == GPT_IMAGE_2_MODEL { - handle_vector_engine_response( + handle_image_response( http_client, request_url.as_str(), response.status, response.body.as_str(), - auditable_vector_engine_image_model(upstream_model), + auditable_image_model(upstream_model), failure_context, started_at.elapsed().as_millis() as u64, Some(prompt.chars().count()), @@ -165,7 +170,7 @@ pub async fn create_vector_engine_image_generation_with_model( ) }) { let error = primary_error.expect("primary error checked above"); - record_vector_engine_model_fallback( + record_image_model_fallback( "generation", request_url.as_str(), upstream_model, @@ -180,7 +185,7 @@ pub async fn create_vector_engine_image_generation_with_model( attempt += 1; continue; } - if retry_vector_engine_upstream_status_after_delay( + if retry_image_upstream_status_after_delay( "generation", request_url.as_str(), attempt, @@ -201,8 +206,8 @@ pub async fn create_vector_engine_image_generation_with_model( response } Err(error) => { - if should_retry_vector_engine_curl_send_error(&error, attempt) { - if retry_vector_engine_send_after_delay( + if should_retry_image_curl_send_error(&error, attempt) { + if retry_image_send_after_delay( "generation", request_url.as_str(), "request_send", @@ -224,12 +229,12 @@ pub async fn create_vector_engine_image_generation_with_model( continue; } } - return Err(finish_vector_engine_model_fallback_error( + return Err(finish_image_model_fallback_error( map_curl_error( format!("{failure_context}:创建图片生成任务失败").as_str(), request_url.as_str(), "request_send", - auditable_vector_engine_image_model(upstream_model), + auditable_image_model(upstream_model), error, started_at.elapsed().as_millis() as u64, Some(prompt.chars().count()), @@ -253,15 +258,15 @@ pub async fn create_vector_engine_image_generation_with_model( attempt, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine 图片生成 HTTP 返回" + "ImageProvider 图片生成 HTTP 返回" ); let response_text = response.body; - match handle_vector_engine_response( + match handle_image_response( http_client, request_url.as_str(), response_status, response_text.as_str(), - auditable_vector_engine_image_model(upstream_model), + auditable_image_model(upstream_model), failure_context, started_at.elapsed().as_millis() as u64, Some(prompt.chars().count()), @@ -287,7 +292,7 @@ pub async fn create_vector_engine_image_generation_with_model( settings, ) => { - record_vector_engine_model_fallback( + record_image_model_fallback( "generation", request_url.as_str(), upstream_model, @@ -302,7 +307,7 @@ pub async fn create_vector_engine_image_generation_with_model( attempt += 1; } Err(error) => { - return Err(finish_vector_engine_model_fallback_error( + return Err(finish_image_model_fallback_error( error, &mut recovered_failure_audits, )); @@ -312,9 +317,9 @@ pub async fn create_vector_engine_image_generation_with_model( } #[allow(clippy::too_many_arguments)] -pub async fn create_vector_engine_nanobanana_generate_content( +pub async fn create_nanobanana_generate_content( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, model: &str, prompt: &str, negative_prompt: Option<&str>, @@ -323,6 +328,11 @@ pub async fn create_vector_engine_nanobanana_generate_content( reference_images: &[ReferenceImage], failure_context: &str, ) -> Result { + let model = normalize_image_model(model); + resolve_image_provider(model).map_err(|message| PlatformImageError::InvalidRequest { + provider: settings.provider.as_str(), + message: format!("{failure_context}:{message}:{model}"), + })?; if reference_images.len() > VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES { return Err(PlatformImageError::InvalidRequest { provider: VECTOR_ENGINE_PROVIDER, @@ -332,9 +342,8 @@ pub async fn create_vector_engine_nanobanana_generate_content( ), }); } - let model = normalize_vector_engine_image_model(model); - let request_url = vector_engine_nanobanana_generate_content_url(settings, model); - let request_body = build_vector_engine_nanobanana_generate_content_request_body( + let request_url = nanobanana_generate_content_url(settings, model); + let request_body = build_nanobanana_generate_content_request_body( prompt, negative_prompt, aspect_ratio, @@ -373,7 +382,7 @@ pub async fn create_vector_engine_nanobanana_generate_content( Some(reference_image_count), )); }; - match send_vector_engine_json_request_with_curl( + match send_image_json_request_with_curl( request_url.as_str(), settings.api_key.as_str(), &request_body, @@ -382,12 +391,12 @@ pub async fn create_vector_engine_nanobanana_generate_content( .await { Ok(response) => { - if should_retry_vector_engine_upstream_response( + if should_retry_image_upstream_response( response.status, response.body.as_str(), attempt, ) { - if retry_vector_engine_upstream_status_after_delay( + if retry_image_upstream_status_after_delay( "nanobanana_generate_content", request_url.as_str(), attempt, @@ -408,8 +417,8 @@ pub async fn create_vector_engine_nanobanana_generate_content( break response; } Err(error) => { - if should_retry_vector_engine_curl_send_error(&error, attempt) { - if retry_vector_engine_send_after_delay( + if should_retry_image_curl_send_error(&error, attempt) { + if retry_image_send_after_delay( "nanobanana_generate_content", request_url.as_str(), "request_send", @@ -460,10 +469,10 @@ pub async fn create_vector_engine_nanobanana_generate_content( attempt, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine nanobanana2 图片生成 HTTP 返回" + "ImageProvider nanobanana2 图片生成 HTTP 返回" ); let response_text = response.body; - handle_vector_engine_response( + handle_image_response( http_client, request_url.as_str(), response_status, @@ -480,16 +489,16 @@ pub async fn create_vector_engine_nanobanana_generate_content( .await } -pub async fn create_vector_engine_image_edit( +pub async fn create_image_edit( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, reference_image: &ReferenceImage, failure_context: &str, ) -> Result { - create_vector_engine_image_edit_with_references( + create_image_edit_with_references( http_client, settings, prompt, @@ -502,9 +511,9 @@ pub async fn create_vector_engine_image_edit( .await } -pub async fn create_vector_engine_image_edit_with_references( +pub async fn create_image_edit_with_references( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, @@ -512,10 +521,10 @@ pub async fn create_vector_engine_image_edit_with_references( reference_images: &[ReferenceImage], failure_context: &str, ) -> Result { - create_vector_engine_image_edit_with_references_and_model( + create_image_edit_with_references_and_model( http_client, settings, - GPT_IMAGE_2_MODEL, + GPT_IMAGE_2_5_EDIT_MODEL, prompt, negative_prompt, size, @@ -527,9 +536,9 @@ pub async fn create_vector_engine_image_edit_with_references( } #[allow(clippy::too_many_arguments)] -pub async fn create_vector_engine_image_edit_with_references_and_model( +pub async fn create_image_edit_with_references_and_model( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, model: &str, prompt: &str, negative_prompt: Option<&str>, @@ -538,7 +547,13 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( reference_images: &[ReferenceImage], failure_context: &str, ) -> Result { - let requested_model = normalize_vector_engine_image_model(model); + let requested_model = normalize_image_model(model); + resolve_image_provider(requested_model).map_err(|message| { + PlatformImageError::InvalidRequest { + provider: settings.provider.as_str(), + message: format!("{failure_context}:{message}:{requested_model}"), + } + })?; if reference_images.is_empty() { return Err(PlatformImageError::InvalidRequest { provider: VECTOR_ENGINE_PROVIDER, @@ -555,18 +570,18 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( }); } - let request_url = vector_engine_images_edit_url(settings); + let request_url = images_edit_url(settings); let normalized_size = normalize_image_size_for_model(requested_model, size); let reference_image_count = reference_images.len(); let reference_image_bytes_total: usize = reference_images.iter().map(|image| image.bytes.len()).sum(); let started_at = std::time::Instant::now(); - let mut upstream_model = preferred_vector_engine_upstream_model(requested_model); + let mut upstream_model = preferred_image_upstream_model(requested_model); let mut recovered_failure_audits = Vec::new(); let mut attempt = 1; loop { - let request_params = build_vector_engine_image_edit_request_log_params( + let request_params = build_image_edit_request_log_params( upstream_model, prompt, negative_prompt, @@ -594,16 +609,16 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( request_params = %request_params, attempt, failure_context, - "VectorEngine 图片编辑请求参数" + "ImageProvider 图片编辑请求参数" ); let Some(attempt_timeout_ms) = effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) else { - return Err(finish_vector_engine_model_fallback_error( + return Err(finish_image_model_fallback_error( request_budget_exhausted_error( request_url.as_str(), failure_context, - auditable_vector_engine_image_model(upstream_model), + auditable_image_model(upstream_model), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), Some(reference_image_count), @@ -611,7 +626,7 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( &mut recovered_failure_audits, )); }; - let response = match send_vector_engine_multipart_edit_request_with_curl( + let response = match send_image_multipart_edit_request_with_curl( request_url.as_str(), settings.api_key.as_str(), upstream_model, @@ -625,18 +640,18 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( .await { Ok(response) => { - if should_retry_vector_engine_upstream_response( + if should_retry_image_upstream_response( response.status, response.body.as_str(), attempt, ) { let primary_error = if upstream_model == GPT_IMAGE_2_MODEL { - handle_vector_engine_response( + handle_image_response( http_client, request_url.as_str(), response.status, response.body.as_str(), - auditable_vector_engine_image_model(upstream_model), + auditable_image_model(upstream_model), failure_context, started_at.elapsed().as_millis() as u64, Some(prompt.chars().count()), @@ -660,7 +675,7 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( ) }) { let error = primary_error.expect("primary error checked above"); - record_vector_engine_model_fallback( + record_image_model_fallback( "edit", request_url.as_str(), upstream_model, @@ -675,7 +690,7 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( attempt += 1; continue; } - if retry_vector_engine_upstream_status_after_delay( + if retry_image_upstream_status_after_delay( "edit", request_url.as_str(), attempt, @@ -696,8 +711,8 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( response } Err(error) => { - if should_retry_vector_engine_curl_send_error(&error, attempt) { - if retry_vector_engine_send_after_delay( + if should_retry_image_curl_send_error(&error, attempt) { + if retry_image_send_after_delay( "edit", request_url.as_str(), "request_send", @@ -719,12 +734,12 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( continue; } } - return Err(finish_vector_engine_model_fallback_error( + return Err(finish_image_model_fallback_error( map_curl_error( format!("{failure_context}:创建图片编辑任务失败").as_str(), request_url.as_str(), "request_send", - auditable_vector_engine_image_model(upstream_model), + auditable_image_model(upstream_model), error, started_at.elapsed().as_millis() as u64, Some(prompt.chars().count()), @@ -750,15 +765,15 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( attempt, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine 图片编辑 HTTP 返回" + "ImageProvider 图片编辑 HTTP 返回" ); let response_text = response.body; - match handle_vector_engine_response( + match handle_image_response( http_client, request_url.as_str(), response_status, response_text.as_str(), - auditable_vector_engine_image_model(upstream_model), + auditable_image_model(upstream_model), failure_context, started_at.elapsed().as_millis() as u64, Some(prompt.chars().count()), @@ -784,7 +799,7 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( settings, ) => { - record_vector_engine_model_fallback( + record_image_model_fallback( "edit", request_url.as_str(), upstream_model, @@ -799,7 +814,7 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( attempt += 1; } Err(error) => { - return Err(finish_vector_engine_model_fallback_error( + return Err(finish_image_model_fallback_error( error, &mut recovered_failure_audits, )); @@ -808,22 +823,21 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( } } -fn preferred_vector_engine_upstream_model(requested_model: &str) -> &str { - if is_gpt_image_2_family_model(requested_model) { - GPT_IMAGE_2_MODEL - } else { - requested_model - } +fn preferred_image_upstream_model(requested_model: &str) -> &str { + // Provider routing is selected by api-server at the task boundary. Keep the + // concrete value intact here so retries stay on the same model and legacy + // persisted values are never silently rewritten. + requested_model } -fn finish_vector_engine_model_fallback_error( +fn finish_image_model_fallback_error( error: PlatformImageError, recovered_failure_audits: &mut Vec, ) -> PlatformImageError { error.with_recovered_failure_audits(std::mem::take(recovered_failure_audits)) } -fn auditable_vector_engine_image_model(model: &str) -> Option<&'static str> { +fn auditable_image_model(model: &str) -> Option<&'static str> { match model { GPT_IMAGE_2_C_MODEL => Some(GPT_IMAGE_2_C_MODEL), GPT_IMAGE_2_MODEL => Some(GPT_IMAGE_2_MODEL), @@ -836,74 +850,15 @@ fn should_fallback_to_gpt_image_2_c( upstream_model: &str, attempt: u32, error: &PlatformImageError, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, ) -> bool { - if !is_gpt_image_2_family_model(requested_model) - || upstream_model != GPT_IMAGE_2_MODEL - || attempt >= VECTOR_ENGINE_SEND_MAX_ATTEMPTS - || effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) - .is_none() - { - return false; - } - - match error { - PlatformImageError::Upstream { - upstream_status, - message, - raw_excerpt, - .. - } => match *upstream_status { - 408 => true, - 429 => !contains_vector_engine_content_rejection(message, raw_excerpt), - status if status >= 500 => true, - 400 | 404 | 422 => contains_vector_engine_model_unavailable(message, raw_excerpt), - _ => false, - }, - PlatformImageError::ResponseParse { - message, - raw_excerpt, - .. - } => !contains_vector_engine_content_rejection(message, raw_excerpt), - PlatformImageError::MissingImage { message, audit, .. } => { - let raw_excerpt = audit - .as_ref() - .and_then(|audit| audit.raw_excerpt.as_deref()) - .unwrap_or_default(); - !contains_vector_engine_content_rejection(message, raw_excerpt) - } - PlatformImageError::InvalidConfig { .. } - | PlatformImageError::InvalidRequest { .. } - | PlatformImageError::Request { .. } - | PlatformImageError::FallbackFailed { .. } => false, - } + // Compatibility function retained for old call sites/tests. New dispatch + // never crosses models; retries are handled by the same-model loop only. + let _ = (requested_model, upstream_model, attempt, error, settings); + false } -fn contains_vector_engine_model_unavailable(message: &str, raw_excerpt: &str) -> bool { - let haystack = format!("{message}\n{raw_excerpt}").to_ascii_lowercase(); - let mentions_model = haystack.contains("model") - || haystack.contains("模型") - || haystack.contains(GPT_IMAGE_2_MODEL) - || haystack.contains(GPT_IMAGE_2_C_MODEL); - let unavailable = [ - "not found", - "not supported", - "unsupported", - "unavailable", - "does not exist", - "invalid model", - "unknown model", - "不存在", - "不支持", - "不可用", - "未开通", - ] - .iter() - .any(|marker| haystack.contains(marker)); - mentions_model && unavailable -} - -fn contains_vector_engine_content_rejection(message: &str, raw_excerpt: &str) -> bool { +fn contains_image_provider_content_rejection(message: &str, raw_excerpt: &str) -> bool { let haystack = format!("{message}\n{raw_excerpt}").to_ascii_lowercase(); [ "invalid_prompt", @@ -928,7 +883,7 @@ fn contains_vector_engine_content_rejection(message: &str, raw_excerpt: &str) -> .any(|marker| haystack.contains(marker)) } -fn record_vector_engine_model_fallback( +fn record_image_model_fallback( request_kind: &'static str, request_url: &str, from_model: &str, @@ -949,26 +904,26 @@ fn record_vector_engine_model_fallback( failure_stage = audit.map(|audit| audit.failure_stage).unwrap_or("unknown"), status = audit.and_then(|audit| audit.status_code).unwrap_or_default(), error = %error.message(), - "VectorEngine 首选图片模型失败,切换兼容模型" + "ImageProvider 首选图片模型失败,切换兼容模型" ); } -fn should_retry_vector_engine_curl_send_error( - error: &super::curl_transport::VectorEngineCurlError, +fn should_retry_image_curl_send_error( + error: &super::curl_transport::ImageProviderCurlError, attempt: u32, ) -> bool { attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (error.is_timeout() || error.is_connect() || error.is_transient_transport()) } -fn should_retry_vector_engine_upstream_response(status: u16, raw_body: &str, attempt: u32) -> bool { +fn should_retry_image_upstream_response(status: u16, raw_body: &str, attempt: u32) -> bool { attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (status == 408 || status >= 500 - || (status == 429 && !contains_vector_engine_content_rejection("", raw_body))) + || (status == 429 && !contains_image_provider_content_rejection("", raw_body))) } -async fn retry_vector_engine_send_after_delay( +async fn retry_image_send_after_delay( request_kind: &'static str, request_url: &str, failure_stage: &'static str, @@ -984,7 +939,8 @@ async fn retry_vector_engine_send_after_delay( request_params: Option<&serde_json::Value>, request_deadline: Option, ) -> bool { - let delay_ms = vector_engine_send_retry_delay_ms(attempt, vector_engine_send_retry_jitter_ms()); + let delay_ms = + image_provider_send_retry_delay_ms(attempt, image_provider_send_retry_jitter_ms()); if !retry_delay_fits_request_deadline(request_deadline, delay_ms) { tracing::warn!( provider = VECTOR_ENGINE_PROVIDER, @@ -1003,7 +959,7 @@ async fn retry_vector_engine_send_after_delay( elapsed_ms, prompt_chars, reference_image_count, - "VectorEngine 图片请求剩余预算不足,停止重试" + "ImageProvider 图片请求剩余预算不足,停止重试" ); return false; } @@ -1027,13 +983,13 @@ async fn retry_vector_engine_send_after_delay( request_params = %request_params .map(|value| value.to_string()) .unwrap_or_default(), - "VectorEngine 图片请求发送失败,准备重试" + "ImageProvider 图片请求发送失败,准备重试" ); tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; true } -async fn retry_vector_engine_upstream_status_after_delay( +async fn retry_image_upstream_status_after_delay( request_kind: &'static str, request_url: &str, attempt: u32, @@ -1045,7 +1001,8 @@ async fn retry_vector_engine_upstream_status_after_delay( request_params: Option<&serde_json::Value>, request_deadline: Option, ) -> bool { - let delay_ms = vector_engine_send_retry_delay_ms(attempt, vector_engine_send_retry_jitter_ms()); + let delay_ms = + image_provider_send_retry_delay_ms(attempt, image_provider_send_retry_jitter_ms()); if !retry_delay_fits_request_deadline(request_deadline, delay_ms) { tracing::warn!( provider = VECTOR_ENGINE_PROVIDER, @@ -1060,7 +1017,7 @@ async fn retry_vector_engine_upstream_status_after_delay( elapsed_ms, prompt_chars, reference_image_count, - "VectorEngine 图片请求剩余预算不足,停止上游状态重试" + "ImageProvider 图片请求剩余预算不足,停止上游状态重试" ); return false; } @@ -1081,19 +1038,19 @@ async fn retry_vector_engine_upstream_status_after_delay( request_params = %request_params .map(|value| value.to_string()) .unwrap_or_default(), - "VectorEngine 图片上游状态可重试,准备重试" + "ImageProvider 图片上游状态可重试,准备重试" ); tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; true } -fn vector_engine_send_retry_delay_ms(attempt: u32, jitter_ms: u64) -> u64 { +fn image_provider_send_retry_delay_ms(attempt: u32, jitter_ms: u64) -> u64 { let exponential_factor = 1_u64 << attempt.saturating_sub(1).min(10); let bounded_jitter_ms = jitter_ms.min(VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS); VECTOR_ENGINE_SEND_RETRY_BASE_DELAY_MS * exponential_factor + bounded_jitter_ms } -fn vector_engine_send_retry_jitter_ms() -> u64 { +fn image_provider_send_retry_jitter_ms() -> u64 { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.subsec_nanos()) @@ -1103,6 +1060,7 @@ fn vector_engine_send_retry_jitter_ms() -> u64 { #[cfg(test)] mod tests { + use super::super::types::ImageProvider; use super::*; fn reference_image(index: usize) -> ReferenceImage { @@ -1113,8 +1071,9 @@ mod tests { } } - fn test_settings() -> VectorEngineImageSettings { - VectorEngineImageSettings { + fn test_settings() -> ImageProviderSettings { + ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: "http://127.0.0.1:9".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, @@ -1125,7 +1084,7 @@ mod tests { #[tokio::test] async fn gpt_image_edit_rejects_six_references_before_network_send() { let references = (0..6).map(reference_image).collect::>(); - let error = create_vector_engine_image_edit_with_references_and_model( + let error = create_image_edit_with_references_and_model( &reqwest::Client::new(), &test_settings(), GPT_IMAGE_2_MODEL, @@ -1145,7 +1104,7 @@ mod tests { #[tokio::test] async fn nanobanana_rejects_fifteen_references_before_network_send() { let references = (0..15).map(reference_image).collect::>(); - let error = create_vector_engine_nanobanana_generate_content( + let error = create_nanobanana_generate_content( &reqwest::Client::new(), &test_settings(), super::super::constants::NANOBANANA_2_MODEL, @@ -1164,13 +1123,14 @@ mod tests { #[tokio::test] async fn expired_deadline_stops_generation_before_network_send() { - let settings = VectorEngineImageSettings { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: "http://127.0.0.1:9".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, request_deadline: Some(Instant::now()), }; - let error = create_vector_engine_image_generation( + let error = create_image_generation( &reqwest::Client::new(), &settings, "测试提示词", @@ -1197,132 +1157,77 @@ mod tests { } #[test] - fn vector_engine_send_retry_policy_allows_four_retries_before_final_attempt() { + fn image_provider_send_retry_policy_allows_four_retries_before_final_attempt() { assert_eq!(VECTOR_ENGINE_SEND_MAX_ATTEMPTS, 5); } #[test] - fn vector_engine_send_retry_policy_treats_ssl_reset_as_transient_transport() { - let error = super::super::curl_transport::VectorEngineCurlError::Curl(curl::Error::new(35)); + fn image_provider_send_retry_policy_treats_ssl_reset_as_transient_transport() { + let error = + super::super::curl_transport::ImageProviderCurlError::Curl(curl::Error::new(35)); assert!(error.is_transient_transport()); - assert!(should_retry_vector_engine_curl_send_error(&error, 1)); - assert!(!should_retry_vector_engine_curl_send_error(&error, 5)); + assert!(should_retry_image_curl_send_error(&error, 1)); + assert!(!should_retry_image_curl_send_error(&error, 5)); } #[test] - fn vector_engine_send_retry_policy_treats_recv_eof_as_transient_transport() { - let error = super::super::curl_transport::VectorEngineCurlError::Curl(curl::Error::new(56)); + fn image_provider_send_retry_policy_treats_recv_eof_as_transient_transport() { + let error = + super::super::curl_transport::ImageProviderCurlError::Curl(curl::Error::new(56)); assert!(error.is_transient_transport()); - assert!(should_retry_vector_engine_curl_send_error(&error, 1)); - assert!(!should_retry_vector_engine_curl_send_error(&error, 5)); + assert!(should_retry_image_curl_send_error(&error, 1)); + assert!(!should_retry_image_curl_send_error(&error, 5)); } #[test] - fn vector_engine_send_retry_policy_treats_upstream_502_as_retryable() { - assert!(should_retry_vector_engine_upstream_response(502, "", 1)); - assert!(should_retry_vector_engine_upstream_response(429, "", 1)); - assert!(should_retry_vector_engine_upstream_response( + fn image_provider_send_retry_policy_treats_upstream_502_as_retryable() { + assert!(should_retry_image_upstream_response(502, "", 1)); + assert!(should_retry_image_upstream_response(429, "", 1)); + assert!(should_retry_image_upstream_response( 429, "request rejected due to rate limit", 1, )); - assert!(should_retry_vector_engine_upstream_response(408, "", 1)); - assert!(!should_retry_vector_engine_upstream_response( + assert!(should_retry_image_upstream_response(408, "", 1)); + assert!(!should_retry_image_upstream_response( 429, "内容审核拒绝", 1, )); - assert!(!should_retry_vector_engine_upstream_response(400, "", 1)); - assert!(!should_retry_vector_engine_upstream_response(502, "", 5)); + assert!(!should_retry_image_upstream_response(400, "", 1)); + assert!(!should_retry_image_upstream_response(502, "", 5)); } #[test] - fn model_fallback_only_accepts_eligible_provider_failures() { - let settings = VectorEngineImageSettings { + fn image_provider_never_falls_back_across_image_models() { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, request_deadline: None, }; - let unsupported_model = PlatformImageError::Upstream { + let error = PlatformImageError::InvalidRequest { provider: VECTOR_ENGINE_PROVIDER, - message: "model gpt-image-2 is not supported".to_string(), - upstream_status: 400, - raw_excerpt: "unknown model".to_string(), - audit: None, + message: "model unavailable".to_string(), }; - let content_rejection = PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, - message: "moderation blocked".to_string(), - upstream_status: 429, - raw_excerpt: "invalid_prompt".to_string(), - audit: None, - }; - let uncertain_send_failure = PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, - message: "send failed".to_string(), - endpoint: None, - timeout: true, - connect: false, - request: true, - body: false, - status_code: None, - source: None, - audit: None, - }; - - assert!(should_fallback_to_gpt_image_2_c( - GPT_IMAGE_2_MODEL, - GPT_IMAGE_2_MODEL, - 1, - &unsupported_model, - &settings, - )); assert!(!should_fallback_to_gpt_image_2_c( GPT_IMAGE_2_MODEL, GPT_IMAGE_2_MODEL, 1, - &content_rejection, - &settings, - )); - let rate_limit_rejection = PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, - message: "request rejected due to rate limit".to_string(), - upstream_status: 429, - raw_excerpt: "rate_limit_exceeded".to_string(), - audit: None, - }; - assert!(should_fallback_to_gpt_image_2_c( - GPT_IMAGE_2_MODEL, - GPT_IMAGE_2_MODEL, - 1, - &rate_limit_rejection, - &settings, - )); - assert!(!should_fallback_to_gpt_image_2_c( - GPT_IMAGE_2_MODEL, - GPT_IMAGE_2_MODEL, - 1, - &uncertain_send_failure, - &settings, - )); - assert!(!should_fallback_to_gpt_image_2_c( - GPT_IMAGE_2_MODEL, - GPT_IMAGE_2_MODEL, - VECTOR_ENGINE_SEND_MAX_ATTEMPTS, - &unsupported_model, + &error, &settings, )); } #[test] - fn vector_engine_send_retry_delay_uses_exponential_backoff_with_bounded_jitter() { - assert_eq!(vector_engine_send_retry_delay_ms(1, 0), 500); - assert_eq!(vector_engine_send_retry_delay_ms(2, 0), 1_000); - assert_eq!(vector_engine_send_retry_delay_ms(3, 0), 2_000); - assert_eq!(vector_engine_send_retry_delay_ms(4, 0), 4_000); - assert_eq!(vector_engine_send_retry_delay_ms(4, 999), 4_999); + fn image_provider_send_retry_delay_uses_exponential_backoff_with_bounded_jitter() { + assert_eq!(image_provider_send_retry_delay_ms(1, 0), 500); + assert_eq!(image_provider_send_retry_delay_ms(2, 0), 1_000); + assert_eq!(image_provider_send_retry_delay_ms(3, 0), 2_000); + assert_eq!(image_provider_send_retry_delay_ms(4, 0), 4_000); + assert_eq!(image_provider_send_retry_delay_ms(4, 999), 4_999); } } diff --git a/server-rs/crates/platform-image/src/vector_engine/constants.rs b/server-rs/crates/platform-image/src/image_provider/constants.rs similarity index 56% rename from server-rs/crates/platform-image/src/vector_engine/constants.rs rename to server-rs/crates/platform-image/src/image_provider/constants.rs index 2ae789387..8c101ae71 100644 --- a/server-rs/crates/platform-image/src/vector_engine/constants.rs +++ b/server-rs/crates/platform-image/src/image_provider/constants.rs @@ -1,8 +1,14 @@ pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c"; +/// Current business model exposed to callers for new image tasks. +pub const GPT_IMAGE_2_5_BUSINESS_NAME: &str = "gpt-image-2.5"; +/// Provider/pricing key for new generation tasks. +pub const GPT_IMAGE_2_5_GENERATION_MODEL: &str = "gpt-image-2.5-flare-c"; +/// Provider/pricing key for explicit image-edit tasks. +pub const GPT_IMAGE_2_5_EDIT_MODEL: &str = "gpt-image-2.5-sunburst-c"; pub const NANOBANANA_2_MODEL: &str = "gemini-3.1-flash-image-preview"; -pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; +pub const TIANTOKEN_PROVIDER: &str = "tiantoken"; pub const VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES: usize = 5; pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14; pub const GPT_IMAGE_2_MIN_PIXELS: u64 = 655_360; diff --git a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs b/server-rs/crates/platform-image/src/image_provider/curl_transport.rs similarity index 88% rename from server-rs/crates/platform-image/src/vector_engine/curl_transport.rs rename to server-rs/crates/platform-image/src/image_provider/curl_transport.rs index fbe94e1b1..b1865fd02 100644 --- a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs +++ b/server-rs/crates/platform-image/src/image_provider/curl_transport.rs @@ -12,19 +12,19 @@ use super::{ }; #[derive(Debug)] -pub(crate) struct VectorEngineCurlResponse { +pub(crate) struct ImageProviderCurlResponse { pub(crate) status: u16, pub(crate) body: String, } #[derive(Debug)] -pub(crate) enum VectorEngineCurlError { +pub(crate) enum ImageProviderCurlError { Curl(curl::Error), Form(FormError), WorkerJoin(tokio::task::JoinError), } -impl VectorEngineCurlError { +impl ImageProviderCurlError { pub(crate) fn is_timeout(&self) -> bool { match self { Self::Curl(error) => error.is_operation_timedout(), @@ -63,7 +63,7 @@ impl VectorEngineCurlError { } } -impl fmt::Display for VectorEngineCurlError { +impl fmt::Display for ImageProviderCurlError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Curl(error) => write!(formatter, "{error}"), @@ -73,26 +73,26 @@ impl fmt::Display for VectorEngineCurlError { } } -impl Error for VectorEngineCurlError {} +impl Error for ImageProviderCurlError {} -impl From for VectorEngineCurlError { +impl From for ImageProviderCurlError { fn from(error: curl::Error) -> Self { Self::Curl(error) } } -impl From for VectorEngineCurlError { +impl From for ImageProviderCurlError { fn from(error: FormError) -> Self { Self::Form(error) } } -pub(crate) async fn send_vector_engine_json_request_with_curl( +pub(crate) async fn send_image_json_request_with_curl( request_url: &str, api_key: &str, request_body: &Value, timeout_ms: u64, -) -> Result { +) -> Result { let request_url = request_url.to_string(); let api_key = api_key.to_string(); let request_body = request_body.to_string(); @@ -105,11 +105,11 @@ pub(crate) async fn send_vector_engine_json_request_with_curl( ) }) .await - .map_err(VectorEngineCurlError::WorkerJoin)? + .map_err(ImageProviderCurlError::WorkerJoin)? } #[allow(clippy::too_many_arguments)] -pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( +pub(crate) async fn send_image_multipart_edit_request_with_curl( request_url: &str, api_key: &str, model: &str, @@ -119,7 +119,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( candidate_count: u32, reference_images: &[ReferenceImage], timeout_ms: u64, -) -> Result { +) -> Result { let request_url = request_url.to_string(); let api_key = api_key.to_string(); let model = model.to_string(); @@ -141,7 +141,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( ) }) .await - .map_err(VectorEngineCurlError::WorkerJoin)? + .map_err(ImageProviderCurlError::WorkerJoin)? } pub(crate) fn map_curl_error( @@ -149,7 +149,7 @@ pub(crate) fn map_curl_error( request_url: &str, failure_stage: &'static str, image_model: Option<&'static str>, - error: VectorEngineCurlError, + error: ImageProviderCurlError, latency_ms: u64, prompt_chars: Option, reference_image_count: Option, @@ -195,7 +195,7 @@ pub(crate) fn map_curl_error( request_params = %request_params .map(|value| value.to_string()) .unwrap_or_default(), - "VectorEngine 图片 libcurl 请求失败" + "ImageProvider 图片 libcurl 请求失败" ); PlatformImageError::Request { @@ -217,8 +217,8 @@ fn send_json_request_with_curl_blocking( api_key: &str, request_body: &str, timeout_ms: u64, -) -> Result { - let mut headers = vector_engine_curl_headers(api_key)?; +) -> Result { + let mut headers = image_curl_headers(api_key)?; headers.append("Content-Type: application/json")?; let mut easy = Easy::new(); easy.url(request_url)?; @@ -240,7 +240,7 @@ fn send_multipart_edit_request_with_curl_blocking( candidate_count: u32, reference_images: &[ReferenceImage], timeout_ms: u64, -) -> Result { +) -> Result { let mut form = Form::new(); form.part("model").contents(model.as_bytes()).add()?; form.part("prompt") @@ -263,7 +263,7 @@ fn send_multipart_edit_request_with_curl_blocking( .add()?; } - let headers = vector_engine_curl_headers(api_key)?; + let headers = image_curl_headers(api_key)?; let mut easy = Easy::new(); easy.url(request_url)?; easy.httppost(form)?; @@ -272,14 +272,14 @@ fn send_multipart_edit_request_with_curl_blocking( Ok(perform_curl_request(easy)?) } -fn vector_engine_curl_headers(api_key: &str) -> Result { +fn image_curl_headers(api_key: &str) -> Result { let mut headers = List::new(); headers.append(format!("Authorization: Bearer {api_key}").as_str())?; headers.append("Accept: application/json")?; Ok(headers) } -fn perform_curl_request(mut easy: Easy) -> Result { +fn perform_curl_request(mut easy: Easy) -> Result { let mut body = Vec::new(); { let mut transfer = easy.transfer(); @@ -291,13 +291,13 @@ fn perform_curl_request(mut easy: Easy) -> Result Result { validate_raw_image_edit_dimensions(options.width, options.height) .map_err(|error| invalid_input(failure_context, error.to_string()))?; - let url = vector_engine_images_edit_url(settings); + let url = images_edit_url(settings); let started_at = Instant::now(); let prompt_chars = Some(prompt.chars().count()); let reference_image_count = Some(1_usize + usize::from(options.mask.is_some())); @@ -137,7 +137,7 @@ pub async fn create_vector_engine_raw_image_edit( return Err(request_budget_exhausted_error( url.as_str(), failure_context, - Some(GPT_IMAGE_2_MODEL), + Some(GPT_IMAGE_2_5_EDIT_MODEL), Some(started_at.elapsed().as_millis() as u64), prompt_chars, reference_image_count, @@ -149,7 +149,7 @@ pub async fn create_vector_engine_raw_image_edit( mime_type: image_mime_type, } = image; let mut form = Form::new() - .text("model", GPT_IMAGE_2_MODEL.to_string()) + .text("model", GPT_IMAGE_2_5_EDIT_MODEL.to_string()) .text("n", "1".to_string()) .text("prompt", prompt.to_string()) .text("size", format!("{}x{}", options.width, options.height)) @@ -209,7 +209,7 @@ pub async fn create_vector_engine_raw_image_edit( reference_image_count, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine Raw 图片编辑 HTTP 返回" + "ImageProvider Raw 图片编辑 HTTP 返回" ); let body = response.text().await.map_err(|error| { request_error( @@ -242,7 +242,7 @@ pub async fn create_vector_engine_raw_image_edit( Some(started_at.elapsed().as_millis() as u64), prompt_chars, reference_image_count, - Some(GPT_IMAGE_2_MODEL), + Some(GPT_IMAGE_2_5_EDIT_MODEL), ); return Err(PlatformImageError::Upstream { provider: VECTOR_ENGINE_PROVIDER, @@ -271,7 +271,7 @@ pub async fn create_vector_engine_raw_image_edit( Some(started_at.elapsed().as_millis() as u64), prompt_chars, reference_image_count, - Some(GPT_IMAGE_2_MODEL), + Some(GPT_IMAGE_2_5_EDIT_MODEL), ); return Err(PlatformImageError::ResponseParse { provider: VECTOR_ENGINE_PROVIDER, @@ -298,7 +298,7 @@ pub async fn create_vector_engine_raw_image_edit( Some(started_at.elapsed().as_millis() as u64), prompt_chars, reference_image_count, - Some(GPT_IMAGE_2_MODEL), + Some(GPT_IMAGE_2_5_EDIT_MODEL), ); return Err(PlatformImageError::MissingImage { provider: VECTOR_ENGINE_PROVIDER, @@ -354,7 +354,7 @@ fn request_error( elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context = context, error = %source, - "VectorEngine Raw 图片编辑请求失败" + "ImageProvider Raw 图片编辑请求失败" ); let audit = build_failure_audit( url, @@ -370,7 +370,7 @@ fn request_error( Some(started_at.elapsed().as_millis() as u64), prompt_chars, reference_image_count, - Some(GPT_IMAGE_2_MODEL), + Some(GPT_IMAGE_2_5_EDIT_MODEL), ); PlatformImageError::Request { provider: VECTOR_ENGINE_PROVIDER, diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/image_provider/request.rs similarity index 84% rename from server-rs/crates/platform-image/src/vector_engine/request.rs rename to server-rs/crates/platform-image/src/image_provider/request.rs index 0b008d2a2..530ec64cc 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/image_provider/request.rs @@ -2,21 +2,22 @@ use serde_json::{Map, Value, json}; use super::{ constants::{ + GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE, GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, GPT_IMAGE_2_MODEL, }, - types::{ReferenceImage, VectorEngineImageSettings}, + types::{ImageProvider, ImageProviderSettings, ReferenceImage}, }; -pub fn build_vector_engine_image_request_body( +pub fn build_image_request_body( prompt: &str, negative_prompt: Option<&str>, size: &str, candidate_count: u32, _reference_images: &[String], ) -> Value { - build_vector_engine_image_request_body_with_model( - GPT_IMAGE_2_MODEL, + build_image_request_body_with_model( + GPT_IMAGE_2_5_GENERATION_MODEL, prompt, negative_prompt, size, @@ -25,7 +26,7 @@ pub fn build_vector_engine_image_request_body( ) } -pub fn build_vector_engine_image_request_body_with_model( +pub fn build_image_request_body_with_model( model: &str, prompt: &str, negative_prompt: Option<&str>, @@ -33,7 +34,7 @@ pub fn build_vector_engine_image_request_body_with_model( candidate_count: u32, _reference_images: &[String], ) -> Value { - let model = normalize_vector_engine_image_model(model); + let model = normalize_image_model(model); let body = Map::from_iter([ ("model".to_string(), Value::String(model.to_string())), ( @@ -50,7 +51,7 @@ pub fn build_vector_engine_image_request_body_with_model( Value::Object(body) } -pub fn build_vector_engine_nanobanana_generate_content_request_body( +pub fn build_nanobanana_generate_content_request_body( prompt: &str, negative_prompt: Option<&str>, aspect_ratio: &str, @@ -86,17 +87,36 @@ pub fn build_vector_engine_nanobanana_generate_content_request_body( }) } -pub fn normalize_vector_engine_image_model(model: &str) -> &str { +pub fn normalize_image_model(model: &str) -> &str { match model.trim() { "" => GPT_IMAGE_2_MODEL, value => value, } } +/// Resolves a persisted/concrete model to its owning provider without rewriting +/// the model string. Legacy GPT Image 2 values remain readable, but are routed +/// to the current Tiantoken task boundary instead of VectorEngine. +pub fn resolve_image_provider(model: &str) -> Result { + match normalize_image_model(model) { + GPT_IMAGE_2_MODEL + | GPT_IMAGE_2_C_MODEL + | GPT_IMAGE_2_5_BUSINESS_NAME + | GPT_IMAGE_2_5_GENERATION_MODEL + | GPT_IMAGE_2_5_EDIT_MODEL => Ok(ImageProvider::Tiantoken), + "gemini-3.1-flash-image-preview" => Ok(ImageProvider::VectorEngine), + _ => Err("不支持的图片模型"), + } +} + pub(crate) fn is_gpt_image_2_family_model(model: &str) -> bool { matches!( - normalize_vector_engine_image_model(model), - GPT_IMAGE_2_MODEL | GPT_IMAGE_2_C_MODEL + normalize_image_model(model), + GPT_IMAGE_2_MODEL + | GPT_IMAGE_2_C_MODEL + | GPT_IMAGE_2_5_BUSINESS_NAME + | GPT_IMAGE_2_5_GENERATION_MODEL + | GPT_IMAGE_2_5_EDIT_MODEL ) } @@ -134,7 +154,7 @@ fn normalize_explicit_pixel_size(value: &str) -> String { fn clamp_gpt_image_2_pixel_size(size: &str) -> String { const MAX_ASPECT_RATIO: f64 = 3.0; - // 中文注释:这里是 VectorEngine 的共享发送边界,只处理 gpt-image-2 的显式像素尺寸。 + // 中文注释:这里是 ImageProvider 的共享发送边界,只处理 gpt-image-2 的显式像素尺寸。 let Some((width, height)) = parse_explicit_pixel_size(size) else { return size.to_string(); }; @@ -232,7 +252,7 @@ fn normalize_nanobanana_aspect_ratio(aspect_ratio: &str) -> &str { fn normalize_nanobanana_image_size(image_size: &str) -> &str { match image_size.trim() { - // 中文注释:nanobanana / Gemini 3.1 的 0.5K 在 VectorEngine 文档中要求传 512。 + // 中文注释:nanobanana / Gemini 3.1 的 0.5K 在 ImageProvider 文档中要求传 512。 "512" | "0.5K" => "512", "2K" => "2K", "4K" => "4K", @@ -240,7 +260,7 @@ fn normalize_nanobanana_image_size(image_size: &str) -> &str { } } -pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String { +pub fn images_generation_url(settings: &ImageProviderSettings) -> String { if settings.base_url.ends_with("/v1") { format!("{}/images/generations", settings.base_url) } else { @@ -248,7 +268,7 @@ pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) } } -pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> String { +pub fn images_edit_url(settings: &ImageProviderSettings) -> String { if settings.base_url.ends_with("/v1") { format!("{}/images/edits", settings.base_url) } else { @@ -256,10 +276,7 @@ pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> St } } -pub fn vector_engine_nanobanana_generate_content_url( - settings: &VectorEngineImageSettings, - model: &str, -) -> String { +pub fn nanobanana_generate_content_url(settings: &ImageProviderSettings, model: &str) -> String { let base_url = settings .base_url .trim_end_matches("/v1") @@ -267,11 +284,11 @@ pub fn vector_engine_nanobanana_generate_content_url( format!( "{}/v1beta/models/{}:generateContent", base_url, - normalize_vector_engine_image_model(model) + normalize_image_model(model) ) } -pub(crate) fn build_vector_engine_image_edit_request_log_params( +pub(crate) fn build_image_edit_request_log_params( model: &str, prompt: &str, negative_prompt: Option<&str>, @@ -279,7 +296,7 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params( candidate_count: u32, reference_images: &[ReferenceImage], ) -> Value { - let model = normalize_vector_engine_image_model(model); + let model = normalize_image_model(model); let prompt = prompt.trim(); let negative_prompt = negative_prompt .map(str::trim) @@ -330,11 +347,11 @@ pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<& #[cfg(test)] mod tests { use super::*; - use crate::vector_engine::types::ReferenceImage; + use crate::image_provider::types::ReferenceImage; #[test] fn edit_request_log_params_include_reference_image_sizes_without_secrets_or_bytes() { - let params = build_vector_engine_image_edit_request_log_params( + let params = build_image_edit_request_log_params( GPT_IMAGE_2_MODEL, " 拼图参考图重绘 ", Some(" 文字,水印 "), @@ -389,7 +406,7 @@ mod tests { "1152x2048", "2048x1152", ] { - let body = build_vector_engine_image_request_body_with_model( + let body = build_image_request_body_with_model( GPT_IMAGE_2_MODEL, "测试", None, diff --git a/server-rs/crates/platform-image/src/vector_engine/response.rs b/server-rs/crates/platform-image/src/image_provider/response.rs similarity index 93% rename from server-rs/crates/platform-image/src/vector_engine/response.rs rename to server-rs/crates/platform-image/src/image_provider/response.rs index 438b5b3a6..aa88b1205 100644 --- a/server-rs/crates/platform-image/src/vector_engine/response.rs +++ b/server-rs/crates/platform-image/src/image_provider/response.rs @@ -12,7 +12,7 @@ use super::{ }; use std::time::Instant; -pub(crate) async fn handle_vector_engine_response( +pub(crate) async fn handle_image_response( http_client: &reqwest::Client, request_url: &str, response_status: u16, @@ -53,7 +53,7 @@ pub(crate) async fn handle_vector_engine_response( retryable = audit.retryable, message = %message, raw_excerpt = %raw_excerpt, - "VectorEngine 图片生成上游错误" + "ImageProvider 图片生成上游错误" ); return Err(PlatformImageError::Upstream { provider: VECTOR_ENGINE_PROVIDER, @@ -89,7 +89,7 @@ pub(crate) async fn handle_vector_engine_response( status = response_status, raw_excerpt = %truncate_raw(response_text), message = %error.message(), - "VectorEngine 图片响应解析失败" + "ImageProvider 图片响应解析失败" ); return Err(error.with_audit(audit)); } @@ -151,7 +151,7 @@ pub(crate) async fn handle_vector_engine_response( image_count = generated.images.len(), elapsed_ms = download_started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine 图片下载完成" + "ImageProvider 图片下载完成" ); return Ok(generated); } @@ -159,7 +159,7 @@ pub(crate) async fn handle_vector_engine_response( if !b64_images.is_empty() { let mut generated = images_from_base64(task_id, b64_images, candidate_count); if generated.images.is_empty() { - let message = format!("{failure_context}:VectorEngine 返回的 base64 图片无法解码"); + let message = format!("{failure_context}:ImageProvider 返回的 base64 图片无法解码"); let raw_excerpt = truncate_raw(response_text); let audit = build_failure_audit( request_url, @@ -183,7 +183,7 @@ pub(crate) async fn handle_vector_engine_response( status = response_status, image_model, raw_excerpt = %raw_excerpt, - "VectorEngine 图片 base64 解码失败" + "ImageProvider 图片 base64 解码失败" ); return Err(PlatformImageError::ResponseParse { provider: VECTOR_ENGINE_PROVIDER, @@ -198,12 +198,12 @@ pub(crate) async fn handle_vector_engine_response( endpoint = %request_url, image_count = generated.images.len(), failure_context, - "VectorEngine 图片 base64 解码完成" + "ImageProvider 图片 base64 解码完成" ); return Ok(generated); } - let message = format!("{failure_context}:VectorEngine 未返回图片地址"); + let message = format!("{failure_context}:ImageProvider 未返回图片地址"); let audit = build_failure_audit( request_url, failure_context, @@ -225,7 +225,7 @@ pub(crate) async fn handle_vector_engine_response( endpoint = %request_url, status = response_status, raw_excerpt = %truncate_raw(response_text), - "VectorEngine 图片响应未返回图片" + "ImageProvider 图片响应未返回图片" ); Err(PlatformImageError::MissingImage { provider: VECTOR_ENGINE_PROVIDER, diff --git a/server-rs/crates/platform-image/src/vector_engine/tests.rs b/server-rs/crates/platform-image/src/image_provider/tests.rs similarity index 92% rename from server-rs/crates/platform-image/src/vector_engine/tests.rs rename to server-rs/crates/platform-image/src/image_provider/tests.rs index febcd539f..3d0acb0c6 100644 --- a/server-rs/crates/platform-image/src/vector_engine/tests.rs +++ b/server-rs/crates/platform-image/src/image_provider/tests.rs @@ -6,7 +6,7 @@ mod tests { #[test] fn request_body_normalizes_size_prompt_and_candidate_count() { - let body = build_vector_engine_image_request_body( + let body = build_image_request_body( " 风雨夜里的街道 ", Some(" 低清,水印 "), " 1:1 ", @@ -14,7 +14,7 @@ mod tests { &["data:image/png;base64,AAAA".to_string()], ); - assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); assert_eq!(body["size"], "1024x1024"); assert_eq!(body["n"], 4); assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印"); @@ -23,13 +23,15 @@ mod tests { #[test] fn provider_urls_normalize_root_and_v1_base_urls() { - let root_settings = VectorEngineImageSettings { + let root_settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: "https://vector.example".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, request_deadline: None, }; - let v1_settings = VectorEngineImageSettings { + let v1_settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, @@ -37,19 +39,19 @@ mod tests { }; assert_eq!( - vector_engine_images_generation_url(&root_settings), + images_generation_url(&root_settings), "https://vector.example/v1/images/generations" ); assert_eq!( - vector_engine_images_generation_url(&v1_settings), + images_generation_url(&v1_settings), "https://vector.example/v1/images/generations" ); assert_eq!( - vector_engine_images_edit_url(&root_settings), + images_edit_url(&root_settings), "https://vector.example/v1/images/edits" ); assert_eq!( - vector_engine_images_edit_url(&v1_settings), + images_edit_url(&v1_settings), "https://vector.example/v1/images/edits" ); } @@ -94,7 +96,7 @@ mod tests { latency_ms: Some(987), prompt_chars: Some(64), reference_image_count: Some(2), - image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + image_model: Some(GPT_IMAGE_2_MODEL), }; let request_error = PlatformImageError::Request { @@ -162,7 +164,7 @@ mod tests { assert!(audit_ref.timeout); assert!(audit_ref.retryable); assert_eq!(audit_ref.reference_image_count, Some(2)); - assert_eq!(audit_ref.image_model, Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL)); + assert_eq!(audit_ref.image_model, Some(GPT_IMAGE_2_MODEL)); assert!(invalid_config.audit().is_none()); assert!(invalid_request.audit().is_none()); } diff --git a/server-rs/crates/platform-image/src/image_provider/transport.rs b/server-rs/crates/platform-image/src/image_provider/transport.rs new file mode 100644 index 000000000..69117c14d --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/transport.rs @@ -0,0 +1,30 @@ +use std::time::Duration; + +use super::{ + error::PlatformImageError, + types::{ImageProviderClient, ImageProviderSettings}, +}; + +pub fn build_image_http_client( + settings: &ImageProviderSettings, +) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) + .http1_only() + .pool_max_idle_per_host(0) + .build() + .map_err(|error| PlatformImageError::InvalidConfig { + provider: settings.provider.as_str(), + message: format!( + "构造 {} 图片生成 HTTP 客户端失败:{error}", + settings.provider.as_str() + ), + }) +} + +pub fn build_image_provider_client( + settings: ImageProviderSettings, +) -> Result { + let http_client = build_image_http_client(&settings)?; + Ok(ImageProviderClient::new(settings, http_client)) +} diff --git a/server-rs/crates/platform-image/src/image_provider/types.rs b/server-rs/crates/platform-image/src/image_provider/types.rs new file mode 100644 index 000000000..593102047 --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/types.rs @@ -0,0 +1,85 @@ +use super::audit::PlatformImageFailureAudit; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ImageProvider { + VectorEngine, + Tiantoken, +} + +impl ImageProvider { + pub const fn as_str(self) -> &'static str { + match self { + Self::VectorEngine => super::constants::VECTOR_ENGINE_PROVIDER, + Self::Tiantoken => super::constants::TIANTOKEN_PROVIDER, + } + } +} + +#[derive(Clone, Debug)] +pub struct ImageProviderSettings { + pub provider: ImageProvider, + pub base_url: String, + pub api_key: String, + pub request_timeout_ms: u64, + pub request_deadline: Option, +} + +#[derive(Clone)] +pub struct ImageProviderClient { + settings: ImageProviderSettings, + http_client: reqwest::Client, +} + +impl std::fmt::Debug for ImageProviderClient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ImageProviderClient") + .field("provider", &self.settings.provider) + .field("base_url", &self.settings.base_url) + .field("api_key", &"") + .finish() + } +} + +impl ImageProviderClient { + pub(crate) fn new(settings: ImageProviderSettings, http_client: reqwest::Client) -> Self { + Self { + settings, + http_client, + } + } + + pub fn provider(&self) -> ImageProvider { + self.settings.provider + } + + pub fn settings(&self) -> &ImageProviderSettings { + &self.settings + } + + pub fn http_client(&self) -> &reqwest::Client { + &self.http_client + } +} + +#[derive(Clone, Debug)] +pub struct GeneratedImages { + pub task_id: String, + pub actual_prompt: Option, + pub images: Vec, + pub recovered_failure_audits: Vec, +} + +#[derive(Clone, Debug)] +pub struct DownloadedImage { + pub bytes: Vec, + pub mime_type: String, + pub extension: String, +} + +#[derive(Clone, Debug)] +pub struct ReferenceImage { + pub bytes: Vec, + pub mime_type: String, + pub file_name: String, +} diff --git a/server-rs/crates/platform-image/src/vector_engine/util.rs b/server-rs/crates/platform-image/src/image_provider/util.rs similarity index 100% rename from server-rs/crates/platform-image/src/vector_engine/util.rs rename to server-rs/crates/platform-image/src/image_provider/util.rs diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index 9ef40103c..d30a32c09 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -1,26 +1,26 @@ pub mod generated_asset_sheets; pub mod generated_assets; +pub mod image_provider; pub mod pixel_art_snapper; -pub mod vector_engine; +pub use image_provider::{ + DownloadedImage, GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, GPT_IMAGE_2_5_BUSINESS_NAME, + GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, + GPT_IMAGE_2_MODEL, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings, + NANOBANANA_2_MODEL, PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, + RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, + RawImageEditDimensionError, RawImageEditImage, RawImageEditOptions, RawImageEditResult, + ReferenceImage, TIANTOKEN_PROVIDER, VECTOR_ENGINE_PROVIDER, build_image_http_client, + build_image_provider_client, build_image_request_body, + build_nanobanana_generate_content_request_body, create_image_edit, + create_image_edit_with_references, create_image_edit_with_references_and_model, + create_image_generation, create_image_generation_with_model, + create_nanobanana_generate_content, create_raw_image_edit, download_remote_image, + images_edit_url, images_generation_url, nanobanana_generate_content_url, + resolve_image_provider, validate_raw_image_edit_dimensions, +}; pub use pixel_art_snapper::{ PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT, PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art_strict_with_deadline, snap_pixel_art_with_deadline, }; -pub use vector_engine::{ - DownloadedImage, GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, - GeneratedImages, NANOBANANA_2_MODEL, PlatformImageError, PlatformImageFailureAudit, - PlatformImageStatusHint, RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, - RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, RawImageEditDimensionError, RawImageEditImage, - RawImageEditOptions, RawImageEditResult, ReferenceImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, - VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, - build_vector_engine_image_request_body, - build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit, - create_vector_engine_image_edit_with_references, - create_vector_engine_image_edit_with_references_and_model, - create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, - create_vector_engine_nanobanana_generate_content, create_vector_engine_raw_image_edit, - download_remote_image, validate_raw_image_edit_dimensions, vector_engine_images_edit_url, - vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url, -}; diff --git a/server-rs/crates/platform-image/src/vector_engine/mod.rs b/server-rs/crates/platform-image/src/vector_engine/mod.rs deleted file mode 100644 index 25e984180..000000000 --- a/server-rs/crates/platform-image/src/vector_engine/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -mod audit; -mod budget; -mod client; -mod constants; -mod curl_transport; -mod error; -mod image_source; -mod payload; -mod raw_edit; -mod request; -mod response; -mod transport; -mod types; -mod util; - -pub use audit::PlatformImageFailureAudit; -pub use client::{ - create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, - create_vector_engine_image_edit_with_references_and_model, - create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, - create_vector_engine_nanobanana_generate_content, -}; -pub use constants::{ - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, - VECTOR_ENGINE_PROVIDER, -}; -pub use error::{PlatformImageError, PlatformImageStatusHint}; -pub use image_source::download_remote_image; -pub use raw_edit::{ - GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, - RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, RawImageEditDimensionError, RawImageEditImage, - RawImageEditOptions, RawImageEditResult, create_vector_engine_raw_image_edit, - validate_raw_image_edit_dimensions, -}; -pub use request::{ - build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model, - build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size_for_model, - vector_engine_images_edit_url, vector_engine_images_generation_url, - vector_engine_nanobanana_generate_content_url, -}; -pub use transport::build_vector_engine_image_http_client; -pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings}; diff --git a/server-rs/crates/platform-image/src/vector_engine/transport.rs b/server-rs/crates/platform-image/src/vector_engine/transport.rs deleted file mode 100644 index 6a63878bd..000000000 --- a/server-rs/crates/platform-image/src/vector_engine/transport.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::time::Duration; - -use super::{ - constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, types::VectorEngineImageSettings, -}; - -pub fn build_vector_engine_image_http_client( - settings: &VectorEngineImageSettings, -) -> Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) - .http1_only() - .pool_max_idle_per_host(0) - .build() - .map_err(|error| PlatformImageError::InvalidConfig { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"), - }) -} diff --git a/server-rs/crates/platform-image/src/vector_engine/types.rs b/server-rs/crates/platform-image/src/vector_engine/types.rs deleted file mode 100644 index 77fbd19f9..000000000 --- a/server-rs/crates/platform-image/src/vector_engine/types.rs +++ /dev/null @@ -1,31 +0,0 @@ -use super::audit::PlatformImageFailureAudit; - -#[derive(Clone, Debug)] -pub struct VectorEngineImageSettings { - pub base_url: String, - pub api_key: String, - pub request_timeout_ms: u64, - pub request_deadline: Option, -} - -#[derive(Clone, Debug)] -pub struct GeneratedImages { - pub task_id: String, - pub actual_prompt: Option, - pub images: Vec, - pub recovered_failure_audits: Vec, -} - -#[derive(Clone, Debug)] -pub struct DownloadedImage { - pub bytes: Vec, - pub mime_type: String, - pub extension: String, -} - -#[derive(Clone, Debug)] -pub struct ReferenceImage { - pub bytes: Vec, - pub mime_type: String, - pub file_name: String, -} diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/image_provider.rs similarity index 72% rename from server-rs/crates/platform-image/tests/vector_engine.rs rename to server-rs/crates/platform-image/tests/image_provider.rs index f1bd4470b..cae9ed173 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -1,11 +1,10 @@ -use platform_image::vector_engine::{ - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, PlatformImageError, ReferenceImage, - VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, - build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model, - build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit, - create_vector_engine_image_generation, create_vector_engine_nanobanana_generate_content, - vector_engine_images_edit_url, vector_engine_images_generation_url, - vector_engine_nanobanana_generate_content_url, +use platform_image::image_provider::{ + GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, ImageProvider, + ImageProviderSettings, PlatformImageError, ReferenceImage, VECTOR_ENGINE_PROVIDER, + build_image_http_client, build_image_request_body, build_image_request_body_with_model, + build_nanobanana_generate_content_request_body, create_image_edit, create_image_generation, + create_nanobanana_generate_content, images_edit_url, images_generation_url, + nanobanana_generate_content_url, }; use std::{ sync::{ @@ -21,39 +20,39 @@ use tokio::{ }; #[test] -fn vector_engine_module_exposes_provider_protocol_helpers() { - let settings = VectorEngineImageSettings { +fn image_provider_module_exposes_provider_protocol_helpers() { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, request_deadline: None, }; - let body = - build_vector_engine_image_request_body("雾海神殿", Some("文字,水印"), "16:9", 9, &[]); + let body = build_image_request_body("雾海神殿", Some("文字,水印"), "16:9", 9, &[]); assert_eq!(GPT_IMAGE_2_MODEL, "gpt-image-2"); assert_eq!(GPT_IMAGE_2_C_MODEL, "gpt-image-2-c"); assert_eq!(VECTOR_ENGINE_PROVIDER, "vector-engine"); - assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); assert_eq!(body["size"], "1536x1024"); assert_eq!(body["n"], 4); assert_eq!(body["prompt"], "雾海神殿\n避免:文字,水印"); assert_eq!( - vector_engine_images_generation_url(&settings), + images_generation_url(&settings), "https://vector.example/v1/images/generations" ); assert_eq!( - vector_engine_images_edit_url(&settings), + images_edit_url(&settings), "https://vector.example/v1/images/edits" ); } #[test] -fn vector_engine_clamps_gpt_image_2_explicit_pixel_sizes_to_its_supported_pixel_budget() { - let cover = build_vector_engine_image_request_body("宣发首图", None, "720x540", 1, &[]); - let detail = build_vector_engine_image_request_body("详情单图", None, "720x1280", 1, &[]); - let poster = build_vector_engine_image_request_body("运营海报", None, "1280x720", 1, &[]); +fn image_provider_clamps_gpt_image_2_explicit_pixel_sizes_to_its_supported_pixel_budget() { + let cover = build_image_request_body("宣发首图", None, "720x540", 1, &[]); + let detail = build_image_request_body("详情单图", None, "720x1280", 1, &[]); + let poster = build_image_request_body("运营海报", None, "1280x720", 1, &[]); assert_eq!(cover["size"], "944x704"); assert_eq!(detail["size"], "720x1280"); @@ -61,17 +60,17 @@ fn vector_engine_clamps_gpt_image_2_explicit_pixel_sizes_to_its_supported_pixel_ } #[test] -fn vector_engine_normalizes_2k_landscape_spec_size() { - let body = build_vector_engine_image_request_body("生成规范图", None, "2048x1152", 1, &[]); +fn image_provider_normalizes_2k_landscape_spec_size() { + let body = build_image_request_body("生成规范图", None, "2048x1152", 1, &[]); - assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); assert_eq!(body["size"], "2048x1152"); assert_eq!(body["n"], 1); } #[test] -fn vector_engine_request_body_can_use_nanobanana2_model() { - let body = build_vector_engine_image_request_body_with_model( +fn image_provider_request_body_can_use_nanobanana2_model() { + let body = build_image_request_body_with_model( "gemini-3.1-flash-image-preview", "生成图标 spritesheet", None, @@ -86,16 +85,10 @@ fn vector_engine_request_body_can_use_nanobanana2_model() { } #[test] -fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { - let gpt_body = build_vector_engine_image_request_body_with_model( - GPT_IMAGE_2_MODEL, - "小尺寸图", - None, - "640x640", - 1, - &[], - ); - let nanobanana_body = build_vector_engine_image_request_body_with_model( +fn image_provider_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { + let gpt_body = + build_image_request_body_with_model(GPT_IMAGE_2_MODEL, "小尺寸图", None, "640x640", 1, &[]); + let nanobanana_body = build_image_request_body_with_model( "gemini-3.1-flash-image-preview", "小尺寸图", None, @@ -103,7 +96,7 @@ fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { 1, &[], ); - let oversized_gpt_body = build_vector_engine_image_request_body_with_model( + let oversized_gpt_body = build_image_request_body_with_model( GPT_IMAGE_2_MODEL, "大尺寸图", None, @@ -111,7 +104,7 @@ fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { 1, &[], ); - let fallback_gpt_body = build_vector_engine_image_request_body_with_model( + let fallback_gpt_body = build_image_request_body_with_model( GPT_IMAGE_2_C_MODEL, "小尺寸图", None, @@ -127,7 +120,7 @@ fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { } #[test] -fn vector_engine_gpt_image_2_sizes_always_meet_the_full_provider_envelope() { +fn image_provider_gpt_image_2_sizes_always_meet_the_full_provider_envelope() { for size in [ "1x1", "720x540", @@ -137,7 +130,7 @@ fn vector_engine_gpt_image_2_sizes_always_meet_the_full_provider_envelope() { "16x4096", "3840x3840", ] { - let body = build_vector_engine_image_request_body("约束测试", None, size, 1, &[]); + let body = build_image_request_body("约束测试", None, size, 1, &[]); let normalized = body["size"].as_str().expect("size should be a string"); let (width, height) = normalized .split_once('x') @@ -154,8 +147,8 @@ fn vector_engine_gpt_image_2_sizes_always_meet_the_full_provider_envelope() { } #[test] -fn vector_engine_request_body_can_use_nanobanana2_half_k() { - let body = build_vector_engine_image_request_body_with_model( +fn image_provider_request_body_can_use_nanobanana2_half_k() { + let body = build_image_request_body_with_model( "gemini-3.1-flash-image-preview", "生成图标 spritesheet", None, @@ -170,7 +163,7 @@ fn vector_engine_request_body_can_use_nanobanana2_half_k() { #[test] fn nanobanana_generate_content_body_carries_aspect_ratio_and_image_size() { - let body = build_vector_engine_nanobanana_generate_content_request_body( + let body = build_nanobanana_generate_content_request_body( "生成角色图", Some("文字、水印"), "2:3", @@ -195,7 +188,8 @@ fn nanobanana_generate_content_body_carries_aspect_ratio_and_image_size() { #[test] fn nanobanana_generate_content_url_uses_model_path() { - let settings = VectorEngineImageSettings { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, @@ -203,13 +197,13 @@ fn nanobanana_generate_content_url_uses_model_path() { }; assert_eq!( - vector_engine_nanobanana_generate_content_url(&settings, "gemini-3.1-flash-image-preview"), + nanobanana_generate_content_url(&settings, "gemini-3.1-flash-image-preview"), "https://vector.example/v1beta/models/gemini-3.1-flash-image-preview:generateContent" ); } #[tokio::test] -async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { +async fn image_edit_retries_send_timeout_once_and_succeeds() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("mock server should bind"); @@ -250,28 +244,28 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { } }); - let settings = VectorEngineImageSettings { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: format!("http://{server_addr}/v1"), api_key: "test-key".to_string(), request_timeout_ms: 40, request_deadline: None, }; - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let http_client = build_image_http_client(&settings).expect("client should build"); let reference_image = ReferenceImage { bytes: b"reference".to_vec(), mime_type: "image/png".to_string(), file_name: "reference.png".to_string(), }; - let generated = create_vector_engine_image_edit( + let generated = create_image_edit( &http_client, &settings, "测试提示词", None, "1024x1024", &reference_image, - "测试 VectorEngine 图片编辑失败", + "测试 ImageProvider 图片编辑失败", ) .await .expect("second attempt should return generated image"); @@ -284,7 +278,7 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { assert!( requests .iter() - .all(|request| request.contains("\r\n\r\ngpt-image-2\r\n")) + .all(|request| request.contains("\r\n\r\ngpt-image-2.5-sunburst-c\r\n")) ); server.abort(); } @@ -328,7 +322,7 @@ async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Vec { } #[tokio::test] -async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() { +async fn image_provider_deadline_clips_stalled_attempt_and_prevents_retry() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("mock server should bind"); @@ -351,18 +345,18 @@ async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() { } }); - let mut settings = VectorEngineImageSettings { + let mut settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: format!("http://{server_addr}/v1"), api_key: "test-key".to_string(), request_timeout_ms: 5_000, request_deadline: None, }; - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let http_client = build_image_http_client(&settings).expect("client should build"); let started_at = Instant::now(); settings.request_deadline = Some(started_at + Duration::from_secs(1)); - let error = create_vector_engine_image_generation( + let error = create_image_generation( &http_client, &settings, "测试提示词", @@ -370,7 +364,7 @@ async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() { "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await .expect_err("stalled request should exhaust the shared deadline"); @@ -435,15 +429,16 @@ async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() { ); let _ = stream.write_all(response.as_bytes()).await; }); - let settings = VectorEngineImageSettings { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: format!("http://{}", server_addr), api_key: "test-key".to_string(), request_timeout_ms: 1_000, request_deadline: None, }; - let client = build_vector_engine_image_http_client(&settings).expect("client should build"); + let client = build_image_http_client(&settings).expect("client should build"); - let generated = create_vector_engine_nanobanana_generate_content( + let generated = create_nanobanana_generate_content( &client, &settings, "gemini-3.1-flash-image-preview", @@ -463,7 +458,7 @@ async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() { } #[tokio::test] -async fn vector_engine_image_generation_falls_back_after_upstream_502_and_succeeds() { +async fn image_generation_stays_on_model_after_upstream_502() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("mock server should bind"); @@ -510,16 +505,16 @@ async fn vector_engine_image_generation_falls_back_after_upstream_502_and_succee } }); - let settings = VectorEngineImageSettings { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: format!("http://{server_addr}/v1"), api_key: "test-key".to_string(), request_timeout_ms: 1_000, request_deadline: None, }; - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let http_client = build_image_http_client(&settings).expect("client should build"); - let generated = create_vector_engine_image_generation( + let generated = create_image_generation( &http_client, &settings, "测试提示词", @@ -527,38 +522,31 @@ async fn vector_engine_image_generation_falls_back_after_upstream_502_and_succee "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await - .expect("second attempt should return generated image"); + .expect("same-model retry should recover"); assert_eq!(generated.images.len(), 1); - assert_eq!(generated.images[0].mime_type, "image/png"); - assert_eq!(generated.recovered_failure_audits.len(), 1); - assert_eq!( - generated.recovered_failure_audits[0].image_model, - Some(GPT_IMAGE_2_MODEL) - ); assert_eq!(request_count.load(Ordering::SeqCst), 2); let requests = requests.lock().await; - assert!(requests[0].contains("\"model\":\"gpt-image-2\"")); - assert!(requests[1].contains("\"model\":\"gpt-image-2-c\"")); + assert!(requests[0].contains("\"model\":\"gpt-image-2.5-flare-c\"")); + assert!(requests[1].contains("\"model\":\"gpt-image-2.5-flare-c\"")); server.abort(); } #[tokio::test] -async fn vector_engine_image_generation_uses_gpt_image_2_without_fallback_on_success() { +async fn image_generation_uses_gpt_image_2_without_fallback_on_success() { let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse { status: "200 OK", content_type: "application/json", body: r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#, }]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); - let generated = create_vector_engine_image_generation( + let generated = create_image_generation( &http_client, &settings, "测试提示词", @@ -566,7 +554,7 @@ async fn vector_engine_image_generation_uses_gpt_image_2_without_fallback_on_suc "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await .expect("preferred model should generate image"); @@ -575,12 +563,12 @@ async fn vector_engine_image_generation_uses_gpt_image_2_without_fallback_on_suc assert!(generated.recovered_failure_audits.is_empty()); let requests = requests.lock().await; assert_eq!(requests.len(), 1); - assert!(requests[0].contains("\"model\":\"gpt-image-2\"")); + assert!(requests[0].contains("\"model\":\"gpt-image-2.5-flare-c\"")); server.abort(); } #[tokio::test] -async fn vector_engine_image_edit_falls_back_when_preferred_model_is_unsupported() { +async fn image_edit_does_not_cross_model_when_unsupported() { let (base_url, server, requests) = start_http_response_sequence(vec![ MockResponse { status: "400 Bad Request", @@ -594,53 +582,45 @@ async fn vector_engine_image_edit_falls_back_when_preferred_model_is_unsupported }, ]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); let reference = ReferenceImage { bytes: b"reference".to_vec(), mime_type: "image/png".to_string(), file_name: "reference.png".to_string(), }; - let generated = create_vector_engine_image_edit( + let error = create_image_edit( &http_client, &settings, "测试提示词", None, "1024x1024", &reference, - "测试 VectorEngine 图片编辑失败", + "测试 ImageProvider 图片编辑失败", ) .await - .expect("fallback model should recover unsupported preferred model"); + .expect_err("unsupported model must remain terminal"); - assert_eq!(generated.images.len(), 1); - assert_eq!(generated.recovered_failure_audits.len(), 1); - assert_eq!( - generated.recovered_failure_audits[0].image_model, - Some(GPT_IMAGE_2_MODEL) - ); + assert!(matches!(error, PlatformImageError::Upstream { .. })); let requests = requests.lock().await; - assert_eq!(requests.len(), 2); - assert!(requests[0].contains("\r\n\r\ngpt-image-2\r\n")); - assert!(requests[1].contains("\r\n\r\ngpt-image-2-c\r\n")); + assert_eq!(requests.len(), 1); + assert!(requests[0].contains("\r\n\r\ngpt-image-2.5-sunburst-c\r\n")); server.abort(); } #[tokio::test] -async fn vector_engine_image_generation_does_not_fallback_on_auth_failure() { +async fn image_generation_does_not_fallback_on_auth_failure() { let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse { status: "401 Unauthorized", content_type: "application/json", body: r#"{"error":{"message":"invalid api key"}}"#, }]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); - let error = create_vector_engine_image_generation( + let error = create_image_generation( &http_client, &settings, "测试提示词", @@ -648,7 +628,7 @@ async fn vector_engine_image_generation_does_not_fallback_on_auth_failure() { "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await .expect_err("authentication failure should remain terminal"); @@ -666,7 +646,7 @@ async fn vector_engine_image_generation_does_not_fallback_on_auth_failure() { } #[tokio::test] -async fn vector_engine_image_generation_falls_back_after_non_image_base64_response() { +async fn image_generation_does_not_cross_model_after_invalid_response() { let (base_url, server, requests) = start_http_response_sequence(vec![ MockResponse { status: "200 OK", @@ -680,11 +660,10 @@ async fn vector_engine_image_generation_falls_back_after_non_image_base64_respon }, ]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); - let generated = create_vector_engine_image_generation( + let error = create_image_generation( &http_client, &settings, "测试提示词", @@ -692,26 +671,20 @@ async fn vector_engine_image_generation_falls_back_after_non_image_base64_respon "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await - .expect("fallback model should recover invalid preferred response"); + .expect_err("invalid image response must remain terminal"); - assert_eq!(generated.images.len(), 1); - assert_eq!(generated.recovered_failure_audits.len(), 1); - assert_eq!( - generated.recovered_failure_audits[0].failure_stage, - "response_parse" - ); + assert!(matches!(error, PlatformImageError::ResponseParse { .. })); let requests = requests.lock().await; - assert_eq!(requests.len(), 2); - assert!(requests[0].contains("\"model\":\"gpt-image-2\"")); - assert!(requests[1].contains("\"model\":\"gpt-image-2-c\"")); + assert_eq!(requests.len(), 1); + assert!(requests[0].contains("\"model\":\"gpt-image-2.5-flare-c\"")); server.abort(); } #[tokio::test] -async fn vector_engine_image_generation_preserves_primary_audit_when_fallback_also_fails() { +async fn image_generation_does_not_retry_as_another_model() { let (base_url, server, requests) = start_http_response_sequence(vec![ MockResponse { status: "502 Bad Gateway", @@ -725,11 +698,10 @@ async fn vector_engine_image_generation_preserves_primary_audit_when_fallback_al }, ]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); - let error = create_vector_engine_image_generation( + let error = create_image_generation( &http_client, &settings, "测试提示词", @@ -737,38 +709,29 @@ async fn vector_engine_image_generation_preserves_primary_audit_when_fallback_al "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await - .expect_err("fallback authentication failure should remain terminal"); + .expect_err("upstream failure should remain terminal"); - assert_eq!(error.recovered_failure_audits().len(), 1); - assert_eq!( - error.recovered_failure_audits()[0].image_model, - Some(GPT_IMAGE_2_MODEL) - ); - assert_eq!( - error.audit().and_then(|audit| audit.image_model), - Some(GPT_IMAGE_2_C_MODEL) - ); + assert!(error.recovered_failure_audits().is_empty()); let requests = requests.lock().await; assert_eq!(requests.len(), 2); server.abort(); } #[tokio::test] -async fn vector_engine_image_generation_does_not_fallback_on_safety_parse_failure() { +async fn image_generation_does_not_fallback_on_safety_parse_failure() { let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse { status: "200 OK", content_type: "application/json", body: "safety refusal: 内容审核拒绝", }]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); - let error = create_vector_engine_image_generation( + let error = create_image_generation( &http_client, &settings, "测试提示词", @@ -776,7 +739,7 @@ async fn vector_engine_image_generation_does_not_fallback_on_safety_parse_failur "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await .expect_err("content rejection should not switch models"); @@ -828,8 +791,9 @@ async fn start_http_response_sequence( (format!("http://{server_addr}/v1"), server, requests) } -fn test_vector_engine_settings(base_url: String) -> VectorEngineImageSettings { - VectorEngineImageSettings { +fn test_image_provider_settings(base_url: String) -> ImageProviderSettings { + ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url, api_key: "test-key".to_string(), request_timeout_ms: 1_000, -- 2.52.0 From b294cbc3dbfde6da43f2b97b0d72489854c4b9c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 16:28:45 +0800 Subject: [PATCH 03/80] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20GPT=20Image=202.5=20?= =?UTF-8?q?=E5=8F=8C=20provider=20=E5=90=AF=E5=8A=A8=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动时分别构造 VectorEngine 与 Tiantoken 图片 client 按生成、编辑和 nanobanana 模型选择具体 provider 迁移 api-server、编辑器 Agent 与 raw edit 调用方 拆分 GPT Image 2.5 生成与编辑定价并隐藏 provider 具体值 --- .../editor-generation-pricing.default.json | 14 ++ .../api-server/src/character_visual_assets.rs | 21 +-- server-rs/crates/api-server/src/config.rs | 21 +-- .../src/editor_generation_config.rs | 111 +++++++++++- .../crates/api-server/src/editor_project.rs | 60 ++++--- .../api-server/src/editor_project_icon.rs | 2 +- .../api-server/src/openai_image_generation.rs | 161 ++++++++++++------ server-rs/crates/api-server/src/raw_image.rs | 8 +- server-rs/crates/api-server/src/state.rs | 71 ++++++++ .../src/agent/tools/edit_image.rs | 16 +- .../agent/tools/generate_icon_spritesheet.rs | 4 +- .../src/agent/tools/generate_image.rs | 8 +- .../src/agent/tools/generate_ui_design.rs | 23 +-- .../agent/tools/image_generation_options.rs | 28 +-- .../src/agent/tools/mod.rs | 19 ++- .../ImageCanvasGenerationModel.ts | 23 ++- 16 files changed, 430 insertions(+), 160 deletions(-) diff --git a/server-rs/crates/api-server/config/editor-generation-pricing.default.json b/server-rs/crates/api-server/config/editor-generation-pricing.default.json index 18d18f1b7..09a923eac 100644 --- a/server-rs/crates/api-server/config/editor-generation-pricing.default.json +++ b/server-rs/crates/api-server/config/editor-generation-pricing.default.json @@ -15,6 +15,20 @@ "2K": 5 } }, + "gpt-image-2.5-flare-c": { + "unit": "perGeneration", + "prices": { + "1K": 3, + "2K": 5 + } + }, + "gpt-image-2.5-sunburst-c": { + "unit": "perGeneration", + "prices": { + "1K": 3, + "2K": 5 + } + }, "seedance2.0-fast": { "unit": "perSecond", "prices": { diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index cea47141f..e4f62bb33 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -36,16 +36,16 @@ use crate::{ }, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiImageSettings, - build_openai_image_http_client, create_openai_image_generation, - require_openai_image_settings, + DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_GENERATION_MODEL, + OpenAiImageSettings, build_openai_image_http_client, + create_openai_image_generation_with_model, require_openai_image_settings, }, platform_errors::map_oss_error, request_context::RequestContext, state::AppState, }; -const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_MODEL; +const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_5_BUSINESS_NAME; const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual"; const CHARACTER_VISUAL_ENTITY_KIND: &str = "character"; const CHARACTER_VISUAL_SLOT: &str = "primary_visual"; @@ -777,13 +777,13 @@ fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJob } fn resolve_character_visual_model(value: &str) -> String { - // 中文注释:旧前端和历史草稿可能仍传 wan2.7-image-pro;RPG 主图当前统一归一到 gpt-image-2。 + // 中文注释:旧前端和历史草稿可能仍传旧模型;只在新任务提交边界归一到当前业务模型。 let trimmed = value.trim(); if !trimmed.is_empty() && trimmed != CHARACTER_VISUAL_MODEL { tracing::warn!( requested_model = trimmed, effective_model = CHARACTER_VISUAL_MODEL, - "角色主形象图片模型已归一到 gpt-image-2" + "角色主形象图片模型已归一到当前业务模型" ); } CHARACTER_VISUAL_MODEL.to_string() @@ -946,9 +946,10 @@ async fn create_character_visual_generation_once( candidate_count: u32, reference_images: &[String], ) -> Result { - let generated = create_openai_image_generation( + let generated = create_openai_image_generation_with_model( http_client, settings, + GPT_IMAGE_2_5_GENERATION_MODEL, prompt, Some(build_character_visual_negative_prompt().as_str()), size, @@ -1921,12 +1922,12 @@ mod tests { } #[test] - fn legacy_character_visual_model_normalizes_to_gpt_image_2() { + fn legacy_character_visual_model_normalizes_to_gpt_image_2_5() { assert_eq!( resolve_character_visual_model("wan2.7-image-pro"), - "gpt-image-2" + "gpt-image-2.5" ); - assert_eq!(resolve_character_visual_model(""), "gpt-image-2"); + assert_eq!(resolve_character_visual_model(""), "gpt-image-2.5"); } #[test] diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 94ea4d4e4..04135970f 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -1428,18 +1428,15 @@ impl AppConfig { } } -/// Tiantoken 是图片、文本和旧版非 Suno 音频生成的新 provider。 -/// -/// 这里保留对 `AppConfig.vector_engine_*` 的回退,方便测试构造的旧配置继续工作; -/// 生产环境一旦设置了新的 `TIANTOKEN_*` 变量,就不会再把非 Suno 请求发往 VectorEngine。 +/// Tiantoken 是 GPT Image 2.5 图片任务的独立 provider;凭证不得回退到 VectorEngine。 pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String { - read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]) - .unwrap_or_else(|| config.vector_engine_base_url.clone()) + let _ = config; + read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]).unwrap_or_default() } pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option { + let _ = config; read_first_non_empty_env(&["TIANTOKEN_API_KEY"]) - .or_else(|| config.vector_engine_api_key.clone()) } fn read_first_non_empty_env(keys: &[&str]) -> Option { @@ -1864,14 +1861,8 @@ mod tests { std::env::remove_var("TIANTOKEN_BASE_URL"); std::env::remove_var("TIANTOKEN_API_KEY"); } - assert_eq!( - tiantoken_base_url(&config), - "https://vector.example.invalid" - ); - assert_eq!( - tiantoken_api_key(&config).as_deref(), - Some("legacy-vector-key") - ); + assert_eq!(tiantoken_base_url(&config), ""); + assert_eq!(tiantoken_api_key(&config), None); } #[test] diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 11fdaa818..71e961d35 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -17,6 +17,9 @@ pub(crate) const EDITOR_GENERATION_PRICING_DEFAULT_JSON: &str = include_str!("../config/editor-generation-pricing.default.json"); const EDITOR_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; +pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS: &str = "gpt-image-2.5"; +pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION: &str = "gpt-image-2.5-flare-c"; +pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT: &str = "gpt-image-2.5-sunburst-c"; const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2"; const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana"; @@ -94,6 +97,25 @@ pub(crate) enum EditorGenerationPricingError { } impl EditorGenerationPricingConfig { + /// Public main-site projection: provider-specific GPT Image keys remain an + /// admin/server concern and are represented by the business model name. + pub(crate) fn public_projection(&self) -> Self { + let mut models = self.models.clone(); + if let Some(generation) = models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + .cloned() + { + models.insert( + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS.to_string(), + generation, + ); + } + models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION); + models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT); + models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2); + Self { models } + } + pub(crate) fn image_model_mud_points( &self, model: Option<&str>, @@ -114,15 +136,35 @@ impl EditorGenerationPricingConfig { ) } + pub(crate) fn image_edit_model_mud_points( + &self, + model: Option<&str>, + image_size: Option<&str>, + ) -> u32 { + if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() { + return price_mud_points; + } + let normalized_model = normalize_editor_image_edit_model(model); + let normalized_size = + normalize_editor_generation_image_price_size(normalized_model, image_size); + read_tier_price( + &self.models, + normalized_model, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, + normalized_size, + DEFAULT_IMAGE_PRICE_SIZE, + ) + } + pub(crate) fn spec_model_mud_points(&self, model: Option<&str>) -> u32 { if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() { return price_mud_points; } - let normalized_model = normalize_non_empty_model(model, EDITOR_IMAGE_MODEL_GPT_IMAGE_2); + let normalized_model = normalize_editor_image_model(model); read_tier_price( &self.models, normalized_model, - EDITOR_IMAGE_MODEL_GPT_IMAGE_2, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, SPEC_IMAGE_PRICE_SIZE, SPEC_IMAGE_PRICE_SIZE, ) @@ -221,7 +263,13 @@ impl EditorGenerationPricingConfig { )?; validate_required_tier_prices( &self.models, - EDITOR_IMAGE_MODEL_GPT_IMAGE_2, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + EditorGenerationPricingUnit::PerGeneration, + REQUIRED_GPT_IMAGE_SIZES, + )?; + validate_required_tier_prices( + &self.models, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EditorGenerationPricingUnit::PerGeneration, REQUIRED_GPT_IMAGE_SIZES, )?; @@ -326,6 +374,7 @@ fn load_editor_generation_pricing_from_candidates( serde_json::from_str::(override_json.as_str()) .map_err(EditorGenerationPricingError::Json)?; backfill_legacy_sfx_pricing(&mut override_config, &config, source.as_ref())?; + backfill_legacy_gpt_image_2_5_pricing(&mut override_config, &config, source.as_ref())?; override_config.validate().map_err(|error| match error { EditorGenerationPricingError::Invalid(message) => { EditorGenerationPricingError::Invalid(format!("{source}: {message}")) @@ -338,6 +387,43 @@ fn load_editor_generation_pricing_from_candidates( Ok(config) } +fn backfill_legacy_gpt_image_2_5_pricing( + config: &mut EditorGenerationPricingConfig, + fallback: &EditorGenerationPricingConfig, + source: &str, +) -> Result<(), EditorGenerationPricingError> { + if config + .models + .contains_key(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + && config + .models + .contains_key(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) + { + return Ok(()); + } + // TODO: compatibility backfill for legacy single-key pricing; remove once + // all persisted overrides contain the two explicit GPT Image 2.5 keys. + let pricing = config + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) + .or_else(|| fallback.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2)) + .cloned() + .ok_or_else(|| { + EditorGenerationPricingError::Invalid(format!( + "{source}: 受控默认配置缺少模型 {EDITOR_IMAGE_MODEL_GPT_IMAGE_2} 的兼容泥点配置" + )) + })?; + config + .models + .entry(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION.to_string()) + .or_insert_with(|| pricing.clone()); + config + .models + .entry(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT.to_string()) + .or_insert(pricing); + Ok(()) +} + fn backfill_legacy_sfx_pricing( config: &mut EditorGenerationPricingConfig, fallback: &EditorGenerationPricingConfig, @@ -437,7 +523,12 @@ fn default_runtime_pricing() -> EditorGenerationPricingConfig { fn normalize_editor_image_model(model: Option<&str>) -> &'static str { match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2, + Some( + EDITOR_IMAGE_MODEL_GPT_IMAGE_2 + | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS + | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + ) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, Some(EDITOR_IMAGE_MODEL_NANOBANANA2) | Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS) | Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => EDITOR_IMAGE_MODEL_NANOBANANA2, @@ -445,6 +536,18 @@ fn normalize_editor_image_model(model: Option<&str>) -> &'static str { } } +fn normalize_editor_image_edit_model(model: Option<&str>) -> &'static str { + match model.map(str::trim).filter(|value| !value.is_empty()) { + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, + Some( + EDITOR_IMAGE_MODEL_GPT_IMAGE_2 + | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS + | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + ) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, + _ => normalize_editor_image_model(model), + } +} + fn normalize_editor_generation_image_price_size( model: &str, image_size: Option<&str>, diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5f92b841a..0e1a041b3 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -112,8 +112,9 @@ use crate::{ }, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, OpenAiImageSettings, - OpenAiReferenceImage, build_openai_image_http_client, + DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, + GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, + OpenAiImageSettings, OpenAiReferenceImage, build_openai_image_http_client, create_openai_image_edit_with_references_and_model, create_openai_image_generation_with_model, create_openai_nanobanana_generate_content, require_openai_image_settings, @@ -374,6 +375,9 @@ pub(crate) struct EditorImagePromptBuildOutput { pub(crate) struct EditorImageProviderRequest<'a> { pub(crate) model: &'a str, + /// Concrete provider/pricing route selected by api-server. The business + /// model remains `model`; this value must never be exposed to normal UI. + pub(crate) provider_model: &'a str, pub(crate) prompt: &'a str, pub(crate) negative_prompt: Option<&'a str>, pub(crate) size: &'a str, @@ -392,7 +396,7 @@ pub(crate) async fn request_editor_generated_images( create_openai_nanobanana_generate_content( http_client, settings, - request.model, + request.provider_model, request.prompt, request.negative_prompt, request.aspect_ratio, @@ -405,7 +409,7 @@ pub(crate) async fn request_editor_generated_images( create_openai_image_generation_with_model( http_client, settings, - request.model, + request.provider_model, request.prompt, request.negative_prompt, request.size, @@ -418,7 +422,7 @@ pub(crate) async fn request_editor_generated_images( create_openai_image_edit_with_references_and_model( http_client, settings, - request.model, + request.provider_model, request.prompt, request.negative_prompt, request.size, @@ -1926,7 +1930,10 @@ pub async fn get_editor_generation_pricing( "message": error.to_string(), })) })?; - Ok(json_success_body(Some(&request_context), pricing)) + Ok(json_success_body( + Some(&request_context), + pricing.public_projection(), + )) } pub async fn list_editor_projects( @@ -2764,7 +2771,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( matches!(normalized_kind, Some("publication-material")); let generation_options = normalize_editor_generation_options( if is_ui_design_generation || is_publication_material_generation { - Some(GPT_IMAGE_2_MODEL) + Some(GPT_IMAGE_2_5_BUSINESS_NAME) } else { payload.model.as_deref() }, @@ -2854,7 +2861,7 @@ pub(crate) async fn validate_editor_image_generation_parameters_for_owner( matches!(normalized_kind, Some("publication-material")); let generation_options = normalize_editor_generation_options( if is_ui_design_generation || is_publication_material_generation { - Some(GPT_IMAGE_2_MODEL) + Some(GPT_IMAGE_2_5_BUSINESS_NAME) } else { payload.model.as_deref() }, @@ -2987,7 +2994,7 @@ where matches!(normalized_kind, Some("publication-material")); let generation_options = normalize_editor_generation_options( if is_ui_design_generation || is_publication_material_generation { - Some(GPT_IMAGE_2_MODEL) + Some(GPT_IMAGE_2_5_BUSINESS_NAME) } else { payload.model.as_deref() }, @@ -3181,6 +3188,11 @@ where &settings, EditorImageProviderRequest { model: generation_options.model, + provider_model: if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + generation_options.model + } else { + GPT_IMAGE_2_5_GENERATION_MODEL + }, prompt: submitted_prompt.as_str(), negative_prompt, size: provider_request_size.as_ref(), @@ -3832,7 +3844,7 @@ async fn resolve_editor_image_edit_price( "message": error.to_string(), })) })? - .image_generation_mud_points(Some("quick-edit"), Some(model), Some(price_size)); + .image_edit_model_mud_points(Some(model), Some(price_size)); Ok(expected_price_mud_points) } @@ -3975,7 +3987,15 @@ pub(crate) fn normalize_editor_generation_options( image_size: Option<&str>, ) -> EditorGenerationOptions { let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(GPT_IMAGE_2_MODEL) => GPT_IMAGE_2_MODEL, + // TODO: compatibility for persisted/client legacy `gpt-image-2`; do not + // rewrite the stored record, only use the current business value when a + // new task is submitted. + Some( + GPT_IMAGE_2_MODEL + | GPT_IMAGE_2_5_BUSINESS_NAME + | "gpt-image-2.5-flare-c" + | "gpt-image-2.5-sunburst-c", + ) => GPT_IMAGE_2_5_BUSINESS_NAME, Some( EDITOR_IMAGE_MODEL_NANOBANANA2 | EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS @@ -3983,7 +4003,7 @@ pub(crate) fn normalize_editor_generation_options( ) => EDITOR_IMAGE_MODEL_NANOBANANA2, // 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2; // 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。 - None => GPT_IMAGE_2_MODEL, + None => GPT_IMAGE_2_5_BUSINESS_NAME, _ => EDITOR_IMAGE_MODEL_NANOBANANA2, }; let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio); @@ -6048,7 +6068,7 @@ pub(crate) async fn edit_editor_image_for_owner_with_source_snapshot( create_openai_image_edit_with_references_and_model( &http_client, &settings, - generation_options.model, + GPT_IMAGE_2_5_EDIT_MODEL, prompt.as_str(), Some("文字、水印、边框、按钮、UI 控件、变形主体"), provider_size.as_str(), @@ -8493,7 +8513,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( create_openai_image_edit_with_references_and_model( &http_client, &settings, - generation_options.model, + GPT_IMAGE_2_5_GENERATION_MODEL, prompt.as_str(), None, generation_options.provider_size.as_str(), @@ -17817,7 +17837,7 @@ mod tests { #[test] fn editor_generation_dimensions_follow_model_options() { let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K")); - assert_eq!(default_generation.model, GPT_IMAGE_2_MODEL); + assert_eq!(default_generation.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(default_generation.size, "1024x1024"); let nanobanana = normalize_editor_generation_options( @@ -17847,7 +17867,7 @@ mod tests { assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9"); let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K")); - assert_eq!(gpt.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(gpt.size, "683x1024"); assert_eq!(gpt.provider_size, "688x1024"); assert_eq!(gpt.aspect_ratio, "2:3"); @@ -17862,21 +17882,21 @@ mod tests { let gpt_cover = normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K")); - assert_eq!(gpt_cover.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt_cover.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(gpt_cover.size, "1024x768"); assert_eq!(gpt_cover.provider_size, "1024x768"); assert_eq!(gpt_cover.aspect_ratio, "4:3"); let gpt_landscape_2k = normalize_editor_generation_options(Some("gpt-image-2"), Some("16:9"), Some("2K")); - assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(gpt_landscape_2k.size, "2048x1152"); assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9"); assert_eq!(gpt_landscape_2k.image_size, "2K"); let gpt_portrait_2k = normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K")); - assert_eq!(gpt_portrait_2k.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt_portrait_2k.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(gpt_portrait_2k.size, "1152x2048"); assert_eq!(gpt_portrait_2k.aspect_ratio, "9:16"); assert_eq!(gpt_portrait_2k.image_size, "2K"); @@ -19989,7 +20009,7 @@ mod tests { ) .expect("UI extraction dimensions should pass"); - assert_eq!(generation_options.model, GPT_IMAGE_2_MODEL); + assert_eq!(generation_options.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(generation_options.size, "2048x2048"); assert_eq!( resolve_editor_ui_design_asset_extraction_price( diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index d0e68e432..c55e3cfad 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -64,7 +64,7 @@ use crate::{ state::AppState, }; -const ICON_SPEC_MODEL: &str = "gpt-image-2"; +const ICON_SPEC_MODEL: &str = "gpt-image-2.5"; const ICON_SPEC_ASPECT_RATIO: &str = "16:9"; const ICON_SPEC_IMAGE_SIZE: &str = "2K"; const ICON_SPEC_SIZE: &str = "2048x1152"; diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 2cf90c425..4c3cf2aea 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -1,17 +1,14 @@ use axum::http::StatusCode; use platform_image::{ - DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage, - VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, - create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, - create_vector_engine_image_edit_with_references_and_model, - create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, - create_vector_engine_nanobanana_generate_content, + DownloadedImage, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings, + NANOBANANA_2_MODEL, PlatformImageError, PlatformImageStatusHint, ReferenceImage, + VECTOR_ENGINE_PROVIDER, build_image_http_client, create_image_edit, + create_image_edit_with_references, create_image_edit_with_references_and_model, + create_image_generation, create_image_generation_with_model, + create_nanobanana_generate_content, }; #[cfg(test)] -use platform_image::{ - build_vector_engine_image_request_body, vector_engine_images_edit_url, - vector_engine_images_generation_url, -}; +use platform_image::{build_image_request_body, images_edit_url, images_generation_url}; use serde_json::{Value, json}; use std::time::Instant; use time::OffsetDateTime; @@ -27,9 +24,10 @@ use crate::{ tracking::record_external_generation_run_after_success, }; -pub(crate) use platform_image::GPT_IMAGE_2_MODEL; -#[cfg(test)] -use platform_image::VECTOR_ENGINE_GPT_IMAGE_2_MODEL; +pub(crate) use platform_image::{ + GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, + GPT_IMAGE_2_MODEL, +}; pub(crate) type OpenAiGeneratedImages = GeneratedImages; pub(crate) type DownloadedOpenAiImage = DownloadedImage; @@ -45,6 +43,8 @@ pub(crate) struct OpenAiImageSettings { pub external_api_audit_user_id: Option, pub external_api_audit_profile_id: Option, pub external_api_audit_request_id: Option, + pub tiantoken_client: Option, + pub vector_engine_client: Option, } impl std::fmt::Debug for OpenAiImageSettings { @@ -71,6 +71,11 @@ impl std::fmt::Debug for OpenAiImageSettings { "external_api_audit_request_id", &self.external_api_audit_request_id, ) + .field("tiantoken_client_enabled", &self.tiantoken_client.is_some()) + .field( + "vector_engine_client_enabled", + &self.vector_engine_client.is_some(), + ) .finish() } } @@ -110,18 +115,22 @@ pub(crate) fn require_openai_image_settings( external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: Some(state.tiantoken_image_client().clone()), + vector_engine_client: Some(state.vector_engine_image_client().clone()), }) } pub(crate) fn build_openai_image_http_client( settings: &OpenAiImageSettings, ) -> Result { - build_vector_engine_image_http_client(&settings.provider_settings()) - .map_err(map_platform_image_error) + if let Some(client) = settings.tiantoken_client.as_ref() { + return Ok(client.http_client().clone()); + } + build_image_http_client(&settings.provider_settings()).map_err(map_platform_image_error) } pub(crate) async fn create_openai_image_generation( - http_client: &reqwest::Client, + _http_client: &reqwest::Client, settings: &OpenAiImageSettings, prompt: &str, negative_prompt: Option<&str>, @@ -138,9 +147,11 @@ pub(crate) async fn create_openai_image_generation( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": reference_images.len(), }); - let result = create_vector_engine_image_generation( - http_client, - &settings.provider_settings(), + let provider_client = settings.client_for_model(GPT_IMAGE_2_5_GENERATION_MODEL); + let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_GENERATION_MODEL); + let result = create_image_generation( + provider_client.http_client(), + &provider_settings, prompt, negative_prompt, size, @@ -162,7 +173,7 @@ pub(crate) async fn create_openai_image_generation( #[allow(clippy::too_many_arguments)] pub(crate) async fn create_openai_image_generation_with_model( - http_client: &reqwest::Client, + _http_client: &reqwest::Client, settings: &OpenAiImageSettings, model: &str, prompt: &str, @@ -181,9 +192,11 @@ pub(crate) async fn create_openai_image_generation_with_model( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": reference_images.len(), }); - let result = create_vector_engine_image_generation_with_model( - http_client, - &settings.provider_settings(), + let provider_client = settings.client_for_model(model); + let provider_settings = settings.provider_settings_for_model(model); + let result = create_image_generation_with_model( + provider_client.http_client(), + &provider_settings, model, prompt, negative_prompt, @@ -206,7 +219,7 @@ pub(crate) async fn create_openai_image_generation_with_model( #[allow(clippy::too_many_arguments)] pub(crate) async fn create_openai_nanobanana_generate_content( - http_client: &reqwest::Client, + _http_client: &reqwest::Client, settings: &OpenAiImageSettings, model: &str, prompt: &str, @@ -225,9 +238,11 @@ pub(crate) async fn create_openai_nanobanana_generate_content( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": reference_images.len(), }); - let result = create_vector_engine_nanobanana_generate_content( - http_client, - &settings.provider_settings(), + let provider_client = settings.client_for_model(model); + let provider_settings = settings.provider_settings_for_model(model); + let result = create_nanobanana_generate_content( + provider_client.http_client(), + &provider_settings, model, prompt, negative_prompt, @@ -249,7 +264,7 @@ pub(crate) async fn create_openai_nanobanana_generate_content( } pub(crate) async fn create_openai_image_edit( - http_client: &reqwest::Client, + _http_client: &reqwest::Client, settings: &OpenAiImageSettings, prompt: &str, negative_prompt: Option<&str>, @@ -264,9 +279,11 @@ pub(crate) async fn create_openai_image_edit( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": 1, }); - let result = create_vector_engine_image_edit( - http_client, - &settings.provider_settings(), + let provider_client = settings.client_for_model(GPT_IMAGE_2_5_EDIT_MODEL); + let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_EDIT_MODEL); + let result = create_image_edit( + provider_client.http_client(), + &provider_settings, prompt, negative_prompt, size, @@ -286,7 +303,7 @@ pub(crate) async fn create_openai_image_edit( } pub(crate) async fn create_openai_image_edit_with_references( - http_client: &reqwest::Client, + _http_client: &reqwest::Client, settings: &OpenAiImageSettings, prompt: &str, negative_prompt: Option<&str>, @@ -303,9 +320,11 @@ pub(crate) async fn create_openai_image_edit_with_references( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": reference_images.len(), }); - let result = create_vector_engine_image_edit_with_references( - http_client, - &settings.provider_settings(), + let provider_client = settings.client_for_model(GPT_IMAGE_2_5_EDIT_MODEL); + let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_EDIT_MODEL); + let result = create_image_edit_with_references( + provider_client.http_client(), + &provider_settings, prompt, negative_prompt, size, @@ -327,7 +346,7 @@ pub(crate) async fn create_openai_image_edit_with_references( #[allow(clippy::too_many_arguments)] pub(crate) async fn create_openai_image_edit_with_references_and_model( - http_client: &reqwest::Client, + _http_client: &reqwest::Client, settings: &OpenAiImageSettings, model: &str, prompt: &str, @@ -345,9 +364,11 @@ pub(crate) async fn create_openai_image_edit_with_references_and_model( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": reference_images.len(), }); - let result = create_vector_engine_image_edit_with_references_and_model( - http_client, - &settings.provider_settings(), + let provider_client = settings.client_for_model(model); + let provider_settings = settings.provider_settings_for_model(model); + let result = create_image_edit_with_references_and_model( + provider_client.http_client(), + &provider_settings, model, prompt, negative_prompt, @@ -376,7 +397,7 @@ pub(crate) fn build_openai_image_request_body( candidate_count: u32, reference_images: &[String], ) -> Value { - build_vector_engine_image_request_body( + build_image_request_body( prompt, negative_prompt, size, @@ -386,6 +407,24 @@ pub(crate) fn build_openai_image_request_body( } impl OpenAiImageSettings { + fn client_for_model(&self, model: &str) -> &ImageProviderClient { + if model == NANOBANANA_2_MODEL { + self.vector_engine_client + .as_ref() + .expect("vector engine image client is initialized at startup") + } else { + self.tiantoken_client + .as_ref() + .expect("tiantoken image client is initialized at startup") + } + } + + fn provider_settings_for_model(&self, model: &str) -> ImageProviderSettings { + let mut settings = self.client_for_model(model).settings().clone(); + settings.request_deadline = self.request_deadline; + settings + } + pub(crate) fn with_external_api_audit_actor( mut self, user_id: Option, @@ -409,8 +448,9 @@ impl OpenAiImageSettings { self } - pub(crate) fn provider_settings(&self) -> VectorEngineImageSettings { - VectorEngineImageSettings { + pub(crate) fn provider_settings(&self) -> ImageProviderSettings { + ImageProviderSettings { + provider: ImageProvider::Tiantoken, base_url: self.base_url.clone(), api_key: self.api_key.clone(), request_timeout_ms: self.request_timeout_ms.max(1), @@ -578,13 +618,13 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError { } #[cfg(test)] -fn vector_engine_images_generation_url_for_test(settings: &OpenAiImageSettings) -> String { - vector_engine_images_generation_url(&settings.provider_settings()) +fn images_generation_url_for_test(settings: &OpenAiImageSettings) -> String { + images_generation_url(&settings.provider_settings()) } #[cfg(test)] -fn vector_engine_images_edit_url_for_test(settings: &OpenAiImageSettings) -> String { - vector_engine_images_edit_url(&settings.provider_settings()) +fn images_edit_url_for_test(settings: &OpenAiImageSettings) -> String { + images_edit_url(&settings.provider_settings()) } #[cfg(test)] @@ -611,6 +651,8 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, } .with_external_api_audit_context(&request_context, None, None); @@ -631,7 +673,7 @@ mod tests { &["data:image/png;base64,abcd".to_string()], ); - assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); assert_eq!(body["size"], "1536x1024"); assert_eq!(body["n"], 2); assert!(body.get("official_fallback").is_none()); @@ -650,6 +692,8 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), @@ -660,14 +704,16 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, }; assert_eq!( - vector_engine_images_generation_url_for_test(&root_settings), + images_generation_url_for_test(&root_settings), "https://vector.example/v1/images/generations" ); assert_eq!( - vector_engine_images_generation_url_for_test(&v1_settings), + images_generation_url_for_test(&v1_settings), "https://vector.example/v1/images/generations" ); } @@ -683,6 +729,8 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), @@ -693,14 +741,16 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, }; assert_eq!( - vector_engine_images_edit_url_for_test(&root_settings), + images_edit_url_for_test(&root_settings), "https://vector.example/v1/images/edits" ); assert_eq!( - vector_engine_images_edit_url_for_test(&v1_settings), + images_edit_url_for_test(&v1_settings), "https://vector.example/v1/images/edits" ); } @@ -716,6 +766,8 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, }; let http_client = reqwest::Client::new(); @@ -764,7 +816,7 @@ mod tests { latency_ms: Some(321), prompt_chars: Some(42), reference_image_count: Some(1), - image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + image_model: Some(GPT_IMAGE_2_MODEL), }; let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft( &build_external_api_failure_draft_from_platform_image_audit(&audit), @@ -782,10 +834,7 @@ mod tests { assert_eq!(tracking.metadata["retryable"], true); assert_eq!(tracking.metadata["promptChars"], 42); assert_eq!(tracking.metadata["referenceImageCount"], 1); - assert_eq!( - tracking.metadata["imageModel"], - VECTOR_ENGINE_GPT_IMAGE_2_MODEL - ); + assert_eq!(tracking.metadata["imageModel"], GPT_IMAGE_2_MODEL); } } diff --git a/server-rs/crates/api-server/src/raw_image.rs b/server-rs/crates/api-server/src/raw_image.rs index 0c243e0d1..03926e13a 100644 --- a/server-rs/crates/api-server/src/raw_image.rs +++ b/server-rs/crates/api-server/src/raw_image.rs @@ -6,8 +6,8 @@ use axum::{ use bytes::Bytes; use image::{ImageDecoder, ImageFormat, ImageReader}; use platform_image::{ - GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, - RawImageEditImage, RawImageEditOptions, create_vector_engine_raw_image_edit, + GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, GPT_IMAGE_2_5_EDIT_MODEL, RAW_IMAGE_MAX_EDGE, + RAW_IMAGE_MAX_PIXELS, RawImageEditImage, RawImageEditOptions, create_raw_image_edit, validate_raw_image_edit_dimensions, }; use serde::Serialize; @@ -148,7 +148,7 @@ pub(crate) async fn edit_raw_image( }); let started_at_micros = (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64; let operation = async move { - let generated = match create_vector_engine_raw_image_edit( + let generated = match create_raw_image_edit( &http_client, &provider_settings, prepared.prompt.as_str(), @@ -572,7 +572,7 @@ async fn raw_image_edit_price(state: &AppState, width: u32, height: u32) -> Resu .editor_generation_pricing() .await .map(|pricing| { - pricing.image_generation_mud_points(Some("quick-edit"), Some("gpt-image-2"), Some(tier)) + pricing.image_edit_model_mud_points(Some(GPT_IMAGE_2_5_EDIT_MODEL), Some(tier)) }) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index b4030cd62..49d721a48 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -23,6 +23,9 @@ use platform_auth::{ RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token, }; +use platform_image::{ + ImageProvider, ImageProviderClient, ImageProviderSettings, build_image_provider_client, +}; use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider, OpenAiChatTokenBudgetField}; use platform_matting::{MattingClient, MattingConfig}; use platform_oss::{OssClient, OssConfig, OssError}; @@ -306,6 +309,8 @@ pub struct AppStateInner { /// 非 Suno 的文本、图片和旧版音频生成 provider 配置。 tiantoken_base_url: String, tiantoken_api_key: Option, + vector_engine_image_client: ImageProviderClient, + tiantoken_image_client: ImageProviderClient, matting_client: Option, bgfilter_provider_http_client: reqwest::Client, bgfilter_worker_http_client: reqwest::Client, @@ -605,6 +610,18 @@ impl AppState { .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; let tiantoken_base_url = crate::config::tiantoken_base_url(&config); let tiantoken_api_key = crate::config::tiantoken_api_key(&config); + let vector_engine_image_client = build_required_image_provider_client( + ImageProvider::VectorEngine, + config.vector_engine_base_url.clone(), + config.vector_engine_api_key.clone(), + config.vector_engine_image_request_timeout_ms, + )?; + let tiantoken_image_client = build_required_image_provider_client( + ImageProvider::Tiantoken, + tiantoken_base_url.clone(), + tiantoken_api_key.clone(), + config.vector_engine_image_request_timeout_ms, + )?; let llm_client = build_llm_client(&config)?; let vector_engine_llm_client = build_vector_engine_llm_client( &config, @@ -688,6 +705,8 @@ impl AppState { vector_engine_llm_client, tiantoken_base_url, tiantoken_api_key, + vector_engine_image_client, + tiantoken_image_client, matting_client, bgfilter_provider_http_client, bgfilter_worker_http_client, @@ -1598,6 +1617,14 @@ impl AppState { self.tiantoken_api_key.as_deref() } + pub(crate) fn vector_engine_image_client(&self) -> &ImageProviderClient { + &self.vector_engine_image_client + } + + pub(crate) fn tiantoken_image_client(&self) -> &ImageProviderClient { + &self.tiantoken_image_client + } + pub fn matting_client(&self) -> Option<&MattingClient> { self.matting_client.as_ref() } @@ -2309,6 +2336,50 @@ impl AdminRuntime { } } +fn build_required_image_provider_client( + provider: ImageProvider, + base_url: String, + api_key: Option, + request_timeout_ms: u64, +) -> Result { + #[cfg(test)] + let (base_url, api_key) = ( + if base_url.trim().is_empty() { + "http://127.0.0.1".to_string() + } else { + base_url + }, + api_key.or_else(|| Some("test-key".to_string())), + ); + #[cfg(not(test))] + let (base_url, api_key) = (base_url, api_key); + let base_url = base_url.trim().trim_end_matches('/'); + if base_url.is_empty() { + return Err(AppStateInitError::DependencyUnavailable(format!( + "{} 图片 provider 缺少 BASE_URL 配置", + provider.as_str() + ))); + } + let api_key = api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppStateInitError::DependencyUnavailable(format!( + "{} 图片 provider 缺少 API_KEY 配置", + provider.as_str() + )) + })?; + build_image_provider_client(ImageProviderSettings { + provider, + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: request_timeout_ms.max(1), + request_deadline: None, + }) + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string())) +} + fn build_oss_client(config: &AppConfig) -> Result, AppStateInitError> { let oss_fields = [ ("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()), diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs index 5dec72306..fa4cdfc30 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs @@ -2,7 +2,7 @@ use crate::agent::asset::ImageId; use crate::agent::prompt::{PENDING_USER_CONFIRMATION_MESSAGE, edit_image_tool_description}; use crate::agent::tools::context::EditorToolContext; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; -use platform_image::GPT_IMAGE_2_MODEL; +use platform_image::GPT_IMAGE_2_5_BUSINESS_NAME; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::error::Error; @@ -33,7 +33,7 @@ impl Display for EditImageError { EditImageError::InvalidModel(model) => { write!( f, - "{model} is not a valid model name, only {GPT_IMAGE_2_MODEL} is supported for now." + "{model} is not a valid model name, only {GPT_IMAGE_2_5_BUSINESS_NAME} is supported for now." ) } } @@ -52,7 +52,7 @@ pub struct EditImageToolArgs { pub model: String, } fn default_model_name() -> String { - GPT_IMAGE_2_MODEL.to_string() + GPT_IMAGE_2_5_BUSINESS_NAME.to_string() } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -90,9 +90,9 @@ impl Tool for EditImageTool { // TODO need to introduce size param, but that needs more metadata such as original image size, skip in this version "model": { "type": "string", - "enum": [GPT_IMAGE_2_MODEL], - "default": GPT_IMAGE_2_MODEL, - "description": format!("图片编辑固定使用{GPT_IMAGE_2_MODEL}") + "enum": [GPT_IMAGE_2_5_BUSINESS_NAME], + "default": GPT_IMAGE_2_5_BUSINESS_NAME, + "description": format!("图片编辑固定使用{GPT_IMAGE_2_5_BUSINESS_NAME}") } }, "required": ["object_image_id", "prompt"], @@ -155,7 +155,7 @@ pub struct EditorImageEditResult { impl EditImageTool { /// Validate the semantic correctness of the arguments. pub fn validate_args(&self, args: &EditImageToolArgs) -> Option { - if args.model != GPT_IMAGE_2_MODEL { + if args.model != GPT_IMAGE_2_5_BUSINESS_NAME { return Some(EditImageError::InvalidModel(args.model.clone())); } if args.prompt.trim().is_empty() { @@ -210,7 +210,7 @@ mod tests { "sourceType": "generated", "prompt": "修改图片", "actualPrompt": "修改后的图片", - "model": "gpt-image-2", + "model": "gpt-image-2.5", "taskId": "task-2", "resource": null, "asset": null, diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs index 6db172ab9..082d66443 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs @@ -10,7 +10,7 @@ use crate::agent::tools::image_generation_options::{ validate_image_generation_options, }; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; -use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; +use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::error::Error; @@ -39,7 +39,7 @@ impl Display for GenerateIconSpritesheetError { match self { Self::InvalidModel(model) => write!( f, - "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}" + "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_5_BUSINESS_NAME}" ), Self::InvalidAspectRatio(aspect_ratio) => { write!(f, "invalid aspect ratio: {aspect_ratio}") diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs index f4f9950ef..293e3a822 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs @@ -8,7 +8,7 @@ use crate::agent::tools::image_generation_options::{ validate_image_generation_options, }; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; -use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; +use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::error::Error; @@ -33,11 +33,11 @@ impl Display for GenerateImageError { match self { Self::InvalidModel(model) => write!( f, - "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}" + "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_5_BUSINESS_NAME}" ), Self::UnsupportedUiDesignModel(model) => write!( f, - "{model} is not supported for UI design generation; required model: {GPT_IMAGE_2_MODEL}" + "{model} is not supported for UI design generation; required model: {GPT_IMAGE_2_5_BUSINESS_NAME}" ), Self::InvalidAspectRatio(aspect_ratio) => { write!(f, "invalid aspect ratio: {aspect_ratio}") @@ -222,7 +222,7 @@ mod tests { "sourceType": "generated", "prompt": "生成一张图片", "actualPrompt": "生成一张清晰图片", - "model": "gpt-image-2", + "model": "gpt-image-2.5", "taskId": "task-1", "resource": null, "asset": null, diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs index 295f29ef0..ae73c9a2f 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs @@ -12,7 +12,7 @@ use crate::agent::tools::image_generation_options::{ }; use crate::framework::tool::ToolFailureKind; use crate::framework::tool::{Tool, ToolFailure}; -use platform_image::GPT_IMAGE_2_MODEL; +use platform_image::GPT_IMAGE_2_5_BUSINESS_NAME; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -34,7 +34,7 @@ pub struct GenerateUiDesignToolArgs { } fn default_ui_design_model() -> String { - GPT_IMAGE_2_MODEL.to_string() + GPT_IMAGE_2_5_BUSINESS_NAME.to_string() } impl Tool for GenerateUiDesignTool { @@ -54,9 +54,9 @@ impl Tool for GenerateUiDesignTool { "prompt": { "type": "string", "description": "完整 UI 画面、信息层级、视觉风格和构图描述。" }, "model": { "type": "string", - "enum": [GPT_IMAGE_2_MODEL], - "default": GPT_IMAGE_2_MODEL, - "description": "UI 设计图固定使用 gpt-image-2。" + "enum": [GPT_IMAGE_2_5_BUSINESS_NAME], + "default": GPT_IMAGE_2_5_BUSINESS_NAME, + "description": "UI 设计图固定使用 gpt-image-2.5。" }, "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "image_id(s) for desc UI 风格或布局" }, "aspect_ratio": image_aspect_ratio_parameter_schema(), @@ -95,7 +95,7 @@ impl Tool for GenerateUiDesignTool { impl GenerateUiDesignTool { pub fn validate_args(&self, args: &GenerateUiDesignToolArgs) -> Result<(), GenerateImageError> { - if args.model != GPT_IMAGE_2_MODEL { + if args.model != GPT_IMAGE_2_5_BUSINESS_NAME { return Err(GenerateImageError::UnsupportedUiDesignModel( args.model.clone(), )); @@ -165,11 +165,11 @@ mod tests { assert_eq!( parameters["properties"]["model"]["enum"], - json!([GPT_IMAGE_2_MODEL]) + json!([GPT_IMAGE_2_5_BUSINESS_NAME]) ); assert_eq!( parameters["properties"]["model"]["default"], - GPT_IMAGE_2_MODEL + GPT_IMAGE_2_5_BUSINESS_NAME ); assert_eq!( parameters["properties"]["image_size"]["enum"], @@ -184,7 +184,10 @@ mod tests { .as_array() .is_some_and(|required| required.contains(&json!("model"))) ); - assert!(tool.validate_args(&args(GPT_IMAGE_2_MODEL)).is_ok()); + assert!( + tool.validate_args(&args(GPT_IMAGE_2_5_BUSINESS_NAME)) + .is_ok() + ); assert!(matches!( tool.validate_args(&args(NANOBANANA_2_MODEL)), Err(GenerateImageError::UnsupportedUiDesignModel(model)) if model == NANOBANANA_2_MODEL @@ -198,7 +201,7 @@ mod tests { })) .expect("旧版 UI 设计参数应能反序列化"); - assert_eq!(args.model, GPT_IMAGE_2_MODEL); + assert_eq!(args.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert!(tool().validate_args(&args).is_ok()); } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs index c4f2ab218..5ea13ff47 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs @@ -1,4 +1,4 @@ -use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; +use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL}; use serde_json::{Value, json}; use std::error::Error; use std::fmt::Display; @@ -21,7 +21,7 @@ impl Display for ImageGenerationOptionsError { match self { Self::InvalidModel(model) => write!( f, - "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}" + "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_5_BUSINESS_NAME}" ), Self::InvalidAspectRatio(aspect_ratio) => write!( f, @@ -75,9 +75,9 @@ pub fn validate_image_generation_options( pub fn image_model_parameter_schema() -> Value { json!({ "type": "string", - "enum": [NANOBANANA_2_MODEL, GPT_IMAGE_2_MODEL], + "enum": [NANOBANANA_2_MODEL, GPT_IMAGE_2_5_BUSINESS_NAME], "default": NANOBANANA_2_MODEL, - "description": "生图模型。默认 gemini-3.1-flash-image-preview(user may call it nanobanana2);也可选择 gpt-image-2。" + "description": "生图模型。默认 gemini-3.1-flash-image-preview(user may call it nanobanana2);也可选择 gpt-image-2.5。" }) } @@ -95,7 +95,7 @@ pub fn image_size_parameter_schema() -> Value { "type": "string", "enum": NANOBANANA_2_IMAGE_SIZES, "default": DEFAULT_IMAGE_SIZE, - "description": "图片尺寸档位。nanobanana2 支持 0.5K、1K、2K;gpt-image-2 仅支持 1K、2K;默认 1K。" + "description": "图片尺寸档位。nanobanana2 支持 0.5K、1K、2K;gpt-image-2.5 仅支持 1K、2K;默认 1K。" }) } @@ -104,14 +104,14 @@ pub fn gpt_image_2_size_parameter_schema() -> Value { "type": "string", "enum": GPT_IMAGE_2_IMAGE_SIZES, "default": DEFAULT_IMAGE_SIZE, - "description": "图片尺寸档位。gpt-image-2 仅支持 1K、2K;默认 1K。" + "description": "图片尺寸档位。gpt-image-2.5 仅支持 1K、2K;默认 1K。" }) } pub fn image_model_size_constraint_schema() -> Value { json!({ "if": { - "properties": { "model": { "const": GPT_IMAGE_2_MODEL } }, + "properties": { "model": { "const": GPT_IMAGE_2_5_BUSINESS_NAME } }, // model 省略时运行时默认 nanobanana2,仍允许 0.5K。 "required": ["model"] }, @@ -126,7 +126,7 @@ pub fn image_model_size_constraint_schema() -> Value { fn supported_image_sizes(model: &str) -> Option<&'static [&'static str]> { match model { NANOBANANA_2_MODEL => Some(NANOBANANA_2_IMAGE_SIZES), - GPT_IMAGE_2_MODEL => Some(GPT_IMAGE_2_IMAGE_SIZES), + GPT_IMAGE_2_5_BUSINESS_NAME => Some(GPT_IMAGE_2_IMAGE_SIZES), _ => None, } } @@ -150,14 +150,18 @@ mod tests { } for image_size in GPT_IMAGE_2_IMAGE_SIZES { assert!( - validate_image_generation_options(GPT_IMAGE_2_MODEL, aspect_ratio, image_size,) - .is_ok() + validate_image_generation_options( + GPT_IMAGE_2_5_BUSINESS_NAME, + aspect_ratio, + image_size, + ) + .is_ok() ); } } assert!(matches!( - validate_image_generation_options(GPT_IMAGE_2_MODEL, "1:1", "0.5K"), + validate_image_generation_options(GPT_IMAGE_2_5_BUSINESS_NAME, "1:1", "0.5K"), Err(ImageGenerationOptionsError::InvalidImageSize { .. }) )); assert!(matches!( @@ -192,7 +196,7 @@ mod tests { let model_size_constraint = image_model_size_constraint_schema(); assert_eq!( model_size_constraint["if"]["properties"]["model"]["const"], - GPT_IMAGE_2_MODEL + GPT_IMAGE_2_5_BUSINESS_NAME ); assert_eq!(model_size_constraint["if"]["required"], json!(["model"])); assert_eq!( diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs index 69daaf15b..0425cfbdb 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs @@ -26,7 +26,7 @@ mod tests { use super::generate_video::{GenerateVideoTool, GenerateVideoToolArgs}; use crate::framework::tool::Tool; use platform_audio::{ELEVENLABS_SOUND_EFFECT_MODEL, SUNO_DEFAULT_MODEL}; - use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; + use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL}; use serde_json::json; #[test] @@ -69,11 +69,11 @@ mod tests { assert_eq!(image.model, NANOBANANA_2_MODEL); assert_eq!(image.aspect_ratio, "1:1"); assert_eq!(image.image_size, "1K"); - assert_eq!(edit.model, GPT_IMAGE_2_MODEL); + assert_eq!(edit.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(character.model, NANOBANANA_2_MODEL); assert_eq!(character.aspect_ratio, "1:1"); assert_eq!(character.image_size, "1K"); - assert_eq!(ui_design.model, GPT_IMAGE_2_MODEL); + assert_eq!(ui_design.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(ui_design.aspect_ratio, "1:1"); assert_eq!(ui_design.image_size, "1K"); assert_eq!(icon.model, NANOBANANA_2_MODEL); @@ -133,14 +133,17 @@ mod tests { }; assert!(tool.validate_args(&args(NANOBANANA_2_MODEL)).is_ok()); - assert!(tool.validate_args(&args(GPT_IMAGE_2_MODEL)).is_ok()); + assert!( + tool.validate_args(&args(GPT_IMAGE_2_5_BUSINESS_NAME)) + .is_ok() + ); assert!(matches!( tool.validate_args(&args("unknown-image-model")), Err(GenerateImageError::InvalidModel(_)) )); assert_eq!( tool.parameters()["properties"]["model"]["enum"], - json!([NANOBANANA_2_MODEL, GPT_IMAGE_2_MODEL]) + json!([NANOBANANA_2_MODEL, GPT_IMAGE_2_5_BUSINESS_NAME]) ); } @@ -166,7 +169,7 @@ mod tests { )); let ui_args = GenerateUiDesignToolArgs { prompt: "生成游戏主界面".to_string(), - model: GPT_IMAGE_2_MODEL.to_string(), + model: GPT_IMAGE_2_5_BUSINESS_NAME.to_string(), reference_image_ids: vec![missing_image.clone()], aspect_ratio: "16:9".to_string(), image_size: "1K".to_string(), @@ -208,7 +211,7 @@ mod tests { assert_eq!( edit["properties"]["model"]["enum"], - json!([GPT_IMAGE_2_MODEL]) + json!([GPT_IMAGE_2_5_BUSINESS_NAME]) ); assert_eq!( video["properties"]["duration_seconds"]["enum"], @@ -229,7 +232,7 @@ mod tests { for schema in [&image, &character, &icon] { assert_eq!( schema["allOf"][0]["if"]["properties"]["model"]["const"], - json!(GPT_IMAGE_2_MODEL) + json!(GPT_IMAGE_2_5_BUSINESS_NAME) ); assert_eq!( schema["allOf"][0]["then"]["properties"]["image_size"]["enum"], diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 4929dcedd..d263af996 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -44,6 +44,7 @@ import { // 中文注释:与 api-server/src/editor_generation_config.rs 保持同名模型定价语义,前端仅作为展示兜底。 export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; +export const IMAGE_MODEL_GPT_IMAGE_2_5 = 'gpt-image-2.5'; export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2; const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([ @@ -51,7 +52,7 @@ const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([ 'nanobanana2', 'nano-banana', ]); -export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; +export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2_5; export const SPEC_GENERATION_ASPECT_RATIO = '16:9'; export const SPEC_GENERATION_IMAGE_SIZE = '2K'; export const SPEC_GENERATION_SIZE = '2048x1152'; @@ -93,6 +94,12 @@ export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = { '1K': 12, '2K': 24, }, + [IMAGE_MODEL_GPT_IMAGE_2_5]: { + '1K': 3, + '2K': 5, + }, + // Legacy persisted model value; keep readable for old assets without exposing + // it as a new selectable model. [IMAGE_MODEL_GPT_IMAGE_2]: { '1K': 3, '2K': 5, @@ -127,7 +134,7 @@ export const QUICK_EDIT_SIZE_PRESETS = [ ] as const; export const EDITOR_IMAGE_MODEL_OPTIONS = [ { label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 }, - { label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 }, + { label: 'GPT Image 2.5', value: IMAGE_MODEL_GPT_IMAGE_2_5 }, ] as const; export const QUICK_EDIT_MODEL_OPTIONS = EDITOR_IMAGE_MODEL_OPTIONS; export const EDITOR_IMAGE_DIMENSION_OPTIONS = { @@ -135,6 +142,10 @@ export const EDITOR_IMAGE_DIMENSION_OPTIONS = { aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'], imageSizes: ['0.5K', '1K', '2K'], }, + [IMAGE_MODEL_GPT_IMAGE_2_5]: { + aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'], + imageSizes: ['1K', '2K'], + }, [IMAGE_MODEL_GPT_IMAGE_2]: { aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'], imageSizes: ['1K', '2K'], @@ -381,9 +392,9 @@ export const EDITOR_MODEL_MUD_POINT_CONFIG = { unit: 'perGeneration', prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_NANOBANANA2], }, - [IMAGE_MODEL_GPT_IMAGE_2]: { + [IMAGE_MODEL_GPT_IMAGE_2_5]: { unit: 'perGeneration', - prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2], + prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2_5], }, [VIDEO_MODEL_SEEDANCE_2_FAST]: { unit: 'perSecond', @@ -1452,7 +1463,7 @@ export function decodeCanvasGenerationInputs( } else if (action === 'ui-design.generate') { normalizeStringField('prompt', '用户输入'); normalizeImageParameters({ - defaultModel: IMAGE_MODEL_GPT_IMAGE_2, + defaultModel: IMAGE_MODEL_GPT_IMAGE_2_5, defaultAspectRatio: '16:9', }); } else if (action === 'image.edit') { @@ -1993,7 +2004,7 @@ export function buildUiDesignGenerationInputs( ...createGenerationInputField('用户输入', prompt, 'prompt'), ...createGenerationInputField( '模型', - options.model ?? IMAGE_MODEL_GPT_IMAGE_2, + options.model ?? IMAGE_MODEL_GPT_IMAGE_2_5, 'model', ), ...createGenerationInputField( -- 2.52.0 From 1367e9c04e151c2c5b4cf05282879f0574c45c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 16:50:10 +0800 Subject: [PATCH 04/80] =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=8E=86=E5=8F=B2=20GP?= =?UTF-8?q?T=20Image=202-c=20=E6=8F=90=E4=BA=A4=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 读取历史 gpt-image-2-c 时按当前 GPT Image 2.5 业务任务处理 保留历史字符串不改写并沿用新的生成与编辑定价路由 --- .../crates/api-server/src/editor_generation_config.rs | 3 +++ server-rs/crates/api-server/src/editor_project.rs | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 71e961d35..610731d80 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -17,6 +17,7 @@ pub(crate) const EDITOR_GENERATION_PRICING_DEFAULT_JSON: &str = include_str!("../config/editor-generation-pricing.default.json"); const EDITOR_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; +const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_C: &str = "gpt-image-2-c"; pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS: &str = "gpt-image-2.5"; pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION: &str = "gpt-image-2.5-flare-c"; pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT: &str = "gpt-image-2.5-sunburst-c"; @@ -525,6 +526,7 @@ fn normalize_editor_image_model(model: Option<&str>) -> &'static str { match model.map(str::trim).filter(|value| !value.is_empty()) { Some( EDITOR_IMAGE_MODEL_GPT_IMAGE_2 + | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_C | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, ) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, @@ -541,6 +543,7 @@ fn normalize_editor_image_edit_model(model: Option<&str>) -> &'static str { Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, Some( EDITOR_IMAGE_MODEL_GPT_IMAGE_2 + | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_C | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, ) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 0e1a041b3..8d5b94b48 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -113,9 +113,9 @@ use crate::{ http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, - GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, - OpenAiImageSettings, OpenAiReferenceImage, build_openai_image_http_client, - create_openai_image_edit_with_references_and_model, + GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, + OpenAiGeneratedImages, OpenAiImageSettings, OpenAiReferenceImage, + build_openai_image_http_client, create_openai_image_edit_with_references_and_model, create_openai_image_generation_with_model, create_openai_nanobanana_generate_content, require_openai_image_settings, }, @@ -3992,6 +3992,7 @@ pub(crate) fn normalize_editor_generation_options( // new task is submitted. Some( GPT_IMAGE_2_MODEL + | GPT_IMAGE_2_C_MODEL | GPT_IMAGE_2_5_BUSINESS_NAME | "gpt-image-2.5-flare-c" | "gpt-image-2.5-sunburst-c", -- 2.52.0 From 1574f206a2804256e427a42556ed2260b8028242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 17:45:09 +0800 Subject: [PATCH 05/80] =?UTF-8?q?=E6=95=B4=E7=90=86=E5=9B=BE=E7=89=87=20pr?= =?UTF-8?q?ovider=20=E6=A8=A1=E5=9D=97=E5=B1=82=E6=AC=A1=E5=B9=B6=E6=8B=86?= =?UTF-8?q?=E5=87=BA=20raw=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 raw image edit 提取到独立 raw_image_edit 模块 按 protocol、runtime、transport 目录收拢 image_provider 实现 同步后端架构与 raw edit 技术文档路径 --- ...¹案】Raw GPT Image 2图片编辑代理-2026-09-07.md | 2 +- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../platform-image/src/image_provider/mod.rs | 47 ++++++------------- .../src/image_provider/protocol/mod.rs | 12 +++++ .../image_provider/{ => protocol}/payload.rs | 2 +- .../image_provider/{ => protocol}/request.rs | 2 +- .../image_provider/{ => protocol}/response.rs | 2 +- .../src/image_provider/{ => runtime}/audit.rs | 2 +- .../image_provider/{ => runtime}/budget.rs | 2 +- .../src/image_provider/{ => runtime}/error.rs | 2 +- .../{client.rs => runtime/executor.rs} | 16 ++++--- .../{ => runtime}/image_source.rs | 2 +- .../src/image_provider/runtime/mod.rs | 25 ++++++++++ .../src/image_provider/{ => runtime}/types.rs | 6 +-- .../src/image_provider/{ => runtime}/util.rs | 2 +- .../{transport.rs => transport/client.rs} | 2 +- .../{curl_transport.rs => transport/curl.rs} | 2 +- .../src/image_provider/transport/mod.rs | 6 +++ server-rs/crates/platform-image/src/lib.rs | 24 ++++++---- .../raw_edit.rs => raw_image_edit/mod.rs} | 13 +++-- 20 files changed, 102 insertions(+), 71 deletions(-) create mode 100644 server-rs/crates/platform-image/src/image_provider/protocol/mod.rs rename server-rs/crates/platform-image/src/image_provider/{ => protocol}/payload.rs (99%) rename server-rs/crates/platform-image/src/image_provider/{ => protocol}/request.rs (99%) rename server-rs/crates/platform-image/src/image_provider/{ => protocol}/response.rs (99%) rename server-rs/crates/platform-image/src/image_provider/{ => runtime}/audit.rs (96%) rename server-rs/crates/platform-image/src/image_provider/{ => runtime}/budget.rs (99%) rename server-rs/crates/platform-image/src/image_provider/{ => runtime}/error.rs (98%) rename server-rs/crates/platform-image/src/image_provider/{client.rs => runtime/executor.rs} (99%) rename server-rs/crates/platform-image/src/image_provider/{ => runtime}/image_source.rs (99%) create mode 100644 server-rs/crates/platform-image/src/image_provider/runtime/mod.rs rename server-rs/crates/platform-image/src/image_provider/{ => runtime}/types.rs (88%) rename server-rs/crates/platform-image/src/image_provider/{ => runtime}/util.rs (96%) rename server-rs/crates/platform-image/src/image_provider/{transport.rs => transport/client.rs} (97%) rename server-rs/crates/platform-image/src/image_provider/{curl_transport.rs => transport/curl.rs} (99%) create mode 100644 server-rs/crates/platform-image/src/image_provider/transport/mod.rs rename server-rs/crates/platform-image/src/{image_provider/raw_edit.rs => raw_image_edit/mod.rs} (98%) diff --git a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md index ef702e821..a228c4592 100644 --- a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md +++ b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md @@ -90,7 +90,7 @@ provider 响应只提取并透传 `data[].b64_json` 字符串,不在服务端 ## 代码拆分 - `server-rs/crates/api-server/src/raw_image.rs`:独立路由 handler、multipart 字段解析、请求/响应 DTO、PNG 输入校验、预检查和 raw billing 编排。 -- `server-rs/crates/platform-image/src/vector_engine/raw_edit.rs`:raw 编辑选项、严格尺寸校验、独立 provider 请求映射和 `b64_json` 响应透传;由 api-server 按现有图片 API 传统构造并传入共享的 VectorEngine `reqwest::Client`,不在每个 raw 调用内部重复构造 client。每次请求仍用 `effective_request_timeout_ms` 通过 request builder 设置剩余 deadline;不修改统一 transport builder 的连接池策略。 +- `server-rs/crates/platform-image/src/raw_image_edit/mod.rs`:raw 编辑选项、严格尺寸校验、独立 provider 请求映射和 `b64_json` 响应透传;由 api-server 按现有图片 API 传统构造并传入启动时构造的 Tiantoken `reqwest::Client`,不在每个 raw 调用内部重复构造 client。每次请求仍用 `effective_request_timeout_ms` 通过 request builder 设置剩余 deadline;不修改统一 transport builder 的连接池策略。 raw-edit 的图片输入使用独立的 `RawImageEditImage`(`bytes::Bytes`),由 reqwest `Part::stream(Body::from(Bytes))` 直接接管 multipart 请求体,避免整图和掩码在 `Part::bytes` 的 `Cow<[u8]>` 转换中再次复制。既有 `ReferenceImage`、`DownloadedImage` 及 curl/编辑器链路继续保持 `Vec` 契约,不因 raw-edit 引入全局字节类型迁移。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index dfab9a03e..e30cea3ff 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -168,7 +168,7 @@ npm run check:server-rs-ddd 2. Adapter 输入应显式包含 provider、prompt、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source job、metadata 和可选透明背景后处理。 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 -5. 图片 provider 协议不再放在玩法模块里实现。产品、计费、DTO、持久化和 VectorEngine 创建 / 编辑首选请求统一使用 `gpt-image-2`;只有符合回退条件时,provider 边界才切到兜底模型 `gpt-image-2-c`。URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 +5. 图片 provider 协议不再放在玩法模块里实现。产品、计费、DTO、持久化和 GPT Image 2.5 创建 / 编辑请求统一使用业务模型 `gpt-image-2.5`,api-server 在提交边界分派 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c`;已持久化的 `gpt-image-2` / `gpt-image-2-c` 只按兼容规则读取,不改写历史值。URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/image_provider/`;其中 `runtime/executor.rs` 负责共享 provider-neutral 执行编排,`transport/` 负责 HTTP client 与 curl 传输,`protocol/` 负责请求体、路径和响应 JSON 字段。raw image edit 的 multipart 与严格尺寸校验位于独立的 `server-rs/crates/platform-image/src/raw_image_edit/`。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。generated 私有对象上传时必须由 OSS 对象头承载浏览器 / CDN 缓存策略,默认写入 `Cache-Control: public, max-age=31536000, immutable`,不得改成 api-server 本地磁盘静态资源兜底。 7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 diff --git a/server-rs/crates/platform-image/src/image_provider/mod.rs b/server-rs/crates/platform-image/src/image_provider/mod.rs index 74ecc3cba..9c3b4f2ba 100644 --- a/server-rs/crates/platform-image/src/image_provider/mod.rs +++ b/server-rs/crates/platform-image/src/image_provider/mod.rs @@ -1,44 +1,27 @@ -mod audit; -mod budget; -mod client; -mod constants; -mod curl_transport; -mod error; -mod image_source; -mod payload; -mod raw_edit; -mod request; -mod response; -mod transport; -mod types; -mod util; +pub(crate) mod constants; +pub(crate) mod protocol; +pub(crate) mod runtime; +pub(crate) mod transport; + +pub(crate) use protocol::{payload, request, response}; +pub(crate) use runtime::{audit, budget, error, image_source, types, util}; +pub(crate) use transport::curl as curl_transport; -pub use audit::PlatformImageFailureAudit; -pub use client::{ - create_image_edit, create_image_edit_with_references, - create_image_edit_with_references_and_model, create_image_generation, - create_image_generation_with_model, create_nanobanana_generate_content, -}; pub use constants::TIANTOKEN_PROVIDER; pub use constants::{ GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_PROVIDER, }; -pub use error::{PlatformImageError, PlatformImageStatusHint}; -pub use image_source::download_remote_image; -pub use raw_edit::{ - GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, - RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, RawImageEditDimensionError, RawImageEditImage, - RawImageEditOptions, RawImageEditResult, create_raw_image_edit, - validate_raw_image_edit_dimensions, -}; -pub use request::{ +pub use protocol::{ build_image_request_body, build_image_request_body_with_model, build_nanobanana_generate_content_request_body, images_edit_url, images_generation_url, nanobanana_generate_content_url, normalize_image_size_for_model, resolve_image_provider, }; -pub use transport::{build_image_http_client, build_image_provider_client}; -pub use types::{ +pub use runtime::{ DownloadedImage, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings, - ReferenceImage, + PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage, + create_image_edit, create_image_edit_with_references, + create_image_edit_with_references_and_model, create_image_generation, + create_image_generation_with_model, create_nanobanana_generate_content, download_remote_image, }; +pub use transport::{build_image_http_client, build_image_provider_client}; diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/mod.rs b/server-rs/crates/platform-image/src/image_provider/protocol/mod.rs new file mode 100644 index 000000000..4cd272c84 --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/protocol/mod.rs @@ -0,0 +1,12 @@ +#[path = "payload.rs"] +pub(crate) mod payload; +#[path = "request.rs"] +pub(crate) mod request; +#[path = "response.rs"] +pub(crate) mod response; + +pub use request::{ + build_image_request_body, build_image_request_body_with_model, + build_nanobanana_generate_content_request_body, images_edit_url, images_generation_url, + nanobanana_generate_content_url, normalize_image_size_for_model, resolve_image_provider, +}; diff --git a/server-rs/crates/platform-image/src/image_provider/payload.rs b/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs similarity index 99% rename from server-rs/crates/platform-image/src/image_provider/payload.rs rename to server-rs/crates/platform-image/src/image_provider/protocol/payload.rs index 9f5130a62..1dc8388ac 100644 --- a/server-rs/crates/platform-image/src/image_provider/payload.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs @@ -1,6 +1,6 @@ use serde_json::Value; -use super::{ +use crate::image_provider::{ constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, util::{ParsedJsonPayload, truncate_raw}, diff --git a/server-rs/crates/platform-image/src/image_provider/request.rs b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs similarity index 99% rename from server-rs/crates/platform-image/src/image_provider/request.rs rename to server-rs/crates/platform-image/src/image_provider/protocol/request.rs index 530ec64cc..06ba2a149 100644 --- a/server-rs/crates/platform-image/src/image_provider/request.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs @@ -1,6 +1,6 @@ use serde_json::{Map, Value, json}; -use super::{ +use crate::image_provider::{ constants::{ GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE, diff --git a/server-rs/crates/platform-image/src/image_provider/response.rs b/server-rs/crates/platform-image/src/image_provider/protocol/response.rs similarity index 99% rename from server-rs/crates/platform-image/src/image_provider/response.rs rename to server-rs/crates/platform-image/src/image_provider/protocol/response.rs index aa88b1205..473516f61 100644 --- a/server-rs/crates/platform-image/src/image_provider/response.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/response.rs @@ -1,4 +1,4 @@ -use super::{ +use crate::image_provider::{ audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, diff --git a/server-rs/crates/platform-image/src/image_provider/audit.rs b/server-rs/crates/platform-image/src/image_provider/runtime/audit.rs similarity index 96% rename from server-rs/crates/platform-image/src/image_provider/audit.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/audit.rs index 41bef9a6d..338902b0a 100644 --- a/server-rs/crates/platform-image/src/image_provider/audit.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/audit.rs @@ -1,4 +1,4 @@ -use super::constants::VECTOR_ENGINE_PROVIDER; +use crate::image_provider::constants::VECTOR_ENGINE_PROVIDER; #[derive(Clone, Debug)] pub struct PlatformImageFailureAudit { diff --git a/server-rs/crates/platform-image/src/image_provider/budget.rs b/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs similarity index 99% rename from server-rs/crates/platform-image/src/image_provider/budget.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/budget.rs index 75831c03b..bc7d912e3 100644 --- a/server-rs/crates/platform-image/src/image_provider/budget.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs @@ -1,6 +1,6 @@ use std::time::{Duration, Instant}; -use super::{ +use crate::image_provider::{ audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, }; diff --git a/server-rs/crates/platform-image/src/image_provider/error.rs b/server-rs/crates/platform-image/src/image_provider/runtime/error.rs similarity index 98% rename from server-rs/crates/platform-image/src/image_provider/error.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/error.rs index cb820e08a..a58f2d247 100644 --- a/server-rs/crates/platform-image/src/image_provider/error.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/error.rs @@ -1,6 +1,6 @@ use std::{error::Error, fmt}; -use super::{audit::PlatformImageFailureAudit, util::is_timeout_message}; +use crate::image_provider::{audit::PlatformImageFailureAudit, util::is_timeout_message}; #[derive(Clone, Debug)] pub enum PlatformImageError { diff --git a/server-rs/crates/platform-image/src/image_provider/client.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs similarity index 99% rename from server-rs/crates/platform-image/src/image_provider/client.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index 9987f97bc..11b597acf 100644 --- a/server-rs/crates/platform-image/src/image_provider/client.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -4,7 +4,7 @@ const VECTOR_ENGINE_SEND_MAX_ATTEMPTS: u32 = 5; const VECTOR_ENGINE_SEND_RETRY_BASE_DELAY_MS: u64 = 500; const VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS: u64 = 999; -use super::{ +use crate::image_provider::{ budget::{ effective_request_timeout_ms, request_budget_exhausted_error, retry_delay_fits_request_deadline, @@ -1060,8 +1060,8 @@ fn image_provider_send_retry_jitter_ms() -> u64 { #[cfg(test)] mod tests { - use super::super::types::ImageProvider; use super::*; + use crate::image_provider::types::ImageProvider; fn reference_image(index: usize) -> ReferenceImage { ReferenceImage { @@ -1107,7 +1107,7 @@ mod tests { let error = create_nanobanana_generate_content( &reqwest::Client::new(), &test_settings(), - super::super::constants::NANOBANANA_2_MODEL, + crate::image_provider::constants::NANOBANANA_2_MODEL, "测试提示词", None, "1:1", @@ -1163,8 +1163,9 @@ mod tests { #[test] fn image_provider_send_retry_policy_treats_ssl_reset_as_transient_transport() { - let error = - super::super::curl_transport::ImageProviderCurlError::Curl(curl::Error::new(35)); + let error = crate::image_provider::transport::curl::ImageProviderCurlError::Curl( + curl::Error::new(35), + ); assert!(error.is_transient_transport()); assert!(should_retry_image_curl_send_error(&error, 1)); @@ -1173,8 +1174,9 @@ mod tests { #[test] fn image_provider_send_retry_policy_treats_recv_eof_as_transient_transport() { - let error = - super::super::curl_transport::ImageProviderCurlError::Curl(curl::Error::new(56)); + let error = crate::image_provider::transport::curl::ImageProviderCurlError::Curl( + curl::Error::new(56), + ); assert!(error.is_transient_transport()); assert!(should_retry_image_curl_send_error(&error, 1)); diff --git a/server-rs/crates/platform-image/src/image_provider/image_source.rs b/server-rs/crates/platform-image/src/image_provider/runtime/image_source.rs similarity index 99% rename from server-rs/crates/platform-image/src/image_provider/image_source.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/image_source.rs index dbc0b38a4..3e12b0055 100644 --- a/server-rs/crates/platform-image/src/image_provider/image_source.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/image_source.rs @@ -2,7 +2,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use reqwest::header; use std::time::Instant; -use super::{ +use crate::image_provider::{ budget::request_budget_exhausted_error, constants::{VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER}, error::PlatformImageError, diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs b/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs new file mode 100644 index 000000000..88a04c640 --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs @@ -0,0 +1,25 @@ +pub(crate) mod audit; +pub(crate) mod budget; +pub(crate) mod error; +pub(crate) mod executor; +pub(crate) mod image_source; +pub(crate) mod types; +pub(crate) mod util; + +pub(crate) use crate::image_provider::transport::curl as curl_transport; + +pub use audit::PlatformImageFailureAudit; +pub(crate) use audit::build_failure_audit; +pub(crate) use budget::{effective_request_timeout_ms, request_budget_exhausted_error}; +pub use error::{PlatformImageError, PlatformImageStatusHint}; +pub use executor::{ + create_image_edit, create_image_edit_with_references, + create_image_edit_with_references_and_model, create_image_generation, + create_image_generation_with_model, create_nanobanana_generate_content, +}; +pub use image_source::download_remote_image; +pub use types::{ + DownloadedImage, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings, + ReferenceImage, +}; +pub(crate) use util::truncate_raw; diff --git a/server-rs/crates/platform-image/src/image_provider/types.rs b/server-rs/crates/platform-image/src/image_provider/runtime/types.rs similarity index 88% rename from server-rs/crates/platform-image/src/image_provider/types.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/types.rs index 593102047..6ac5bb229 100644 --- a/server-rs/crates/platform-image/src/image_provider/types.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/types.rs @@ -1,4 +1,4 @@ -use super::audit::PlatformImageFailureAudit; +use crate::image_provider::audit::PlatformImageFailureAudit; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ImageProvider { @@ -9,8 +9,8 @@ pub enum ImageProvider { impl ImageProvider { pub const fn as_str(self) -> &'static str { match self { - Self::VectorEngine => super::constants::VECTOR_ENGINE_PROVIDER, - Self::Tiantoken => super::constants::TIANTOKEN_PROVIDER, + Self::VectorEngine => crate::image_provider::constants::VECTOR_ENGINE_PROVIDER, + Self::Tiantoken => crate::image_provider::constants::TIANTOKEN_PROVIDER, } } } diff --git a/server-rs/crates/platform-image/src/image_provider/util.rs b/server-rs/crates/platform-image/src/image_provider/runtime/util.rs similarity index 96% rename from server-rs/crates/platform-image/src/image_provider/util.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/util.rs index 621d57c6a..9995dbf92 100644 --- a/server-rs/crates/platform-image/src/image_provider/util.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/util.rs @@ -1,6 +1,6 @@ use serde_json::Value; -use super::{audit::PlatformImageFailureAudit, error::PlatformImageError}; +use crate::image_provider::{audit::PlatformImageFailureAudit, error::PlatformImageError}; pub(crate) fn is_timeout_message(message: &str) -> bool { let lower = message.to_ascii_lowercase(); diff --git a/server-rs/crates/platform-image/src/image_provider/transport.rs b/server-rs/crates/platform-image/src/image_provider/transport/client.rs similarity index 97% rename from server-rs/crates/platform-image/src/image_provider/transport.rs rename to server-rs/crates/platform-image/src/image_provider/transport/client.rs index 69117c14d..51bb6c71c 100644 --- a/server-rs/crates/platform-image/src/image_provider/transport.rs +++ b/server-rs/crates/platform-image/src/image_provider/transport/client.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use super::{ +use crate::image_provider::{ error::PlatformImageError, types::{ImageProviderClient, ImageProviderSettings}, }; diff --git a/server-rs/crates/platform-image/src/image_provider/curl_transport.rs b/server-rs/crates/platform-image/src/image_provider/transport/curl.rs similarity index 99% rename from server-rs/crates/platform-image/src/image_provider/curl_transport.rs rename to server-rs/crates/platform-image/src/image_provider/transport/curl.rs index b1865fd02..41ac2a426 100644 --- a/server-rs/crates/platform-image/src/image_provider/curl_transport.rs +++ b/server-rs/crates/platform-image/src/image_provider/transport/curl.rs @@ -6,7 +6,7 @@ use curl::{ }; use serde_json::Value; -use super::{ +use crate::image_provider::{ audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, request::build_prompt_with_negative, types::ReferenceImage, }; diff --git a/server-rs/crates/platform-image/src/image_provider/transport/mod.rs b/server-rs/crates/platform-image/src/image_provider/transport/mod.rs new file mode 100644 index 000000000..b836cc66a --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/transport/mod.rs @@ -0,0 +1,6 @@ +#[path = "client.rs"] +pub(crate) mod client; +#[path = "curl.rs"] +pub(crate) mod curl; + +pub use client::{build_image_http_client, build_image_provider_client}; diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index d30a32c09..654062eac 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -2,25 +2,29 @@ pub mod generated_asset_sheets; pub mod generated_assets; pub mod image_provider; pub mod pixel_art_snapper; +pub mod raw_image_edit; pub use image_provider::{ - DownloadedImage, GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, GPT_IMAGE_2_5_BUSINESS_NAME, - GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, - GPT_IMAGE_2_MODEL, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings, - NANOBANANA_2_MODEL, PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, - RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, - RawImageEditDimensionError, RawImageEditImage, RawImageEditOptions, RawImageEditResult, - ReferenceImage, TIANTOKEN_PROVIDER, VECTOR_ENGINE_PROVIDER, build_image_http_client, + DownloadedImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, + GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, + ImageProvider, ImageProviderClient, ImageProviderSettings, NANOBANANA_2_MODEL, + PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage, + TIANTOKEN_PROVIDER, VECTOR_ENGINE_PROVIDER, build_image_http_client, build_image_provider_client, build_image_request_body, build_nanobanana_generate_content_request_body, create_image_edit, create_image_edit_with_references, create_image_edit_with_references_and_model, create_image_generation, create_image_generation_with_model, - create_nanobanana_generate_content, create_raw_image_edit, download_remote_image, - images_edit_url, images_generation_url, nanobanana_generate_content_url, - resolve_image_provider, validate_raw_image_edit_dimensions, + create_nanobanana_generate_content, download_remote_image, images_edit_url, + images_generation_url, nanobanana_generate_content_url, resolve_image_provider, }; pub use pixel_art_snapper::{ PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT, PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art_strict_with_deadline, snap_pixel_art_with_deadline, }; +pub use raw_image_edit::{ + GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, + RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, RawImageEditDimensionError, RawImageEditImage, + RawImageEditOptions, RawImageEditResult, create_raw_image_edit, + validate_raw_image_edit_dimensions, +}; diff --git a/server-rs/crates/platform-image/src/image_provider/raw_edit.rs b/server-rs/crates/platform-image/src/raw_image_edit/mod.rs similarity index 98% rename from server-rs/crates/platform-image/src/image_provider/raw_edit.rs rename to server-rs/crates/platform-image/src/raw_image_edit/mod.rs index d715a0200..8b80752fd 100644 --- a/server-rs/crates/platform-image/src/image_provider/raw_edit.rs +++ b/server-rs/crates/platform-image/src/raw_image_edit/mod.rs @@ -4,17 +4,16 @@ use bytes::Bytes; use reqwest::multipart::{Form, Part}; use serde::Deserialize; -use super::{ - audit::build_failure_audit, - budget::{effective_request_timeout_ms, request_budget_exhausted_error}, +use crate::image_provider::{ constants::{ GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE, GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, VECTOR_ENGINE_PROVIDER, }, - error::PlatformImageError, - request::images_edit_url, - types::ImageProviderSettings, - util::truncate_raw, + images_edit_url, + runtime::{ + ImageProviderSettings, PlatformImageError, build_failure_audit, + effective_request_timeout_ms, request_budget_exhausted_error, truncate_raw, + }, }; #[derive(Clone, Debug)] -- 2.52.0 From beb3c992407691d907c840f6d2c86bae4e984a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 23:28:45 +0800 Subject: [PATCH 06/80] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20GPT=20Image=202.5=20?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E4=B8=8E=E5=B7=A5=E5=85=B7=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新 OpenAPI 与外部编辑器模型说明 补充历史模型兼容解析与参数回退告警约定 将现役 ImageGen 工具脚本切换到 GPT Image 2.5 --- .../references/api-operations.md | 2 +- .codex/skills/gpt-image-2-apimart/SKILL.md | 12 ++--- .../generate-anthro-cat-illustrations.mjs | 47 ++----------------- .../scripts/generate-template-samples.mjs | 47 ++----------------- ... Image 2.5模型路由与历史值兼容-2026-09-18.md | 2 + .../genarrative-external-v1.openapi.json | 14 +++--- ...’】GPT Image 2.5 provider边界重构-2026-09-18.md | 23 ++++++++- 7 files changed, 45 insertions(+), 102 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index a744d8589..074bea5a0 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -102,7 +102,7 @@ Use OpenAPI as the final authority; these common values are a routing aid: - Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it. - External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. -- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. +- Image `model`: `gpt-image-2.5`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. Persisted `gpt-image-2` / `gpt-image-2-c` are legacy values resolved only when submitting a new task. - Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`. - Image `imageSize`: `0.5K`, `1K`, `2K`. - Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`. diff --git a/.codex/skills/gpt-image-2-apimart/SKILL.md b/.codex/skills/gpt-image-2-apimart/SKILL.md index f350d98a5..38baf072c 100644 --- a/.codex/skills/gpt-image-2-apimart/SKILL.md +++ b/.codex/skills/gpt-image-2-apimart/SKILL.md @@ -1,11 +1,11 @@ --- name: gpt-image-2-apimart -description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow with gpt-image-2-c fallback. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical. +description: Generate or inspect project image assets through this repository's image workflow using the GPT Image 2.5 business model. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical. --- -# gpt-image-2 VectorEngine +# GPT Image 2.5 project image workflow -Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine image path. Keep the product/price model identifier and primary provider request as `gpt-image-2`, then fall back once to `gpt-image-2-c` for eligible provider failures. The folder still contains `apimart` in its name for compatibility with existing local plugin references. +Use this skill for project-local image asset generation that must match the repository's image request contract. Use the business model identifier `gpt-image-2.5`; provider concrete model routing is owned by `server-rs`, and this client must not perform a cross-model fallback. The folder still contains `apimart` in its name for compatibility with existing local plugin references. ## Workflow @@ -40,7 +40,7 @@ Default body: ```json { - "model": "gpt-image-2", + "model": "gpt-image-2.5", "prompt": "", "n": 1, "size": "1024x1024" @@ -58,14 +58,14 @@ Content-Type: multipart/form-data Multipart fields: ```text -model=gpt-image-2 +model=gpt-image-2.5 prompt= n=1 size=1024x1024 image=@reference.png ``` -In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths prefer `gpt-image-2`; on an eligible upstream/model failure they retry with `gpt-image-2-c`. Do not fall back for authentication, local validation, request-budget exhaustion, uncertain send/connection failure, content-safety rejection, or a generated image URL download failure. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part. +In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths send the business model `gpt-image-2.5`; provider routing and retry policy remain server-owned. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part. Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints. diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs index 457374380..97bbae547 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs @@ -9,8 +9,7 @@ const skillRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(skillRoot, '..', '..', '..'); const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations'); const defaultTimeoutMs = 1000000; -const preferredImageModel = 'gpt-image-2'; -const fallbackImageModel = 'gpt-image-2-c'; +const preferredImageModel = 'gpt-image-2.5'; const prompts = [ { @@ -256,41 +255,8 @@ async function fetchJson(url, options, timeoutMs) { } } -function shouldFallbackImageModel(error) { - const raw = - `${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase(); - if (error?.vectorEngineResponseParse) { - return !containsContentRejection(raw); - } - const status = Number(error?.vectorEngineStatus || 0); - if (status === 408 || status >= 500) { - return true; - } - if (status === 429) { - return !containsContentRejection(raw); - } - const mentionsImageModel = - raw.includes('model') || - raw.includes('模型') || - raw.includes(preferredImageModel) || - raw.includes(fallbackImageModel); - return ( - [400, 404, 422].includes(status) && - mentionsImageModel && - /(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test( - raw, - ) - ); -} - -function containsContentRejection(raw) { - return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test( - raw, - ); -} - async function requestImagePayload(env, entry) { - for (const model of [preferredImageModel, fallbackImageModel]) { + for (const model of [preferredImageModel]) { const requestBody = { model, prompt: buildPrompt(entry), @@ -322,12 +288,7 @@ async function requestImagePayload(env, entry) { error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); throw error; } catch (error) { - if (model !== preferredImageModel || !shouldFallbackImageModel(error)) { - throw error; - } - console.warn( - `VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`, - ); + throw error; } } throw new Error(`VectorEngine returned no image for ${entry.id}`); @@ -408,7 +369,7 @@ if (dryRun) { requests: selectedPrompts.map((entry) => ({ id: entry.id, title: entry.title, - fallbackModel: fallbackImageModel, + fallbackModel: null, body: { model: preferredImageModel, prompt: buildPrompt(entry), diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs index bb1354921..aa39d5bb7 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs @@ -18,8 +18,7 @@ const defaultOutDir = path.join( 'puzzle-creation-templates', ); const defaultTimeoutMs = 1000000; -const preferredImageModel = 'gpt-image-2'; -const fallbackImageModel = 'gpt-image-2-c'; +const preferredImageModel = 'gpt-image-2.5'; const args = new Map(); for (let index = 2; index < process.argv.length; index += 1) { @@ -226,41 +225,8 @@ async function fetchJson(url, options, timeoutMs) { } } -function shouldFallbackImageModel(error) { - const raw = - `${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase(); - if (error?.vectorEngineResponseParse) { - return !containsContentRejection(raw); - } - const status = Number(error?.vectorEngineStatus || 0); - if (status === 408 || status >= 500) { - return true; - } - if (status === 429) { - return !containsContentRejection(raw); - } - const mentionsImageModel = - raw.includes('model') || - raw.includes('模型') || - raw.includes(preferredImageModel) || - raw.includes(fallbackImageModel); - return ( - [400, 404, 422].includes(status) && - mentionsImageModel && - /(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test( - raw, - ) - ); -} - -function containsContentRejection(raw) { - return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test( - raw, - ); -} - async function requestImagePayload(env, template) { - for (const model of [preferredImageModel, fallbackImageModel]) { + for (const model of [preferredImageModel]) { const requestBody = { model, prompt: buildPrompt(template), @@ -294,12 +260,7 @@ async function requestImagePayload(env, template) { error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); throw error; } catch (error) { - if (model !== preferredImageModel || !shouldFallbackImageModel(error)) { - throw error; - } - console.warn( - `VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`, - ); + throw error; } } throw new Error(`VectorEngine returned no image for ${template.id}`); @@ -384,7 +345,7 @@ if (dryRun) { requests: selectedTemplates.map((template) => ({ id: template.id, title: template.title, - fallbackModel: fallbackImageModel, + fallbackModel: null, body: { model: preferredImageModel, prompt: buildPrompt(template), diff --git a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md index e87f9338b..3b061f49e 100644 --- a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md +++ b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md @@ -8,6 +8,8 @@ 普通主站前端只接触业务模型和新生成展示名 `GPT Image 2.5`,admin Web/API 可以查看和编辑两个具体定价 key;普通生成即使因参考图使用 edits multipart,仍按生成 concrete model。旧 `gpt-image-2-c` 审计记录原样保留,新代码不再跨模型或跨 provider fallback。 +主站前端只把原本明确使用 GPT Image 2 的专用新任务改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为保持不变。画布参数或编辑布局读到 `gpt-image-2` / `gpt-image-2-c` 时,在使用端解析为 `gpt-image-2.5` 并触发参数迁移警告,原始资源、审计、metadata 和历史 fixture 不回写。AGC 现有资源生成界面与请求默认保持不变,不因本决策新增模型字段、选择器或尺寸行为。 + ## Consequences - 定价配置的活动 key 是两个具体 provider model;旧单 key 配置只允许受控 backfill,并留下兼容 TODO。 diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index a70761fa3..dc76e0815 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3017,7 +3017,7 @@ }, "model": { "type": "string", - "description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时沿用编辑器默认。" + "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 / gpt-image-2-c 仅作为历史值在新任务提交边界兼容解析。未传时沿用编辑器默认。" }, "aspectRatio": { "type": "string", @@ -3033,7 +3033,7 @@ "type": "array", "items": { "type": "string", - "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 gpt-image-2 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。" + "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 GPT Image 2.5 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。" }, "maxItems": 9 }, @@ -3170,7 +3170,7 @@ }, "model": { "type": "string", - "description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。" + "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 / gpt-image-2-c 仅作为历史值兼容解析。" }, "aspectRatio": { "type": "string", @@ -3184,7 +3184,7 @@ "type": "array", "items": { "type": "string", - "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" + "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" }, "maxItems": 8 }, @@ -3373,7 +3373,7 @@ "type": "array", "items": { "type": "string", - "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" + "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" }, "maxItems": 8 }, @@ -3489,13 +3489,13 @@ "model": { "type": "string", "default": "gemini-3.1-flash-image-preview", - "description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时默认使用 nanobanana。" + "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 / gpt-image-2-c 仅作为历史值兼容解析。未传时默认使用 nanobanana。" }, "referenceImageSrcs": { "type": "array", "items": { "type": "string", - "description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。" + "description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。" }, "maxItems": 5 }, diff --git a/docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md b/docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md index d342b69f5..a196d49f1 100644 --- a/docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md +++ b/docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md @@ -1,6 +1,6 @@ # GPT Image 2.5 provider 边界重构实施计划 -- Version: 2 +- Version: 3 - Status: active - Date: 2026-09-18 - Parent Milestone: `docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md` @@ -14,6 +14,21 @@ 5. 迁移 api-server、Agent、raw edit、角色/图标/UI 入口和测试;核对 pricing/admin/public DTO 可见性。 6. 删除跨模型/跨 provider fallback 分支,保留同 concrete model retry。 +## 前端同步边界(已确认) + +1. 主站 `src/components/image-editor/` 中,凡是原本明确写死 `gpt-image-2` 的 GPT 专用新任务(快速编辑、UI 设计、宣发、规范及对应提交/锁定模型)统一改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为不变。 +2. `normalizeEditorImageModel` 在编辑面板、画布生成参数和历史布局恢复的使用端把 `gpt-image-2` 与 `gpt-image-2-c` 解析为 `gpt-image-2.5`。该兼容命中必须触发既有参数回退警告;未知模型仍按原有无效参数处理。原始资源、审计、metadata 和历史 fixture 不回写。 +3. 主站新请求的 `model` 字段只发送业务模型值或 nanobanana 业务值,不发送 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 等 concrete provider model。 +4. AGC(`apps/ai-game-creator-shell/src` 及其 Tauri 本地资源生成链路)本里程碑不增加模型字段、选择器或尺寸行为;继续使用现有请求默认值。AGC 代码只在确属历史兼容读取/测试证明的位置保留旧值,不借本次同步引入新功能。 +5. OpenAPI、External editor skill reference、现役脚本和当前技术方案中的“新任务使用 GPT Image 2 / 2-c fallback”改为 GPT Image 2.5 口径;静态已生成 manifest、历史审计和兼容 fixture 保持旧值并补充历史语义断言。 + +## 前端实现顺序 + +1. 先在 `ImageCanvasGenerationModel` 集中定义历史别名解析与迁移警告结果,保持 nanobanana 默认值、尺寸矩阵和可选模型顺序不变。 +2. 迁移 `ImageCanvasGenerationDialogModel`、`ImageCanvasGenerationSubmissionModel`、生成工作流、快速编辑弹窗、规范/宣发面板的 GPT 专用默认、锁定值和提交 payload。 +3. 为 `generationInputs` / 画布布局 / 资源历史恢复补齐旧值迁移警告测试;确认新请求字段为 `gpt-image-2.5`,而历史原文仍未被改写。 +4. 更新 OpenAPI 与现役工具/专题文档,区分业务模型、concrete provider model、历史值和产品展示名;不得把 AGC 默认行为改成前端显式模型选择。 + ## 验证命令 - `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` @@ -24,8 +39,12 @@ - `npm run check:doc-index` - `npm run check:encoding` - `git diff --check` +- 主站图片编辑定向测试:模型选项/展示名、GPT 专用提交值、历史 `gpt-image-2` 与 `gpt-image-2-c` 恢复及警告、nanobanana 默认回归。 +- AGC 定向测试:确认未新增 `model` IPC 入参、未改变原有尺寸/UI 行为,现有资源生成请求继续依赖服务端默认。 +- OpenAPI/External editor 契约测试:模型说明、参考图容量说明和新业务模型值一致,provider concrete model 不进入普通前端契约。 ## 风险与回滚 - 风险:启动阶段依赖变化、历史任务兼容解析遗漏、nanobanana 被误路由到 Tiantoken、provider key 泄露到公开 DTO。 -- 回滚:以 provider-neutral seam、启动配置、model route、调用方迁移四个局部提交边界回滚;不执行数据库历史迁移。 +- 风险:主站把 nanobanana 的默认路径误改成 GPT Image 2.5,或把历史兼容值静默吞掉导致用户无法识别参数迁移。 +- 回滚:以 provider-neutral seam、启动配置、model route、主站前端同步四个局部提交边界回滚;不执行数据库历史迁移,也不回滚历史资源值。 -- 2.52.0 From f4315ffd239c5f5d833824a4a90d0ffd803dd3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 23:30:48 +0800 Subject: [PATCH 07/80] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=BB=E7=AB=99=20GP?= =?UTF-8?q?T=20Image=202.5=20=E7=94=9F=E6=88=90=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 GPT 专用新任务与锁定模型切换为 gpt-image-2.5 保留 nanobanana 普通生成默认行为 将历史 gpt-image-2 与 gpt-image-2-c 归一并触发参数回退告警 --- .../ImageCanvasEditGenerationModalView.tsx | 7 +++++-- .../ImageCanvasGenerationDialogModel.ts | 10 +++++----- .../ImageCanvasGenerationModel.ts | 19 +++++++++++++++++++ .../ImageCanvasGenerationSubmissionModel.ts | 12 ++++++------ ...anvasPublicationMaterialsDemoPanelView.tsx | 6 +++--- .../ImageCanvasSpecGenerationPanelView.tsx | 6 +++--- .../useImageCanvasGenerationWorkflow.ts | 4 ++-- 7 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx index 83e7bcb00..b2b48f37f 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx @@ -3,7 +3,10 @@ import { type Dispatch, type SetStateAction } from 'react'; import { UnifiedModal } from '../common/UnifiedModal'; import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; import type { GenerateDialogState } from './ImageCanvasEditorTypes'; -import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; +import { + calculateEditorImageGenerationPrice, + IMAGE_MODEL_GPT_IMAGE_2_5, +} from './ImageCanvasGenerationModel'; type ImageCanvasEditGenerationModalViewProps = { dialog: GenerateDialogState | null; @@ -17,7 +20,7 @@ export function ImageCanvasEditGenerationModalView({ onSubmit, }: ImageCanvasEditGenerationModalViewProps) { const isOpen = dialog?.mode === 'edit' && dialog.status !== 'generating'; - const dialogImageModel = dialog?.imageModel ?? 'gpt-image-2'; + const dialogImageModel = dialog?.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2_5; const dialogImageSize = dialog?.imageSize ?? '1K'; const editPrice = calculateEditorImageGenerationPrice({ kind: 'quick-edit', diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 6c6a5fb0f..ef6b72ad9 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -43,7 +43,7 @@ import { EDITOR_IMAGE_DIMENSION_OPTIONS, EDITOR_IMAGE_MODEL_OPTIONS, formatGenerationInputValue, - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, inferEditorImageAspectRatio, inferEditorImageSizeLabel, isNormalizedCanvasGenerationInputs, @@ -426,7 +426,7 @@ export function createPublicationGenerationDialogDraft({ publicationWorkflowId: workflowId, publicationGameInfo: { ...DEFAULT_PUBLICATION_GAME_INFO }, publicationReferences: [], - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: workflow.aspectRatio, imageSize: workflow.imageSize, placeholder: { @@ -1149,7 +1149,7 @@ function createNormalizedGenerationDialogDraft({ imageModel: getNormalizedString( sourceLayer, 'model', - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, ), }), prompt, @@ -1757,7 +1757,7 @@ export function createSameSourceGenerationDialogDraft({ ...createUiDesignGenerationDialogDraft({ canvasSize, viewport, - imageModel: sourceDialog?.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2, + imageModel: sourceDialog?.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2_5, }), prompt, sourceLayerId: sourceLayer.id, @@ -2000,7 +2000,7 @@ export function createQuickEditGenerationDialogDraft({ assetLabel, status = 'idle', references = [], - model = IMAGE_MODEL_GPT_IMAGE_2, + model = IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio, imageSize, frame, diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index d263af996..8b6fa5273 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -44,6 +44,7 @@ import { // 中文注释:与 api-server/src/editor_generation_config.rs 保持同名模型定价语义,前端仅作为展示兜底。 export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; +export const IMAGE_MODEL_GPT_IMAGE_2_C = 'gpt-image-2-c'; export const IMAGE_MODEL_GPT_IMAGE_2_5 = 'gpt-image-2.5'; export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2; @@ -638,9 +639,23 @@ export function normalizeEditorImageModel(model: string | null | undefined) { if (IMAGE_MODEL_NANOBANANA_ALIASES.has(normalizedModel)) { return IMAGE_MODEL_NANOBANANA2; } + if ( + normalizedModel === IMAGE_MODEL_GPT_IMAGE_2 || + normalizedModel === IMAGE_MODEL_GPT_IMAGE_2_C + ) { + return IMAGE_MODEL_GPT_IMAGE_2_5; + } return normalizedModel; } +export function isLegacyEditorImageModel(model: string | null | undefined) { + const normalizedModel = model?.trim(); + return ( + normalizedModel === IMAGE_MODEL_GPT_IMAGE_2 || + normalizedModel === IMAGE_MODEL_GPT_IMAGE_2_C + ); +} + export function getEditorImageModelDisplayName( model: string | null | undefined, ) { @@ -1398,6 +1413,7 @@ export function decodeCanvasGenerationInputs( defaultAspectRatio: string; fixedAspectRatios?: readonly string[]; }) => { + const rawModel = fields.find((field) => field.id === 'model')?.value; const imageModels = EDITOR_IMAGE_MODEL_OPTIONS.map( (option) => option.value, ); @@ -1408,6 +1424,9 @@ export function decodeCanvasGenerationInputs( defaultModel, normalizeEditorImageModel, ); + if (typeof rawModel === 'string' && isLegacyEditorImageModel(rawModel)) { + addFallbackWarning(['model']); + } const dimensions = EDITOR_IMAGE_DIMENSION_OPTIONS[ model as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 6c14a925d..7da42ab9a 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -51,7 +51,7 @@ import { DEFAULT_VIDEO_WEB_SEARCH_ENABLED, EDITOR_IMAGE_DIMENSION_OPTIONS, EDITOR_IMAGE_MODEL_OPTIONS, - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, inferEditorImageAspectRatio, inferEditorImageSizeLabel, isQuickEditSupportedLayer, @@ -584,17 +584,17 @@ export function buildImageGenerationSubmissionPlan({ const basePrompt = dialog.prompt.trim() || '快速编辑图片'; const normalizedQuickEditPrompt = basePrompt; const requestedImageModel = normalizeEditorImageModel( - dialog.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2, + dialog.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2_5, ); const imageModel = EDITOR_IMAGE_MODEL_OPTIONS.some( (option) => option.value === requestedImageModel, ) ? requestedImageModel - : IMAGE_MODEL_GPT_IMAGE_2; + : IMAGE_MODEL_GPT_IMAGE_2_5; const dimensionOptions = EDITOR_IMAGE_DIMENSION_OPTIONS[ imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS - ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_GPT_IMAGE_2]; + ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_GPT_IMAGE_2_5]; const supportedAspectRatios = dimensionOptions.aspectRatios as readonly string[]; const supportedImageSizes = @@ -852,7 +852,7 @@ export function buildImageGenerationSubmissionPlan({ } if (dialog.mode === 'ui-design') { - const imageModel = IMAGE_MODEL_GPT_IMAGE_2; + const imageModel = IMAGE_MODEL_GPT_IMAGE_2_5; const referenceImageSrcs = [ dialog.uiDesignSpecReference ? resolveImageReferenceSubmissionSource(dialog.uiDesignSpecReference) @@ -897,7 +897,7 @@ export function buildImageGenerationSubmissionPlan({ const workflow = getPublicationMaterialsWorkflow( dialog.publicationWorkflowId ?? 'publication-cover-image', ); - const imageModel = IMAGE_MODEL_GPT_IMAGE_2; + const imageModel = IMAGE_MODEL_GPT_IMAGE_2_5; const publicationPrompt = buildPublicationMaterialsPrompt(dialog.publicationGameInfo) || normalizedPrompt; diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx index 74c491470..e9ae251f3 100644 --- a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx @@ -26,7 +26,7 @@ import { buildPublicationMaterialsPrompt, calculateEditorImageModelPrice, DEFAULT_PUBLICATION_GAME_INFO, - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, } from './ImageCanvasGenerationModel'; import type { PublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; @@ -299,10 +299,10 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({ includeDimensions={false} onRememberImageModel={onRememberImageModel} hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} - lockedModel={IMAGE_MODEL_GPT_IMAGE_2} + lockedModel={IMAGE_MODEL_GPT_IMAGE_2_5} optionLabelPrefix="宣发素材" cost={calculateEditorImageModelPrice( - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, workflow.imageSize, )} submitLabel="生成" diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index 877fea08a..160910e94 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -51,7 +51,7 @@ import { calculateEditorUiDesignPrice, CHARACTER_SPEC_VIEW_OPTIONS, getEditorImageModelDisplayName, - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, resolveEditorImageSizeLabel, SPEC_GENERATION_ASPECT_RATIO, SPEC_GENERATION_IMAGE_SIZE, @@ -758,9 +758,9 @@ export function ImageCanvasSpecGenerationPanelView({ includeDimensions onRememberImageModel={onRememberImageModel} hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} - lockedModel={IMAGE_MODEL_GPT_IMAGE_2} + lockedModel={IMAGE_MODEL_GPT_IMAGE_2_5} cost={calculateEditorUiDesignPrice( - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, dialog.imageSize, )} submitLabel="生成" diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 67341c4c6..6b2ecf8f5 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -99,7 +99,7 @@ import { decodeCanvasGenerationInputs, DEFAULT_IMAGE_MODEL, EDITOR_IMAGE_DIMENSION_OPTIONS, - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, isCanvasGenerationDialog, isQuickEditSupportedLayer, normalizeEditorImageModel, @@ -1702,7 +1702,7 @@ export function useImageCanvasGenerationWorkflow({ const draft = createUiDesignGenerationDialogDraft({ canvasSize, viewport, - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, }); openPlacedCanvasGenerationDialog({ ...draft, -- 2.52.0 From 5b1b0ddb9a5813bb79bd20d648c30cfab7ae1625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 23:31:47 +0800 Subject: [PATCH 08/80] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20GPT=20Image=202.5=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新模型标签、提交 payload 与固定模型断言 覆盖 gpt-image-2-c 历史恢复及参数回退告警 保留历史 fixture 原值并验证 nanobanana 默认不变 --- ...CanvasBasicGenerationComposerView.test.tsx | 6 +-- ...asCharacterGenerationComposerView.test.tsx | 6 +-- ...CanvasEditorGenerationIntegration.test.tsx | 6 +-- ...ImageCanvasGenerationComposerView.test.tsx | 2 +- .../ImageCanvasGenerationDialogModel.test.ts | 25 +++++----- ...eCanvasGenerationImageOptionsView.test.tsx | 23 ++++++---- .../ImageCanvasGenerationModel.test.ts | 46 +++++++++++++++++-- ...ageCanvasGenerationSubmissionModel.test.ts | 25 +++++----- ...CanvasIconSpritesheetComposerView.test.tsx | 6 +-- ...PublicationMaterialsDemoPanelView.test.tsx | 4 +- .../ImageCanvasQuickEditPanelView.test.tsx | 6 +-- ...mageCanvasSpecGenerationPanelView.test.tsx | 4 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 10 ++-- 13 files changed, 106 insertions(+), 63 deletions(-) diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx index 7cd021358..60e5d8e6c 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx @@ -232,10 +232,10 @@ describe('ImageCanvasBasicGenerationComposerView', () => { ); const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' }); fireEvent.click( - within(modelPanel).getByRole('button', { name: /gpt-image-2/ }), + within(modelPanel).getByRole('button', { name: /GPT Image 2\.5/ }), ); - expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2'); + expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2.5'); const submitButton = within(panel).getByRole('button', { name: '生成' }); expect(submitButton.textContent).toBe('生成5泥点'); }); @@ -284,7 +284,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => { ).toBeTruthy(); expect( within(panel).getByRole('button', { - name: '快速编辑图片模型 gpt-image-2', + name: '快速编辑图片模型 GPT Image 2.5', }), ).toBeTruthy(); expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx index 05a538ab8..200498fa9 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx @@ -14,7 +14,7 @@ import type { SpecGenerationType, UploadTarget, } from './ImageCanvasEditorTypes'; -import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel'; +import { IMAGE_MODEL_GPT_IMAGE_2_5 } from './ImageCanvasGenerationModel'; function createDialog( patch: Partial = {}, @@ -165,9 +165,9 @@ describe('ImageCanvasCharacterGenerationComposerView', () => { fireEvent.click( screen.getByRole('button', { name: '生成图片模型 nanobanana2' }), ); - fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' })); + fireEvent.click(screen.getByRole('button', { name: 'GPT Image 2.5' })); - expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); + expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2_5); expect(screen.getByRole('button', { name: '生成' }).textContent).toBe( '生成3泥点', ); diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 004c9d5bd..d07e9a478 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -1807,7 +1807,7 @@ describe('ImageCanvasEditorView generation integration', () => { const characterPanel = screen.getByRole('dialog', { name: '生成角色形象', }); - selectGenerationModel(characterPanel, 'gpt-image-2'); + selectGenerationModel(characterPanel, 'GPT Image 2.5'); selectGenerationDimensions(characterPanel, '2:3', '2K'); setPlainTextEditorValue( within(characterPanel).getByLabelText('角色设定'), @@ -1822,7 +1822,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect.objectContaining({ kind: 'character', prompt: '蓝衣剑士', - model: 'gpt-image-2', + model: 'gpt-image-2.5', aspectRatio: '2:3', imageSize: '2K', }), @@ -1838,7 +1838,7 @@ describe('ImageCanvasEditorView generation integration', () => { const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); expect( within(iconPanel).getByRole('button', { - name: '生成图片模型 gpt-image-2', + name: '生成图片模型 GPT Image 2.5', }), ).toBeTruthy(); fireEvent.click( diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index ceb4edc9e..582b7f9da 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -445,7 +445,7 @@ describe('ImageCanvasGenerationComposerView', () => { ).toBeTruthy(); expect( within(panel).getByRole('button', { - name: '快速编辑图片模型 gpt-image-2', + name: '快速编辑图片模型 GPT Image 2.5', }), ).toBeTruthy(); expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index c1b69ca4e..9787afda4 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -40,6 +40,7 @@ import { } from './ImageCanvasGenerationDialogModel'; import { IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; @@ -187,7 +188,7 @@ describe('ImageCanvasGenerationDialogModel', () => { ).toMatchObject({ mode: 'character', style: 'none', - imageModel: 'gpt-image-2', + imageModel: 'gpt-image-2.5', aspectRatio: '1:1', imageSize: '1K', characterSpecReference: null, @@ -260,7 +261,7 @@ describe('ImageCanvasGenerationDialogModel', () => { }), ).toMatchObject({ mode: 'ui-design', - imageModel: 'gpt-image-2', + imageModel: 'gpt-image-2.5', aspectRatio: '16:9', imageSize: '1K', uiDesignSpecReference: null, @@ -465,7 +466,7 @@ describe('ImageCanvasGenerationDialogModel', () => { status: 'idle', composerOpen: true, sourceLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: 'gpt-image-2.5', aspectRatio: '4:3', imageSize: '1K', }); @@ -475,7 +476,7 @@ describe('ImageCanvasGenerationDialogModel', () => { size: '1024x768', aspectRatio: '4:3', imageSize: '1K', - model: 'gpt-image-2', + model: 'gpt-image-2.5', quickEditReferences: [], status: 'idle', }); @@ -516,7 +517,7 @@ describe('ImageCanvasGenerationDialogModel', () => { expect(createEditDialogDraft(sourceLayer)).toMatchObject({ prompt: '改成雨天', - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', }); @@ -737,7 +738,7 @@ describe('ImageCanvasGenerationDialogModel', () => { mode: 'generate', prompt: '普通图', sourceLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: 'gpt-image-2.5', aspectRatio: '2:3', imageSize: '2K', style: 'none', @@ -778,7 +779,7 @@ describe('ImageCanvasGenerationDialogModel', () => { }), ).toMatchObject({ mode, - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', placeholder: { @@ -815,7 +816,7 @@ describe('ImageCanvasGenerationDialogModel', () => { mode: 'generate', prompt: '元数据提示词', generatedLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: 'gpt-image-2.5', placeholder: { x: 120, y: 140, @@ -1149,7 +1150,7 @@ describe('ImageCanvasGenerationDialogModel', () => { ).toMatchObject({ mode: 'generate', prompt: '', - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', }); @@ -1204,7 +1205,7 @@ describe('ImageCanvasGenerationDialogModel', () => { prompt: '雨夜海边车站', sceneStylePreset: 'custom', sceneCustomStyle: '复古像素风', - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '16:9', imageSize: '2K', generationReferences: [ @@ -1273,7 +1274,7 @@ describe('ImageCanvasGenerationDialogModel', () => { mode: 'scene', prompt: '雨夜海边车站', sceneStylePreset: 'watercolor', - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '16:9', imageSize: '2K', }); @@ -1394,7 +1395,7 @@ describe('ImageCanvasGenerationDialogModel', () => { imageSize: '2K', }), ).toMatchObject({ - model: IMAGE_MODEL_GPT_IMAGE_2, + model: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '16:9', imageSize: '2K', }); diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx index 72b1ad1e7..0f19fbe58 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx @@ -9,6 +9,7 @@ import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationIm import { calculateEditorImageGenerationPrice, IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; @@ -226,17 +227,19 @@ describe('ImageCanvasGenerationImageOptionsView', () => { expect( within(panel).getByRole('button', { name: 'nanobanana2' }), ).toBeTruthy(); - fireEvent.click(within(panel).getByRole('button', { name: 'gpt-image-2' })); + fireEvent.click( + within(panel).getByRole('button', { name: 'GPT Image 2.5' }), + ); expect(screen.getByRole('menu', { name: '生成图片模型选项' })).toBeTruthy(); - expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); + expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2_5); expect(screen.getByLabelText('当前模型').textContent).toBe( - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, ); expect(screen.getByLabelText('当前比例').textContent).toBe('9:16'); expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K'); expect( - screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }), + screen.getByRole('button', { name: '生成图片模型 GPT Image 2.5' }), ).toBeTruthy(); expect( screen.getByRole('button', { name: '生成角色形象' }).textContent, @@ -261,7 +264,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => { ); const modelButton = screen.getByRole('button', { - name: '生成图片模型 gpt-image-2', + name: '生成图片模型 GPT Image 2.5', }) as HTMLButtonElement; expect(modelButton.disabled).toBe(true); @@ -302,12 +305,12 @@ describe('ImageCanvasGenerationImageOptionsView', () => { fireEvent.click( within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole( 'button', - { name: 'gpt-image-2' }, + { name: 'GPT Image 2.5' }, ), ); expect(alertMock).toHaveBeenCalledWith( - '当前已有 8 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换', + '当前已有 8 张参考图,GPT Image 2.5 最多允许 4 张,请先删除多余参考图后再切换', ); expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8'); expect(screen.getByLabelText('当前模型').textContent).toBe( @@ -338,7 +341,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => { fireEvent.click( within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole( 'button', - { name: 'gpt-image-2' }, + { name: 'GPT Image 2.5' }, ), ); @@ -412,12 +415,12 @@ describe('ImageCanvasGenerationImageOptionsView', () => { fireEvent.click( within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole( 'button', - { name: 'gpt-image-2' }, + { name: 'GPT Image 2.5' }, ), ); expect(screen.getByLabelText('当前模型').textContent).toBe( - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, ); expect(screen.getByLabelText('当前比例').textContent).toBe('4:3'); expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K'); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 5a08c5c05..fdfb344ca 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -48,6 +48,8 @@ import { getGenerationFrameAriaLabel, getGenerationFrameLabel, IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, + IMAGE_MODEL_GPT_IMAGE_2_C, IMAGE_MODEL_NANOBANANA2, isNormalizedCanvasGenerationInputs, isQuickEditSupportedLayer, @@ -237,7 +239,7 @@ describe('ImageCanvasGenerationModel', () => { applyEditorGenerationPricingConfig({ models: { ...EDITOR_MODEL_MUD_POINT_CONFIG, - [IMAGE_MODEL_GPT_IMAGE_2]: { + [IMAGE_MODEL_GPT_IMAGE_2_5]: { unit: 'perGeneration', prices: { '1K': 29, @@ -690,7 +692,17 @@ describe('ImageCanvasGenerationModel', () => { imageSize: '2K', }), ); - expect(valid).toMatchObject({ ok: true, warnings: [] }); + expect(valid).toMatchObject({ + ok: true, + inputs: { + fields: expect.arrayContaining([ + { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2_5 }, + ]), + }, + warnings: expect.arrayContaining([ + expect.objectContaining({ fieldIds: ['model'] }), + ]), + }); const alias = decodeCanvasGenerationInputs({ version: 2, @@ -714,6 +726,32 @@ describe('ImageCanvasGenerationModel', () => { ).toBe(IMAGE_MODEL_NANOBANANA2); }); + it('restores the concrete legacy GPT image value to GPT Image 2.5 with a warning', () => { + const decoded = decodeCanvasGenerationInputs({ + version: 2, + action: 'image.generate', + fields: [ + { id: 'prompt', title: '生成提示词', value: '旧模型素材' }, + { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2_C }, + { id: 'aspectRatio', title: '比例', value: '1:1' }, + { id: 'imageSize', title: '尺寸', value: '1K' }, + ], + references: [], + }); + + expect(decoded).toMatchObject({ + ok: true, + inputs: { + fields: expect.arrayContaining([ + { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2_5 }, + ]), + }, + warnings: expect.arrayContaining([ + expect.objectContaining({ fieldIds: ['model'] }), + ]), + }); + }); + it('falls invalid image and workflow parameters back within V2', () => { const image = decodeCanvasGenerationInputs({ version: 2, @@ -932,14 +970,14 @@ describe('ImageCanvasGenerationModel', () => { label: 'nanobanana2', value: 'gemini-3.1-flash-image-preview', }, - { label: 'gpt-image-2', value: 'gpt-image-2' }, + { label: 'GPT Image 2.5', value: 'gpt-image-2.5' }, ]); expect(buildQuickEditModelOptions('nanobanana2')).toEqual([ { label: 'nanobanana2', value: 'gemini-3.1-flash-image-preview', }, - { label: 'gpt-image-2', value: 'gpt-image-2' }, + { label: 'GPT Image 2.5', value: 'gpt-image-2.5' }, ]); expect( isQuickEditSupportedLayer({ diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 7a7ec1dd8..f34db2f07 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -4,6 +4,7 @@ import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/e import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; import { @@ -333,14 +334,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => { expect(plan).toMatchObject({ kind: 'edit', editInput: { - model: IMAGE_MODEL_GPT_IMAGE_2, + model: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', size: '1365x2048', }, generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ - { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 }, + { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2_5 }, { id: 'aspectRatio', title: '比例', value: '2:3' }, { id: 'imageSize', title: '尺寸', value: '2K' }, ]), @@ -384,7 +385,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { sourceLayer, editInput: { size: '1024x768', - model: IMAGE_MODEL_GPT_IMAGE_2, + model: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '4:3', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,style'], @@ -420,7 +421,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ], }), }, - rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, + rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2_5, }); }); @@ -650,7 +651,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { input: { prompt: '白发骑士', kind: 'character', - model: 'gpt-image-2', + model: 'gpt-image-2.5', screenColor: 'auto', segModel: 'birefnet', style: 'pixelArt', @@ -665,7 +666,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { assetKind: 'character', title: '角色形象 9', }, - rememberImageModel: 'gpt-image-2', + rememberImageModel: 'gpt-image-2.5', }); expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( expect.objectContaining({ @@ -727,7 +728,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { normalizedPrompt: '设计移动端商城首页', input: { kind: 'ui-design', - model: IMAGE_MODEL_GPT_IMAGE_2, + model: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '16:9', imageSize: '1K', referenceImageSrcs: [ @@ -762,7 +763,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ], }), }, - rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, + rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2_5, }); }); @@ -802,7 +803,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { prompt: expect.stringContaining('【宣发素材类型】游戏首图'), size: '720x540', kind: 'publication-material', - model: 'gpt-image-2', + model: 'gpt-image-2.5', aspectRatio: '4:3', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,ref'], @@ -950,7 +951,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { expect(plan.input).toEqual( expect.objectContaining({ kind: 'publication-material', - model: 'gpt-image-2', + model: 'gpt-image-2.5', size: '1280x720', aspectRatio: '16:9', imageSize: '1K', @@ -1080,7 +1081,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { referenceId: 'resource-icon-spec', referenceImageSrcs: ['data:image/png;base64,ref'], iconDescriptions: ['返回按钮\n\n设置按钮'], - model: 'gpt-image-2', + model: 'gpt-image-2.5', screenColor: 'auto', segModel: 'birefnet', style: 'pixelArt', @@ -1110,7 +1111,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }, ], }), - rememberImageModel: 'gpt-image-2', + rememberImageModel: 'gpt-image-2.5', resultTitle: '冒险游戏图标', }); }); diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx index f1c4d4bfc..c44630bfe 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx @@ -249,11 +249,11 @@ describe('ImageCanvasIconSpritesheetComposerView', () => { ); const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' }); fireEvent.click( - within(modelPanel).getByRole('button', { name: 'gpt-image-2' }), + within(modelPanel).getByRole('button', { name: 'GPT Image 2.5' }), ); - expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2'); - expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2'); + expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2.5'); + expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2.5'); expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K'); expect(screen.getByRole('button', { name: '生成' }).textContent).toBe( '生成3泥点', diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx index d6f5f47ad..aabefce7e 100644 --- a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx @@ -49,14 +49,14 @@ function PublicationPanelHarness({ } describe('ImageCanvasPublicationMaterialsDemoPanelView', () => { - it('locks publication material model selection to gpt-image-2', () => { + it('locks publication material model selection to GPT Image 2.5', () => { const rememberImageModel = vi.fn(); render( , ); const modelButton = screen.getByRole('button', { - name: '宣发素材模型 gpt-image-2', + name: '宣发素材模型 GPT Image 2.5', }) as HTMLButtonElement; const description = screen.getByRole('textbox', { name: '游戏首图一句话描述游戏', diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx index 98f8e90a1..106c6230f 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx @@ -107,9 +107,9 @@ describe('ImageCanvasQuickEditPanelView', () => { screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), ); expect(screen.getByRole('button', { name: 'nanobanana2' })).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' })); - expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2'); - expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2'); + fireEvent.click(screen.getByRole('button', { name: 'GPT Image 2.5' })); + expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2.5'); + expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2.5'); }); it('renders the failure message inside the quick edit alert', () => { diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index 9a4121c39..b4839b7c4 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -197,7 +197,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { name: '生成图片尺寸 16:9·2K', }) as HTMLButtonElement; const modelButton = screen.getByRole('button', { - name: '生成图片模型 gpt-image-2', + name: '生成图片模型 GPT Image 2.5', }) as HTMLButtonElement; expect(dimensionsButton.disabled).toBe(true); @@ -256,7 +256,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ); const submitButton = screen.getByRole('button', { name: '生成UI设计图' }); const modelButton = screen.getByRole('button', { - name: '生成图片模型 gpt-image-2', + name: '生成图片模型 GPT Image 2.5', }) as HTMLButtonElement; expect(submitButton.textContent).toBe('生成3泥点'); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index b481fdb2c..a58a4d366 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1178,7 +1178,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { prompt: '把当前图改成雨天', sourceReferenceId: 'resource-source', size: '1024x768', - model: 'gpt-image-2', + model: 'gpt-image-2.5', targetLayerId: 'layer-source', assetLabel: '源图 快速编辑', }), @@ -1461,7 +1461,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { referenceImageSrcs: [ 'generated-character-drafts/editor/generation-references/reference.png', ], - model: 'gpt-image-2', + model: 'gpt-image-2.5', targetLayerId: 'layer-source', }), ); @@ -3803,7 +3803,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { size: '720x1280', kind: 'publication-material', assetKind: 'publication-material', - model: 'gpt-image-2', + model: 'gpt-image-2.5', aspectRatio: '9:16', imageSize: '1K', referenceImageSrcs: [ @@ -4102,7 +4102,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect.objectContaining({ referenceId: 'resource-icon-spec', iconDescriptions: ['返回按钮\n设置按钮'], - model: 'gpt-image-2', + model: 'gpt-image-2.5', assetLabel: '复古操作图标', canvasCompletion: expect.objectContaining({ title: '复古操作图标', @@ -4125,7 +4125,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { 'icon:idle:open:layer-icon-spritesheet-1:placeholder:-', ); expect(screen.getByTestId('remembered-model').textContent).toBe( - 'gpt-image-2', + 'gpt-image-2.5', ); expect(screen.getByTestId('generation-warning').textContent).toBe( '图集已生成,但自动拆分未完成:连通域数量不足', -- 2.52.0 From 99081455eed4dea4a0e89350276d4ad0172f49ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:07:21 +0800 Subject: [PATCH 09/80] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20image=5Fprovider=20t?= =?UTF-8?q?ransport=20=E5=86=97=E4=BD=99=20path=20=E5=B1=9E=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉 transport 模块上多余的 #[path] 声明,恢复默认模块解析 保持 client / curl 模块导出与人眼可见的目录结构一致 --- .../crates/platform-image/src/image_provider/transport/mod.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/transport/mod.rs b/server-rs/crates/platform-image/src/image_provider/transport/mod.rs index b836cc66a..0a4ec355b 100644 --- a/server-rs/crates/platform-image/src/image_provider/transport/mod.rs +++ b/server-rs/crates/platform-image/src/image_provider/transport/mod.rs @@ -1,6 +1,4 @@ -#[path = "client.rs"] pub(crate) mod client; -#[path = "curl.rs"] pub(crate) mod curl; pub use client::{build_image_http_client, build_image_provider_client}; -- 2.52.0 From 345c9c3a99c44353a660eb166722c8c317517487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:08:50 +0800 Subject: [PATCH 10/80] =?UTF-8?q?=E6=94=B6=E6=95=9B=20ImageProvider=20?= =?UTF-8?q?=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=E7=9A=84=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 集成测试改用 GPT_IMAGE_2_5_GENERATION_MODEL / GPT_IMAGE_2_5_EDIT_MODEL 常量拼接断言 避免常量改名后遗留过期的 wire model 字符串断言 --- .../platform-image/tests/image_provider.rs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/server-rs/crates/platform-image/tests/image_provider.rs b/server-rs/crates/platform-image/tests/image_provider.rs index cae9ed173..94df7f54e 100644 --- a/server-rs/crates/platform-image/tests/image_provider.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -1,10 +1,10 @@ use platform_image::image_provider::{ - GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, ImageProvider, - ImageProviderSettings, PlatformImageError, ReferenceImage, VECTOR_ENGINE_PROVIDER, - build_image_http_client, build_image_request_body, build_image_request_body_with_model, - build_nanobanana_generate_content_request_body, create_image_edit, create_image_generation, - create_nanobanana_generate_content, images_edit_url, images_generation_url, - nanobanana_generate_content_url, + GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, + GPT_IMAGE_2_MODEL, ImageProvider, ImageProviderSettings, PlatformImageError, ReferenceImage, + VECTOR_ENGINE_PROVIDER, build_image_http_client, build_image_request_body, + build_image_request_body_with_model, build_nanobanana_generate_content_request_body, + create_image_edit, create_image_generation, create_nanobanana_generate_content, + images_edit_url, images_generation_url, nanobanana_generate_content_url, }; use std::{ sync::{ @@ -278,7 +278,7 @@ async fn image_edit_retries_send_timeout_once_and_succeeds() { assert!( requests .iter() - .all(|request| request.contains("\r\n\r\ngpt-image-2.5-sunburst-c\r\n")) + .all(|request| request.contains(&format!("\r\n\r\n{GPT_IMAGE_2_5_EDIT_MODEL}\r\n"))) ); server.abort(); } @@ -530,8 +530,8 @@ async fn image_generation_stays_on_model_after_upstream_502() { assert_eq!(generated.images.len(), 1); assert_eq!(request_count.load(Ordering::SeqCst), 2); let requests = requests.lock().await; - assert!(requests[0].contains("\"model\":\"gpt-image-2.5-flare-c\"")); - assert!(requests[1].contains("\"model\":\"gpt-image-2.5-flare-c\"")); + assert!(requests[0].contains(&format!("\"model\":\"{GPT_IMAGE_2_5_GENERATION_MODEL}\""))); + assert!(requests[1].contains(&format!("\"model\":\"{GPT_IMAGE_2_5_GENERATION_MODEL}\""))); server.abort(); } @@ -563,7 +563,7 @@ async fn image_generation_uses_gpt_image_2_without_fallback_on_success() { assert!(generated.recovered_failure_audits.is_empty()); let requests = requests.lock().await; assert_eq!(requests.len(), 1); - assert!(requests[0].contains("\"model\":\"gpt-image-2.5-flare-c\"")); + assert!(requests[0].contains(&format!("\"model\":\"{GPT_IMAGE_2_5_GENERATION_MODEL}\""))); server.abort(); } @@ -605,7 +605,7 @@ async fn image_edit_does_not_cross_model_when_unsupported() { assert!(matches!(error, PlatformImageError::Upstream { .. })); let requests = requests.lock().await; assert_eq!(requests.len(), 1); - assert!(requests[0].contains("\r\n\r\ngpt-image-2.5-sunburst-c\r\n")); + assert!(requests[0].contains(&format!("\r\n\r\n{GPT_IMAGE_2_5_EDIT_MODEL}\r\n"))); server.abort(); } @@ -679,7 +679,7 @@ async fn image_generation_does_not_cross_model_after_invalid_response() { assert!(matches!(error, PlatformImageError::ResponseParse { .. })); let requests = requests.lock().await; assert_eq!(requests.len(), 1); - assert!(requests[0].contains("\"model\":\"gpt-image-2.5-flare-c\"")); + assert!(requests[0].contains(&format!("\"model\":\"{GPT_IMAGE_2_5_GENERATION_MODEL}\""))); server.abort(); } -- 2.52.0 From fa2b36654bd16e510e1a15c16efb95af17363c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:09:10 +0800 Subject: [PATCH 11/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20ImageProvider=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=B5=8B=E8=AF=95=E7=9A=84=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 image_generation_uses_gpt_image_2_without_fallback_on_success 更名为 image_generation_uses_default_generation_model_on_success 测试实际覆盖 GPT Image 2.5 默认模型且跨模型回退已删除,旧名称会误导后续模型迁移 --- server-rs/crates/platform-image/tests/image_provider.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-rs/crates/platform-image/tests/image_provider.rs b/server-rs/crates/platform-image/tests/image_provider.rs index 94df7f54e..210977600 100644 --- a/server-rs/crates/platform-image/tests/image_provider.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -536,7 +536,7 @@ async fn image_generation_stays_on_model_after_upstream_502() { } #[tokio::test] -async fn image_generation_uses_gpt_image_2_without_fallback_on_success() { +async fn image_generation_uses_default_generation_model_on_success() { let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse { status: "200 OK", content_type: "application/json", -- 2.52.0 From 874d061cb9fda81d3baf57d9ae4bf63fbcf7688f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:09:28 +0800 Subject: [PATCH 12/80] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20ImageProvider=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=B7=AF=E7=94=B1=E4=BD=BF=E7=94=A8=E7=9A=84?= =?UTF-8?q?=20nanobanana=20=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_image_provider 改用 NANOBANANA_2_MODEL 常量匹配 VectorEngine 模型 避免常量更新后路由仍匹配写死的旧模型字符串 --- .../platform-image/src/image_provider/protocol/request.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs index 06ba2a149..b60a93f2b 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs @@ -4,7 +4,7 @@ use crate::image_provider::{ constants::{ GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE, - GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, GPT_IMAGE_2_MODEL, + GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, }, types::{ImageProvider, ImageProviderSettings, ReferenceImage}, }; @@ -104,7 +104,7 @@ pub fn resolve_image_provider(model: &str) -> Result Ok(ImageProvider::Tiantoken), - "gemini-3.1-flash-image-preview" => Ok(ImageProvider::VectorEngine), + NANOBANANA_2_MODEL => Ok(ImageProvider::VectorEngine), _ => Err("不支持的图片模型"), } } -- 2.52.0 From 6c33459b90cd90bf7ec5ebd723062b15e3946fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:09:58 +0800 Subject: [PATCH 13/80] =?UTF-8?q?=E4=B8=BA=20ImageProviderSettings=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=84=B1=E6=95=8F=20Debug=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手动实现 Debug 并在其中把 api_key 渲染为 settings() 公开返回该结构体,默认 derive 会让 {:?} 直接打印 provider 凭据 --- .../src/image_provider/runtime/types.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/types.rs b/server-rs/crates/platform-image/src/image_provider/runtime/types.rs index 6ac5bb229..ef7d26d39 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/types.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/types.rs @@ -15,7 +15,7 @@ impl ImageProvider { } } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct ImageProviderSettings { pub provider: ImageProvider, pub base_url: String, @@ -24,6 +24,20 @@ pub struct ImageProviderSettings { pub request_deadline: Option, } +impl std::fmt::Debug for ImageProviderSettings { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // 中文注释:与 ImageProviderClient 的 Debug 保持一致,避免日志泄漏 api_key。 + formatter + .debug_struct("ImageProviderSettings") + .field("provider", &self.provider) + .field("base_url", &self.base_url) + .field("api_key", &"") + .field("request_timeout_ms", &self.request_timeout_ms) + .field("request_deadline", &self.request_deadline) + .finish() + } +} + #[derive(Clone)] pub struct ImageProviderClient { settings: ImageProviderSettings, -- 2.52.0 From 1c950df116aa7512a4d7878b7b9b3fac174ea792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:14:23 +0800 Subject: [PATCH 14/80] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=B7=B2=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E7=9A=84=E8=B7=A8=E6=A8=A1=E5=9E=8B=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 generation / edit 重试路径中恒为 false 的 should_fallback_to_gpt_image_2_c 判断与 record_image_model_fallback 调用 删除为计算该判断而额外调用 handle_image_response 的预解析,避免每次重试都白解析响应并下载图片 删除只断言该函数恒为 false 的单元测试,跨模型不切换已由集成测试覆盖 --- .../src/image_provider/runtime/executor.rs | 200 +----------------- 1 file changed, 2 insertions(+), 198 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index 11b597acf..5f68ced17 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -99,7 +99,7 @@ pub async fn create_image_generation_with_model( let request_url = images_generation_url(settings); let normalized_size = normalize_image_size_for_model(requested_model, size); let started_at = std::time::Instant::now(); - let mut upstream_model = preferred_image_upstream_model(requested_model); + let upstream_model = preferred_image_upstream_model(requested_model); let mut recovered_failure_audits = Vec::new(); let mut attempt = 1; loop { @@ -140,51 +140,6 @@ pub async fn create_image_generation_with_model( response.body.as_str(), attempt, ) { - let primary_error = if upstream_model == GPT_IMAGE_2_MODEL { - handle_image_response( - http_client, - request_url.as_str(), - response.status, - response.body.as_str(), - auditable_image_model(upstream_model), - failure_context, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_images.len()), - candidate_count, - "vector-engine", - settings.request_deadline, - ) - .await - .err() - } else { - None - }; - if primary_error.as_ref().is_some_and(|error| { - should_fallback_to_gpt_image_2_c( - requested_model, - upstream_model, - attempt, - error, - settings, - ) - }) { - let error = primary_error.expect("primary error checked above"); - record_image_model_fallback( - "generation", - request_url.as_str(), - upstream_model, - GPT_IMAGE_2_C_MODEL, - attempt, - &error, - ); - if let Some(audit) = error.audit().cloned() { - recovered_failure_audits.push(audit); - } - upstream_model = GPT_IMAGE_2_C_MODEL; - attempt += 1; - continue; - } if retry_image_upstream_status_after_delay( "generation", request_url.as_str(), @@ -283,29 +238,6 @@ pub async fn create_image_generation_with_model( .append(&mut recovered_failure_audits); return Ok(generated); } - Err(error) - if should_fallback_to_gpt_image_2_c( - requested_model, - upstream_model, - attempt, - &error, - settings, - ) => - { - record_image_model_fallback( - "generation", - request_url.as_str(), - upstream_model, - GPT_IMAGE_2_C_MODEL, - attempt, - &error, - ); - if let Some(audit) = error.audit().cloned() { - recovered_failure_audits.push(audit); - } - upstream_model = GPT_IMAGE_2_C_MODEL; - attempt += 1; - } Err(error) => { return Err(finish_image_model_fallback_error( error, @@ -577,7 +509,7 @@ pub async fn create_image_edit_with_references_and_model( let reference_image_bytes_total: usize = reference_images.iter().map(|image| image.bytes.len()).sum(); let started_at = std::time::Instant::now(); - let mut upstream_model = preferred_image_upstream_model(requested_model); + let upstream_model = preferred_image_upstream_model(requested_model); let mut recovered_failure_audits = Vec::new(); let mut attempt = 1; loop { @@ -645,51 +577,6 @@ pub async fn create_image_edit_with_references_and_model( response.body.as_str(), attempt, ) { - let primary_error = if upstream_model == GPT_IMAGE_2_MODEL { - handle_image_response( - http_client, - request_url.as_str(), - response.status, - response.body.as_str(), - auditable_image_model(upstream_model), - failure_context, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_image_count), - candidate_count, - "vector-engine-edit", - settings.request_deadline, - ) - .await - .err() - } else { - None - }; - if primary_error.as_ref().is_some_and(|error| { - should_fallback_to_gpt_image_2_c( - requested_model, - upstream_model, - attempt, - error, - settings, - ) - }) { - let error = primary_error.expect("primary error checked above"); - record_image_model_fallback( - "edit", - request_url.as_str(), - upstream_model, - GPT_IMAGE_2_C_MODEL, - attempt, - &error, - ); - if let Some(audit) = error.audit().cloned() { - recovered_failure_audits.push(audit); - } - upstream_model = GPT_IMAGE_2_C_MODEL; - attempt += 1; - continue; - } if retry_image_upstream_status_after_delay( "edit", request_url.as_str(), @@ -790,29 +677,6 @@ pub async fn create_image_edit_with_references_and_model( .append(&mut recovered_failure_audits); return Ok(generated); } - Err(error) - if should_fallback_to_gpt_image_2_c( - requested_model, - upstream_model, - attempt, - &error, - settings, - ) => - { - record_image_model_fallback( - "edit", - request_url.as_str(), - upstream_model, - GPT_IMAGE_2_C_MODEL, - attempt, - &error, - ); - if let Some(audit) = error.audit().cloned() { - recovered_failure_audits.push(audit); - } - upstream_model = GPT_IMAGE_2_C_MODEL; - attempt += 1; - } Err(error) => { return Err(finish_image_model_fallback_error( error, @@ -845,19 +709,6 @@ fn auditable_image_model(model: &str) -> Option<&'static str> { } } -fn should_fallback_to_gpt_image_2_c( - requested_model: &str, - upstream_model: &str, - attempt: u32, - error: &PlatformImageError, - settings: &ImageProviderSettings, -) -> bool { - // Compatibility function retained for old call sites/tests. New dispatch - // never crosses models; retries are handled by the same-model loop only. - let _ = (requested_model, upstream_model, attempt, error, settings); - false -} - fn contains_image_provider_content_rejection(message: &str, raw_excerpt: &str) -> bool { let haystack = format!("{message}\n{raw_excerpt}").to_ascii_lowercase(); [ @@ -883,31 +734,6 @@ fn contains_image_provider_content_rejection(message: &str, raw_excerpt: &str) - .any(|marker| haystack.contains(marker)) } -fn record_image_model_fallback( - request_kind: &'static str, - request_url: &str, - from_model: &str, - to_model: &str, - attempt: u32, - error: &PlatformImageError, -) { - let audit = error.audit(); - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - request_kind, - fallback_from_model = from_model, - fallback_to_model = to_model, - attempt, - next_attempt = attempt + 1, - max_attempts = VECTOR_ENGINE_SEND_MAX_ATTEMPTS, - failure_stage = audit.map(|audit| audit.failure_stage).unwrap_or("unknown"), - status = audit.and_then(|audit| audit.status_code).unwrap_or_default(), - error = %error.message(), - "ImageProvider 首选图片模型失败,切换兼容模型" - ); -} - fn should_retry_image_curl_send_error( error: &super::curl_transport::ImageProviderCurlError, attempt: u32, @@ -1202,28 +1028,6 @@ mod tests { assert!(!should_retry_image_upstream_response(502, "", 5)); } - #[test] - fn image_provider_never_falls_back_across_image_models() { - let settings = ImageProviderSettings { - provider: ImageProvider::VectorEngine, - base_url: "https://vector.example/v1".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000, - request_deadline: None, - }; - let error = PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: "model unavailable".to_string(), - }; - assert!(!should_fallback_to_gpt_image_2_c( - GPT_IMAGE_2_MODEL, - GPT_IMAGE_2_MODEL, - 1, - &error, - &settings, - )); - } - #[test] fn image_provider_send_retry_delay_uses_exponential_backoff_with_bounded_jitter() { assert_eq!(image_provider_send_retry_delay_ms(1, 0), 500); -- 2.52.0 From 90b77d6bd030807e3ed2d1eb053819930743b4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:16:03 +0800 Subject: [PATCH 15/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=A4=84=E7=90=86=E7=9A=84=20provider=20?= =?UTF-8?q?=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_image_response 与 build_failure_audit 接收实际 provider,不再写死 vector-engine executor 生成 / 编辑路径传入 settings.provider,nanobanana 路径传入 VectorEngine budget / curl / raw edit 的审计先沿用原常量,待各自按实际 provider 收口 --- .../src/image_provider/protocol/response.rs | 28 +++++++++++-------- .../src/image_provider/runtime/audit.rs | 5 ++-- .../src/image_provider/runtime/budget.rs | 1 + .../src/image_provider/runtime/executor.rs | 5 +++- .../src/image_provider/transport/curl.rs | 1 + .../platform-image/src/raw_image_edit/mod.rs | 4 +++ 6 files changed, 29 insertions(+), 15 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/response.rs b/server-rs/crates/platform-image/src/image_provider/protocol/response.rs index 473516f61..67c88e3c6 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/response.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/response.rs @@ -1,13 +1,12 @@ use crate::image_provider::{ audit::build_failure_audit, - constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, image_source::{download_images_from_urls, images_from_base64}, payload::{ extract_b64_images, extract_generation_id, extract_image_urls, find_first_string_by_key, parse_api_error_message, parse_json_payload, }, - types::GeneratedImages, + types::{GeneratedImages, ImageProvider}, util::{current_utc_micros, is_timeout_message, truncate_raw}, }; use std::time::Instant; @@ -15,6 +14,7 @@ use std::time::Instant; pub(crate) async fn handle_image_response( http_client: &reqwest::Client, request_url: &str, + provider: ImageProvider, response_status: u16, response_text: &str, image_model: Option<&'static str>, @@ -26,10 +26,12 @@ pub(crate) async fn handle_image_response( task_prefix: &str, request_deadline: Option, ) -> Result { + let provider_label = provider.as_str(); if !(200..=299).contains(&response_status) { let message = parse_api_error_message(response_text, failure_context); let raw_excerpt = truncate_raw(response_text); let audit = build_failure_audit( + provider_label, request_url, failure_context, "upstream_status", @@ -46,7 +48,7 @@ pub(crate) async fn handle_image_response( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, upstream_status = response_status, timeout = is_timeout_message(message.as_str()) || is_timeout_message(raw_excerpt.as_str()), @@ -56,7 +58,7 @@ pub(crate) async fn handle_image_response( "ImageProvider 图片生成上游错误" ); return Err(PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, upstream_status: response_status, raw_excerpt, @@ -68,6 +70,7 @@ pub(crate) async fn handle_image_response( Ok(response_json) => response_json, Err(error) => { let audit = build_failure_audit( + provider_label, request_url, failure_context, "response_parse", @@ -84,7 +87,7 @@ pub(crate) async fn handle_image_response( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, status = response_status, raw_excerpt = %truncate_raw(response_text), @@ -118,6 +121,7 @@ pub(crate) async fn handle_image_response( .audit() .is_some_and(|audit| audit.failure_stage == "request_budget"); let audit = build_failure_audit( + provider_label, request_url, failure_context, if request_budget_exhausted { @@ -146,7 +150,7 @@ pub(crate) async fn handle_image_response( }; generated.actual_prompt = actual_prompt; tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, image_count = generated.images.len(), elapsed_ms = download_started_at.elapsed().as_millis() as u64, @@ -162,6 +166,7 @@ pub(crate) async fn handle_image_response( let message = format!("{failure_context}:ImageProvider 返回的 base64 图片无法解码"); let raw_excerpt = truncate_raw(response_text); let audit = build_failure_audit( + provider_label, request_url, failure_context, "response_parse", @@ -178,7 +183,7 @@ pub(crate) async fn handle_image_response( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, status = response_status, image_model, @@ -186,7 +191,7 @@ pub(crate) async fn handle_image_response( "ImageProvider 图片 base64 解码失败" ); return Err(PlatformImageError::ResponseParse { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, raw_excerpt, audit: Some(audit), @@ -194,7 +199,7 @@ pub(crate) async fn handle_image_response( } generated.actual_prompt = actual_prompt; tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, image_count = generated.images.len(), failure_context, @@ -205,6 +210,7 @@ pub(crate) async fn handle_image_response( let message = format!("{failure_context}:ImageProvider 未返回图片地址"); let audit = build_failure_audit( + provider_label, request_url, failure_context, "missing_image", @@ -221,14 +227,14 @@ pub(crate) async fn handle_image_response( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, status = response_status, raw_excerpt = %truncate_raw(response_text), "ImageProvider 图片响应未返回图片" ); Err(PlatformImageError::MissingImage { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, audit: Some(audit), }) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/audit.rs b/server-rs/crates/platform-image/src/image_provider/runtime/audit.rs index 338902b0a..0ab99bda6 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/audit.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/audit.rs @@ -1,5 +1,3 @@ -use crate::image_provider::constants::VECTOR_ENGINE_PROVIDER; - #[derive(Clone, Debug)] pub struct PlatformImageFailureAudit { pub provider: &'static str, @@ -20,6 +18,7 @@ pub struct PlatformImageFailureAudit { } pub(crate) fn build_failure_audit( + provider: &'static str, request_url: &str, operation: &str, failure_stage: &'static str, @@ -36,7 +35,7 @@ pub(crate) fn build_failure_audit( image_model: Option<&'static str>, ) -> PlatformImageFailureAudit { PlatformImageFailureAudit { - provider: VECTOR_ENGINE_PROVIDER, + provider, endpoint: request_url.to_string(), operation: operation.to_string(), failure_stage, diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs b/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs index bc7d912e3..d0f1bffc9 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs @@ -61,6 +61,7 @@ pub(crate) fn request_budget_exhausted_error( const ERROR_SOURCE: &str = "external request deadline elapsed"; let message = format!("{operation}:外部图片请求执行预算已耗尽"); let audit = build_failure_audit( + VECTOR_ENGINE_PROVIDER, request_url, operation, "request_budget", diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index 5f68ced17..6ae07470a 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -27,7 +27,7 @@ use crate::image_provider::{ resolve_image_provider, }, response::handle_image_response, - types::{GeneratedImages, ImageProviderSettings, ReferenceImage}, + types::{GeneratedImages, ImageProvider, ImageProviderSettings, ReferenceImage}, util::truncate_raw, }; @@ -219,6 +219,7 @@ pub async fn create_image_generation_with_model( match handle_image_response( http_client, request_url.as_str(), + settings.provider, response_status, response_text.as_str(), auditable_image_model(upstream_model), @@ -407,6 +408,7 @@ pub async fn create_nanobanana_generate_content( handle_image_response( http_client, request_url.as_str(), + ImageProvider::VectorEngine, response_status, response_text.as_str(), None, @@ -658,6 +660,7 @@ pub async fn create_image_edit_with_references_and_model( match handle_image_response( http_client, request_url.as_str(), + settings.provider, response_status, response_text.as_str(), auditable_image_model(upstream_model), diff --git a/server-rs/crates/platform-image/src/image_provider/transport/curl.rs b/server-rs/crates/platform-image/src/image_provider/transport/curl.rs index 41ac2a426..416cc01ff 100644 --- a/server-rs/crates/platform-image/src/image_provider/transport/curl.rs +++ b/server-rs/crates/platform-image/src/image_provider/transport/curl.rs @@ -160,6 +160,7 @@ pub(crate) fn map_curl_error( let source = error.to_string(); let message = format!("{context}:{source}"); let audit = build_failure_audit( + VECTOR_ENGINE_PROVIDER, request_url, context, failure_stage, diff --git a/server-rs/crates/platform-image/src/raw_image_edit/mod.rs b/server-rs/crates/platform-image/src/raw_image_edit/mod.rs index 8b80752fd..f4383d87e 100644 --- a/server-rs/crates/platform-image/src/raw_image_edit/mod.rs +++ b/server-rs/crates/platform-image/src/raw_image_edit/mod.rs @@ -228,6 +228,7 @@ pub async fn create_raw_image_edit( ); let raw_excerpt = truncate_raw(body.as_str()); let audit = build_failure_audit( + VECTOR_ENGINE_PROVIDER, url.as_str(), failure_context, "upstream_status", @@ -257,6 +258,7 @@ pub async fn create_raw_image_edit( let message = format!("{failure_context}:上游响应不是 JSON:{error}"); let raw_excerpt = truncate_raw(body.as_str()); let audit = build_failure_audit( + VECTOR_ENGINE_PROVIDER, url.as_str(), failure_context, "response_parse", @@ -284,6 +286,7 @@ pub async fn create_raw_image_edit( if b64_images.is_empty() { let message = format!("{failure_context}:上游未返回 b64_json 图片"); let audit = build_failure_audit( + VECTOR_ENGINE_PROVIDER, url.as_str(), failure_context, "missing_image", @@ -356,6 +359,7 @@ fn request_error( "ImageProvider Raw 图片编辑请求失败" ); let audit = build_failure_audit( + VECTOR_ENGINE_PROVIDER, url, context, failure_stage, -- 2.52.0 From 4888a0eb01f98915eb435593c56aa2e816f2bd67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:16:25 +0800 Subject: [PATCH 16/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E9=94=99=E8=AF=AF=E7=9A=84=20provider=20?= =?UTF-8?q?=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_json_payload 增加 ImageProvider 参数,ResponseParse 使用实际 provider 共享的 handle_image_response 解析失败时不再把 Tiantoken 响应记成 vector-engine --- .../platform-image/src/image_provider/protocol/payload.rs | 5 +++-- .../platform-image/src/image_provider/protocol/response.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs b/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs index 1dc8388ac..b6e420819 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs @@ -1,19 +1,20 @@ use serde_json::Value; use crate::image_provider::{ - constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, + types::ImageProvider, util::{ParsedJsonPayload, truncate_raw}, }; pub(super) fn parse_json_payload( raw_text: &str, failure_context: &str, + provider: ImageProvider, ) -> Result { serde_json::from_str::(raw_text) .map(|payload| ParsedJsonPayload { payload }) .map_err(|error| PlatformImageError::ResponseParse { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("{failure_context}:解析响应失败:{error}"), raw_excerpt: truncate_raw(raw_text), audit: None, diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/response.rs b/server-rs/crates/platform-image/src/image_provider/protocol/response.rs index 67c88e3c6..fa7ad079c 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/response.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/response.rs @@ -66,7 +66,7 @@ pub(crate) async fn handle_image_response( }); } - let response_json = match parse_json_payload(response_text, failure_context) { + let response_json = match parse_json_payload(response_text, failure_context, provider) { Ok(response_json) => response_json, Err(error) => { let audit = build_failure_audit( -- 2.52.0 From d3407e8910220c9085992d3df94b474ab0344d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:19:08 +0800 Subject: [PATCH 17/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E4=B8=8E=E5=9B=BE=E7=89=87=E4=B8=8B=E8=BD=BD=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84=20provider=20=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit request_budget_exhausted_error 接收实际 provider,审计、日志与 Request 错误不再写死 vector-engine download_remote_image / download_images_from_urls / resolve_reference_images / parse_reference_image_data_url 逐层透传 provider executor 与 raw image edit 调用处传入各自 settings.provider,nanobanana 下载路径传入 VectorEngine --- .../src/image_provider/protocol/response.rs | 1 + .../src/image_provider/runtime/budget.rs | 11 +++-- .../src/image_provider/runtime/executor.rs | 4 ++ .../image_provider/runtime/image_source.rs | 45 +++++++++++++------ .../platform-image/src/raw_image_edit/mod.rs | 1 + 5 files changed, 44 insertions(+), 18 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/response.rs b/server-rs/crates/platform-image/src/image_provider/protocol/response.rs index fa7ad079c..4d5a10683 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/response.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/response.rs @@ -109,6 +109,7 @@ pub(crate) async fn handle_image_response( task_id, image_urls, candidate_count, + provider, request_deadline, failure_context, ) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs b/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs index d0f1bffc9..5876fa570 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; use crate::image_provider::{ - audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, + audit::build_failure_audit, error::PlatformImageError, types::ImageProvider, }; pub(crate) fn effective_request_timeout_ms( @@ -51,6 +51,7 @@ fn retry_delay_fits_request_deadline_at( } pub(crate) fn request_budget_exhausted_error( + provider: ImageProvider, request_url: &str, operation: &str, image_model: Option<&'static str>, @@ -59,9 +60,10 @@ pub(crate) fn request_budget_exhausted_error( reference_image_count: Option, ) -> PlatformImageError { const ERROR_SOURCE: &str = "external request deadline elapsed"; + let provider_label = provider.as_str(); let message = format!("{operation}:外部图片请求执行预算已耗尽"); let audit = build_failure_audit( - VECTOR_ENGINE_PROVIDER, + provider_label, request_url, operation, "request_budget", @@ -78,7 +80,7 @@ pub(crate) fn request_budget_exhausted_error( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, failure_stage = "request_budget", timeout = true, @@ -90,7 +92,7 @@ pub(crate) fn request_budget_exhausted_error( "ImageProvider 图片请求执行预算已耗尽" ); PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, endpoint: Some(request_url.to_string()), timeout: true, @@ -164,6 +166,7 @@ mod tests { #[test] fn exhausted_budget_maps_to_timeout_request_error_and_audit() { let error = request_budget_exhausted_error( + ImageProvider::VectorEngine, "https://vector.example/v1/images/generations", "生成图片失败", None, diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index 6ae07470a..b4167f8ca 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -78,6 +78,7 @@ pub async fn create_image_generation_with_model( let resolved_references = resolve_reference_images( http_client, reference_images, + settings.provider, failure_context, settings.request_deadline, ) @@ -116,6 +117,7 @@ pub async fn create_image_generation_with_model( else { return Err(finish_image_model_fallback_error( request_budget_exhausted_error( + settings.provider, request_url.as_str(), failure_context, auditable_image_model(upstream_model), @@ -307,6 +309,7 @@ pub async fn create_nanobanana_generate_content( effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) else { return Err(request_budget_exhausted_error( + ImageProvider::VectorEngine, request_url.as_str(), failure_context, None, @@ -550,6 +553,7 @@ pub async fn create_image_edit_with_references_and_model( else { return Err(finish_image_model_fallback_error( request_budget_exhausted_error( + settings.provider, request_url.as_str(), failure_context, auditable_image_model(upstream_model), diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/image_source.rs b/server-rs/crates/platform-image/src/image_provider/runtime/image_source.rs index 3e12b0055..87d957a23 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/image_source.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/image_source.rs @@ -4,19 +4,21 @@ use std::time::Instant; use crate::image_provider::{ budget::request_budget_exhausted_error, - constants::{VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER}, + constants::VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, error::PlatformImageError, - types::{DownloadedImage, GeneratedImages, ReferenceImage}, + types::{DownloadedImage, GeneratedImages, ImageProvider, ReferenceImage}, }; pub async fn download_remote_image( http_client: &reqwest::Client, image_url: &str, + provider: ImageProvider, ) -> Result { let response = http_client.get(image_url).send().await.map_err(|error| { map_simple_request_error( format!("下载生成图片失败:{error}"), Some(image_url.to_string()), + provider, ) })?; let status = response.status(); @@ -30,11 +32,12 @@ pub async fn download_remote_image( map_simple_request_error( format!("读取生成图片内容失败:{error}"), Some(image_url.to_string()), + provider, ) })?; if !status.is_success() { return Err(PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: "下载生成图片失败".to_string(), endpoint: Some(image_url.to_string()), timeout: false, @@ -58,15 +61,17 @@ pub async fn download_remote_image( async fn download_remote_image_with_deadline( http_client: &reqwest::Client, image_url: &str, + provider: ImageProvider, request_deadline: Option, operation: &str, ) -> Result { let Some(request_deadline) = request_deadline else { - return download_remote_image(http_client, image_url).await; + return download_remote_image(http_client, image_url, provider).await; }; let started_at = Instant::now(); if request_deadline <= started_at { return Err(request_budget_exhausted_error( + provider, image_url, operation, None, @@ -77,11 +82,12 @@ async fn download_remote_image_with_deadline( } tokio::time::timeout_at( tokio::time::Instant::from_std(request_deadline), - download_remote_image(http_client, image_url), + download_remote_image(http_client, image_url, provider), ) .await .map_err(|_| { request_budget_exhausted_error( + provider, image_url, operation, None, @@ -97,6 +103,7 @@ pub(crate) async fn download_images_from_urls( task_id: String, image_urls: Vec, candidate_count: u32, + provider: ImageProvider, request_deadline: Option, operation: &str, ) -> Result { @@ -109,6 +116,7 @@ pub(crate) async fn download_images_from_urls( download_remote_image_with_deadline( http_client, image_url.as_str(), + provider, request_deadline, operation, ) @@ -126,6 +134,7 @@ pub(crate) async fn download_images_from_urls( pub(crate) async fn resolve_reference_images( http_client: &reqwest::Client, reference_images: &[String], + provider: ImageProvider, failure_context: &str, request_deadline: Option, ) -> Result, PlatformImageError> { @@ -135,7 +144,7 @@ pub(crate) async fn resolve_reference_images( .count(); if reference_count > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!( "{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {reference_count} 张。" ), @@ -147,7 +156,7 @@ pub(crate) async fn resolve_reference_images( if source.is_empty() { continue; } - if let Some(reference_image) = parse_reference_image_data_url(source, index)? { + if let Some(reference_image) = parse_reference_image_data_url(source, index, provider)? { resolved.push(reference_image); continue; } @@ -155,6 +164,7 @@ pub(crate) async fn resolve_reference_images( let downloaded = download_remote_image_with_deadline( http_client, source, + provider, request_deadline, failure_context, ) @@ -171,14 +181,14 @@ pub(crate) async fn resolve_reference_images( continue; } return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"), }); } if resolved.is_empty() { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("{failure_context}:图片编辑需要至少一张参考图。"), }); } @@ -189,25 +199,26 @@ pub(crate) async fn resolve_reference_images( pub(crate) fn parse_reference_image_data_url( source: &str, index: usize, + provider: ImageProvider, ) -> Result, PlatformImageError> { let Some(body) = source.strip_prefix("data:") else { return Ok(None); }; let Some((mime_type, data)) = body.split_once(";base64,") else { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: "参考图 Data URL 必须是 base64 图片。".to_string(), }); }; if !mime_type.starts_with("image/") { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: "参考图 Data URL 必须是图片类型。".to_string(), }); } let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| { PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("参考图 Data URL 解码失败:{error}"), } })?; @@ -290,9 +301,13 @@ pub(crate) fn infer_image_mime_type(bytes: &[u8]) -> Option { None } -fn map_simple_request_error(message: String, endpoint: Option) -> PlatformImageError { +fn map_simple_request_error( + message: String, + endpoint: Option, + provider: ImageProvider, +) -> PlatformImageError { PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message, endpoint, timeout: false, @@ -349,6 +364,7 @@ mod tests { let error = download_remote_image_with_deadline( &http_client, "http://127.0.0.1:9/not-called.png", + ImageProvider::VectorEngine, Some(Instant::now()), "下载测试图片失败", ) @@ -387,6 +403,7 @@ mod tests { let error = download_remote_image_with_deadline( &reqwest::Client::new(), format!("http://{address}/pending.png").as_str(), + ImageProvider::VectorEngine, Some(Instant::now() + Duration::from_millis(100)), "下载测试图片失败", ) diff --git a/server-rs/crates/platform-image/src/raw_image_edit/mod.rs b/server-rs/crates/platform-image/src/raw_image_edit/mod.rs index f4383d87e..bdede2d54 100644 --- a/server-rs/crates/platform-image/src/raw_image_edit/mod.rs +++ b/server-rs/crates/platform-image/src/raw_image_edit/mod.rs @@ -134,6 +134,7 @@ pub async fn create_raw_image_edit( effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) else { return Err(request_budget_exhausted_error( + settings.provider, url.as_str(), failure_context, Some(GPT_IMAGE_2_5_EDIT_MODEL), -- 2.52.0 From 5cba2dbeb74b85515394c3a07d50097db87f56fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:20:44 +0800 Subject: [PATCH 18/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20libcurl=20=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E9=94=99=E8=AF=AF=E7=9A=84=20provider=20=E5=BD=92?= =?UTF-8?q?=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit map_curl_error 接收实际 provider,审计、日志与 Request 错误不再写死 vector-engine executor 生成 / 编辑路径传入 settings.provider,nanobanana 路径传入 VectorEngine --- .../src/image_provider/runtime/executor.rs | 3 +++ .../src/image_provider/transport/curl.rs | 14 +++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index b4167f8ca..c6508dd7b 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -188,6 +188,7 @@ pub async fn create_image_generation_with_model( } return Err(finish_image_model_fallback_error( map_curl_error( + settings.provider, format!("{failure_context}:创建图片生成任务失败").as_str(), request_url.as_str(), "request_send", @@ -377,6 +378,7 @@ pub async fn create_nanobanana_generate_content( } } return Err(map_curl_error( + ImageProvider::VectorEngine, format!("{failure_context}:创建 nanobanana2 图片生成任务失败").as_str(), request_url.as_str(), "request_send", @@ -629,6 +631,7 @@ pub async fn create_image_edit_with_references_and_model( } return Err(finish_image_model_fallback_error( map_curl_error( + settings.provider, format!("{failure_context}:创建图片编辑任务失败").as_str(), request_url.as_str(), "request_send", diff --git a/server-rs/crates/platform-image/src/image_provider/transport/curl.rs b/server-rs/crates/platform-image/src/image_provider/transport/curl.rs index 416cc01ff..800c5bbee 100644 --- a/server-rs/crates/platform-image/src/image_provider/transport/curl.rs +++ b/server-rs/crates/platform-image/src/image_provider/transport/curl.rs @@ -7,8 +7,10 @@ use curl::{ use serde_json::Value; use crate::image_provider::{ - audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, - request::build_prompt_with_negative, types::ReferenceImage, + audit::build_failure_audit, + error::PlatformImageError, + request::build_prompt_with_negative, + types::{ImageProvider, ReferenceImage}, }; #[derive(Debug)] @@ -145,6 +147,7 @@ pub(crate) async fn send_image_multipart_edit_request_with_curl( } pub(crate) fn map_curl_error( + provider: ImageProvider, context: &str, request_url: &str, failure_stage: &'static str, @@ -155,12 +158,13 @@ pub(crate) fn map_curl_error( reference_image_count: Option, request_params: Option<&Value>, ) -> PlatformImageError { + let provider_label = provider.as_str(); let is_timeout = error.is_timeout(); let is_connect = error.is_connect() || error.is_transient_transport(); let source = error.to_string(); let message = format!("{context}:{source}"); let audit = build_failure_audit( - VECTOR_ENGINE_PROVIDER, + provider_label, request_url, context, failure_stage, @@ -177,7 +181,7 @@ pub(crate) fn map_curl_error( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, failure_stage, timeout = is_timeout, @@ -200,7 +204,7 @@ pub(crate) fn map_curl_error( ); PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, endpoint: Some(request_url.to_string()), timeout: is_timeout, -- 2.52.0 From 89587c9e9e596698b0855035fc4a46aa5c4d5d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:22:20 +0800 Subject: [PATCH 19/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20raw=20image=20edit?= =?UTF-8?q?=20=E7=9A=84=20provider=20=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit created_raw_image_edit 用 settings.provider 统一驱动审计、日志与错误构造 invalid_request / invalid_input / request_error 增加 provider 参数,不再写死 vector-engine --- .../platform-image/src/raw_image_edit/mod.rs | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/server-rs/crates/platform-image/src/raw_image_edit/mod.rs b/server-rs/crates/platform-image/src/raw_image_edit/mod.rs index bdede2d54..ab7a93c3b 100644 --- a/server-rs/crates/platform-image/src/raw_image_edit/mod.rs +++ b/server-rs/crates/platform-image/src/raw_image_edit/mod.rs @@ -7,11 +7,11 @@ use serde::Deserialize; use crate::image_provider::{ constants::{ GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE, - GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, VECTOR_ENGINE_PROVIDER, + GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, }, images_edit_url, runtime::{ - ImageProviderSettings, PlatformImageError, build_failure_audit, + ImageProvider, ImageProviderSettings, PlatformImageError, build_failure_audit, effective_request_timeout_ms, request_budget_exhausted_error, truncate_raw, }, }; @@ -124,8 +124,9 @@ pub async fn create_raw_image_edit( options: RawImageEditOptions, failure_context: &str, ) -> Result { + let provider_label = settings.provider.as_str(); validate_raw_image_edit_dimensions(options.width, options.height) - .map_err(|error| invalid_input(failure_context, error.to_string()))?; + .map_err(|error| invalid_input(settings.provider, failure_context, error.to_string()))?; let url = images_edit_url(settings); let started_at = Instant::now(); let prompt_chars = Some(prompt.chars().count()); @@ -161,7 +162,9 @@ pub async fn create_raw_image_edit( Part::stream(reqwest::Body::from(image_bytes)) .file_name(image_file_name) .mime_str(image_mime_type.as_str()) - .map_err(|error| invalid_request(failure_context, error.to_string()))?, + .map_err(|error| { + invalid_request(settings.provider, failure_context, error.to_string()) + })?, ); if let Some(value) = options.quality { form = form.text("quality", value); @@ -178,7 +181,9 @@ pub async fn create_raw_image_edit( Part::stream(reqwest::Body::from(mask.bytes)) .file_name(mask.file_name) .mime_str(mask.mime_type.as_str()) - .map_err(|error| invalid_request(failure_context, error.to_string()))?, + .map_err(|error| { + invalid_request(settings.provider, failure_context, error.to_string()) + })?, ); } @@ -191,6 +196,7 @@ pub async fn create_raw_image_edit( .await .map_err(|error| { request_error( + settings.provider, &url, failure_context, "request_send", @@ -202,7 +208,7 @@ pub async fn create_raw_image_edit( })?; let status = response.status(); tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %url, status = status.as_u16(), prompt_chars, @@ -213,6 +219,7 @@ pub async fn create_raw_image_edit( ); let body = response.text().await.map_err(|error| { request_error( + settings.provider, &url, failure_context, "response_read", @@ -229,7 +236,7 @@ pub async fn create_raw_image_edit( ); let raw_excerpt = truncate_raw(body.as_str()); let audit = build_failure_audit( - VECTOR_ENGINE_PROVIDER, + provider_label, url.as_str(), failure_context, "upstream_status", @@ -246,7 +253,7 @@ pub async fn create_raw_image_edit( Some(GPT_IMAGE_2_5_EDIT_MODEL), ); return Err(PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, upstream_status: status.as_u16(), raw_excerpt, @@ -259,7 +266,7 @@ pub async fn create_raw_image_edit( let message = format!("{failure_context}:上游响应不是 JSON:{error}"); let raw_excerpt = truncate_raw(body.as_str()); let audit = build_failure_audit( - VECTOR_ENGINE_PROVIDER, + provider_label, url.as_str(), failure_context, "response_parse", @@ -276,7 +283,7 @@ pub async fn create_raw_image_edit( Some(GPT_IMAGE_2_5_EDIT_MODEL), ); return Err(PlatformImageError::ResponseParse { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, raw_excerpt, audit: Some(audit), @@ -287,7 +294,7 @@ pub async fn create_raw_image_edit( if b64_images.is_empty() { let message = format!("{failure_context}:上游未返回 b64_json 图片"); let audit = build_failure_audit( - VECTOR_ENGINE_PROVIDER, + provider_label, url.as_str(), failure_context, "missing_image", @@ -304,7 +311,7 @@ pub async fn create_raw_image_edit( Some(GPT_IMAGE_2_5_EDIT_MODEL), ); return Err(PlatformImageError::MissingImage { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, audit: Some(audit), }); @@ -319,21 +326,22 @@ fn collect_b64_images(data: Vec) -> Vec { .collect() } -fn invalid_request(context: &str, message: String) -> PlatformImageError { +fn invalid_request(provider: ImageProvider, context: &str, message: String) -> PlatformImageError { PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("{context}:构造上游请求失败:{message}"), } } -fn invalid_input(context: &str, message: String) -> PlatformImageError { +fn invalid_input(provider: ImageProvider, context: &str, message: String) -> PlatformImageError { PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("{context}:请求参数无效:{message}"), } } fn request_error( + provider: ImageProvider, url: &str, context: &str, failure_stage: &'static str, @@ -342,12 +350,13 @@ fn request_error( prompt_chars: Option, reference_image_count: Option, ) -> PlatformImageError { + let provider_label = provider.as_str(); let timeout = error.is_timeout(); let connect = error.is_connect(); let source = error.to_string(); let message = format!("{context}:上游请求失败:{source}"); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %url, failure_stage, timeout, @@ -360,7 +369,7 @@ fn request_error( "ImageProvider Raw 图片编辑请求失败" ); let audit = build_failure_audit( - VECTOR_ENGINE_PROVIDER, + provider_label, url, context, failure_stage, @@ -377,7 +386,7 @@ fn request_error( Some(GPT_IMAGE_2_5_EDIT_MODEL), ); PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, endpoint: Some(url.to_string()), timeout, @@ -466,7 +475,11 @@ mod tests { #[test] fn dimension_validation_error_identifies_client_input() { - let error = invalid_input("raw_image_edit", "尺寸无效".to_string()); + let error = invalid_input( + ImageProvider::Tiantoken, + "raw_image_edit", + "尺寸无效".to_string(), + ); assert_eq!(error.to_string(), "raw_image_edit:请求参数无效:尺寸无效"); } -- 2.52.0 From e393a803f2c40430e3d3a6349706f022dba12e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:23:50 +0800 Subject: [PATCH 20/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20executor=20=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=B8=8E=E9=87=8D=E8=AF=95=E9=94=99=E8=AF=AF=E7=9A=84?= =?UTF-8?q?=20provider=20=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成 / 编辑路径的 tracing 字段与参考图数量校验错误改用 settings.provider nanobanana 路径改用 VectorEngine provider 重试辅助函数 retry_image_send_after_delay / retry_image_upstream_status_after_delay 增加 provider 参数 --- .../src/image_provider/runtime/executor.rs | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index c6508dd7b..22e35dc90 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -12,7 +12,7 @@ use crate::image_provider::{ constants::{ GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, - VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER, + VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, }, curl_transport::{ map_curl_error, send_image_json_request_with_curl, @@ -143,6 +143,7 @@ pub async fn create_image_generation_with_model( attempt, ) { if retry_image_upstream_status_after_delay( + settings.provider, "generation", request_url.as_str(), attempt, @@ -165,6 +166,7 @@ pub async fn create_image_generation_with_model( Err(error) => { if should_retry_image_curl_send_error(&error, attempt) { if retry_image_send_after_delay( + settings.provider, "generation", request_url.as_str(), "request_send", @@ -205,7 +207,7 @@ pub async fn create_image_generation_with_model( }; let response_status = response.status; tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = settings.provider.as_str(), endpoint = %request_url, status = response_status, image_model = upstream_model, @@ -271,7 +273,7 @@ pub async fn create_nanobanana_generate_content( })?; if reference_images.len() > VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: settings.provider.as_str(), message: format!( "{failure_context}:参考图最多允许 {VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。", reference_images.len() @@ -334,6 +336,7 @@ pub async fn create_nanobanana_generate_content( attempt, ) { if retry_image_upstream_status_after_delay( + ImageProvider::VectorEngine, "nanobanana_generate_content", request_url.as_str(), attempt, @@ -356,6 +359,7 @@ pub async fn create_nanobanana_generate_content( Err(error) => { if should_retry_image_curl_send_error(&error, attempt) { if retry_image_send_after_delay( + ImageProvider::VectorEngine, "nanobanana_generate_content", request_url.as_str(), "request_send", @@ -394,7 +398,7 @@ pub async fn create_nanobanana_generate_content( }; let response_status = response.status; tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = settings.provider.as_str(), endpoint = %request_url, status = response_status, image_model = model, @@ -495,13 +499,13 @@ pub async fn create_image_edit_with_references_and_model( })?; if reference_images.is_empty() { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: settings.provider.as_str(), message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), }); } if reference_images.len() > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: settings.provider.as_str(), message: format!( "{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。", reference_images.len() @@ -529,7 +533,7 @@ pub async fn create_image_edit_with_references_and_model( reference_images, ); tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = settings.provider.as_str(), endpoint = %request_url, image_model = upstream_model, requested_image_model = requested_model, @@ -586,6 +590,7 @@ pub async fn create_image_edit_with_references_and_model( attempt, ) { if retry_image_upstream_status_after_delay( + settings.provider, "edit", request_url.as_str(), attempt, @@ -608,6 +613,7 @@ pub async fn create_image_edit_with_references_and_model( Err(error) => { if should_retry_image_curl_send_error(&error, attempt) { if retry_image_send_after_delay( + settings.provider, "edit", request_url.as_str(), "request_send", @@ -648,7 +654,7 @@ pub async fn create_image_edit_with_references_and_model( }; let response_status = response.status; tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = settings.provider.as_str(), endpoint = %request_url, status = response_status, image_model = upstream_model, @@ -760,6 +766,7 @@ fn should_retry_image_upstream_response(status: u16, raw_body: &str, attempt: u3 } async fn retry_image_send_after_delay( + provider: ImageProvider, request_kind: &'static str, request_url: &str, failure_stage: &'static str, @@ -775,11 +782,12 @@ async fn retry_image_send_after_delay( request_params: Option<&serde_json::Value>, request_deadline: Option, ) -> bool { + let provider_label = provider.as_str(); let delay_ms = image_provider_send_retry_delay_ms(attempt, image_provider_send_retry_jitter_ms()); if !retry_delay_fits_request_deadline(request_deadline, delay_ms) { tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, request_kind, failure_stage = "request_budget", @@ -800,7 +808,7 @@ async fn retry_image_send_after_delay( return false; } tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, request_kind, failure_stage, @@ -826,6 +834,7 @@ async fn retry_image_send_after_delay( } async fn retry_image_upstream_status_after_delay( + provider: ImageProvider, request_kind: &'static str, request_url: &str, attempt: u32, @@ -837,11 +846,12 @@ async fn retry_image_upstream_status_after_delay( request_params: Option<&serde_json::Value>, request_deadline: Option, ) -> bool { + let provider_label = provider.as_str(); let delay_ms = image_provider_send_retry_delay_ms(attempt, image_provider_send_retry_jitter_ms()); if !retry_delay_fits_request_deadline(request_deadline, delay_ms) { tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, request_kind, failure_stage = "request_budget", @@ -858,7 +868,7 @@ async fn retry_image_upstream_status_after_delay( return false; } tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, request_kind, failure_stage = "upstream_status", -- 2.52.0 From 0817ea81587573ba3458604ae71552305162f32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:25:09 +0800 Subject: [PATCH 21/80] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=99=BD=E5=90=8D=E5=8D=95=E7=9A=84=20GPT=20?= =?UTF-8?q?Image=202.5=20=E4=B8=8E=20nanobanana=20=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auditable_image_model 识别 gpt-image-2.5、gpt-image-2.5-flare-c、gpt-image-2.5-sunburst-c 与 nanobanana2 避免新模型请求的审计 image_model 与错误元数据丢失模型归因 --- .../src/image_provider/runtime/executor.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index 22e35dc90..a98687c1c 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -10,8 +10,9 @@ use crate::image_provider::{ retry_delay_fits_request_deadline, }, constants::{ - GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, - GPT_IMAGE_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, + GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, + GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, + VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, }, curl_transport::{ @@ -721,6 +722,10 @@ fn auditable_image_model(model: &str) -> Option<&'static str> { match model { GPT_IMAGE_2_C_MODEL => Some(GPT_IMAGE_2_C_MODEL), GPT_IMAGE_2_MODEL => Some(GPT_IMAGE_2_MODEL), + GPT_IMAGE_2_5_BUSINESS_NAME => Some(GPT_IMAGE_2_5_BUSINESS_NAME), + GPT_IMAGE_2_5_GENERATION_MODEL => Some(GPT_IMAGE_2_5_GENERATION_MODEL), + GPT_IMAGE_2_5_EDIT_MODEL => Some(GPT_IMAGE_2_5_EDIT_MODEL), + NANOBANANA_2_MODEL => Some(NANOBANANA_2_MODEL), _ => None, } } -- 2.52.0 From c86ac0422038ba25be5b9f744840d574a6be7985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:26:35 +0800 Subject: [PATCH 22/80] =?UTF-8?q?=E6=81=A2=E5=A4=8D=20image=5Fprovider=20?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image_provider/mod.rs 重新声明 #[cfg(test)] mod tests,tests.rs 去掉失效的模块包裹并补齐 helper 导入 提取器 extract_image_urls / extract_b64_images 放宽到 pub(crate),parse_reference_image_data_url 调用补 provider 参数 补回 5 个此前被静默跳过的单元测试 --- .../platform-image/src/image_provider/mod.rs | 3 + .../src/image_provider/protocol/payload.rs | 4 +- .../src/image_provider/tests.rs | 438 +++++++++--------- 3 files changed, 224 insertions(+), 221 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/mod.rs b/server-rs/crates/platform-image/src/image_provider/mod.rs index 9c3b4f2ba..118e9af4d 100644 --- a/server-rs/crates/platform-image/src/image_provider/mod.rs +++ b/server-rs/crates/platform-image/src/image_provider/mod.rs @@ -3,6 +3,9 @@ pub(crate) mod protocol; pub(crate) mod runtime; pub(crate) mod transport; +#[cfg(test)] +mod tests; + pub(crate) use protocol::{payload, request, response}; pub(crate) use runtime::{audit, budget, error, image_source, types, util}; pub(crate) use transport::curl as curl_transport; diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs b/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs index b6e420819..1fb999319 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs @@ -72,7 +72,7 @@ pub(super) fn extract_generation_id(payload: &Value) -> Option { .or_else(|| find_first_string_by_key(payload, "request_id")) } -pub(super) fn extract_image_urls(payload: &Value) -> Vec { +pub(crate) fn extract_image_urls(payload: &Value) -> Vec { let mut urls = Vec::new(); collect_strings_by_key(payload, "url", &mut urls); collect_strings_by_key(payload, "image", &mut urls); @@ -86,7 +86,7 @@ pub(super) fn extract_image_urls(payload: &Value) -> Vec { deduped } -pub(super) fn extract_b64_images(payload: &Value) -> Vec { +pub(crate) fn extract_b64_images(payload: &Value) -> Vec { let mut values = Vec::new(); collect_strings_by_key(payload, "b64_json", &mut values); collect_inline_image_data(payload, &mut values); diff --git a/server-rs/crates/platform-image/src/image_provider/tests.rs b/server-rs/crates/platform-image/src/image_provider/tests.rs index 3d0acb0c6..692e43a7e 100644 --- a/server-rs/crates/platform-image/src/image_provider/tests.rs +++ b/server-rs/crates/platform-image/src/image_provider/tests.rs @@ -1,223 +1,223 @@ -#[cfg(test)] -mod tests { - use super::*; - use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; - use serde_json::json; +use super::*; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde_json::json; - #[test] - fn request_body_normalizes_size_prompt_and_candidate_count() { - let body = build_image_request_body( - " 风雨夜里的街道 ", - Some(" 低清,水印 "), - " 1:1 ", - 10, - &["data:image/png;base64,AAAA".to_string()], - ); +use super::image_source::{decode_generated_image_base64, parse_reference_image_data_url}; +use super::payload::{extract_b64_images, extract_image_urls}; - assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); - assert_eq!(body["size"], "1024x1024"); - assert_eq!(body["n"], 4); - assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印"); - assert!(body.get("image").is_none()); - } +#[test] +fn request_body_normalizes_size_prompt_and_candidate_count() { + let body = build_image_request_body( + " 风雨夜里的街道 ", + Some(" 低清,水印 "), + " 1:1 ", + 10, + &["data:image/png;base64,AAAA".to_string()], + ); - #[test] - fn provider_urls_normalize_root_and_v1_base_urls() { - let root_settings = ImageProviderSettings { - provider: ImageProvider::VectorEngine, - base_url: "https://vector.example".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000, - request_deadline: None, - }; - let v1_settings = ImageProviderSettings { - provider: ImageProvider::VectorEngine, - base_url: "https://vector.example/v1".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000, - request_deadline: None, - }; - - assert_eq!( - images_generation_url(&root_settings), - "https://vector.example/v1/images/generations" - ); - assert_eq!( - images_generation_url(&v1_settings), - "https://vector.example/v1/images/generations" - ); - assert_eq!( - images_edit_url(&root_settings), - "https://vector.example/v1/images/edits" - ); - assert_eq!( - images_edit_url(&v1_settings), - "https://vector.example/v1/images/edits" - ); - } - - #[test] - fn data_url_and_base64_image_decoding_preserves_image_metadata() { - let data_url = format!( - "data:image/png;base64,{}", - BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") - ); - - let reference = parse_reference_image_data_url(&data_url, 2) - .expect("data url should parse") - .expect("image data url should be accepted"); - assert_eq!(reference.file_name, "reference-2.png"); - assert_eq!(reference.mime_type, "image/png"); - assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest"); - - let image = decode_generated_image_base64( - BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str(), - ) - .expect("base64 image should decode"); - assert_eq!(image.extension, "png"); - assert_eq!(image.mime_type, "image/png"); - assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest"); - } - - #[test] - fn error_status_hints_and_audit_fields_are_structured() { - let audit = PlatformImageFailureAudit { - provider: VECTOR_ENGINE_PROVIDER, - endpoint: "https://vector.example/v1/images/generations".to_string(), - operation: "图片生成失败".to_string(), - failure_stage: "upstream_status", - status_code: Some(504), - status_class: Some("5xx"), - timeout: true, - retryable: true, - error_message: "上游超时".to_string(), - error_source: Some("read timeout".to_string()), - raw_excerpt: Some("{\"error\":\"timeout\"}".to_string()), - latency_ms: Some(987), - prompt_chars: Some(64), - reference_image_count: Some(2), - image_model: Some(GPT_IMAGE_2_MODEL), - }; - - let request_error = PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, - message: "请求发送失败".to_string(), - endpoint: Some("https://vector.example/v1/images/generations".to_string()), - timeout: true, - connect: false, - request: true, - body: false, - status_code: None, - source: None, - audit: None, - }; - let invalid_config = PlatformImageError::InvalidConfig { - provider: VECTOR_ENGINE_PROVIDER, - message: "缺少配置".to_string(), - }; - let invalid_request = PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: "请求不合法".to_string(), - }; - let upstream_timeout = PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, - message: "upstream timeout".to_string(), - upstream_status: 502, - raw_excerpt: "deadline has elapsed".to_string(), - audit: Some(audit.clone()), - }; - - assert_eq!( - invalid_config.status_hint(), - PlatformImageStatusHint::ServiceUnavailable - ); - assert_eq!( - invalid_request.status_hint(), - PlatformImageStatusHint::BadRequest - ); - assert_eq!( - request_error.status_hint(), - PlatformImageStatusHint::GatewayTimeout - ); - assert_eq!( - upstream_timeout.status_hint(), - PlatformImageStatusHint::GatewayTimeout - ); - assert_eq!( - PlatformImageError::MissingImage { - provider: VECTOR_ENGINE_PROVIDER, - message: "缺图".to_string(), - audit: Some(audit.clone()), - } - .status_hint(), - PlatformImageStatusHint::BadGateway - ); - - let audit_ref = upstream_timeout.audit().expect("audit should be preserved"); - assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER); - assert_eq!( - audit_ref.endpoint, - "https://vector.example/v1/images/generations" - ); - assert_eq!(audit_ref.status_code, Some(504)); - assert_eq!(audit_ref.status_class, Some("5xx")); - assert!(audit_ref.timeout); - assert!(audit_ref.retryable); - assert_eq!(audit_ref.reference_image_count, Some(2)); - assert_eq!(audit_ref.image_model, Some(GPT_IMAGE_2_MODEL)); - assert!(invalid_config.audit().is_none()); - assert!(invalid_request.audit().is_none()); - } - - #[test] - fn extract_image_urls_and_b64_values_are_deduped() { - let payload = json!({ - "data": [ - {"image": "https://example.com/a.png"}, - {"url": "https://example.com/a.png"}, - {"image_url": "ftp://example.com/b.png"}, - {"url": "https://example.com/b.png"} - ], - "nested": { - "b64_json": ["YWJj", "ZGVm"], - "parts": [ - { - "inlineData": { - "mimeType": "image/png", - "data": "aW1hZ2UtMQ==" - } - }, - { - "inline_data": { - "mime_type": "image/jpeg", - "data": "aW1hZ2UtMg==" - } - }, - { - "inlineData": { - "mimeType": "text/plain", - "data": "bm90LWltYWdl" - } - } - ] - } - }); - - assert_eq!( - extract_image_urls(&payload), - vec![ - "https://example.com/a.png".to_string(), - "https://example.com/b.png".to_string() - ] - ); - assert_eq!( - extract_b64_images(&payload), - vec![ - "YWJj".to_string(), - "ZGVm".to_string(), - "aW1hZ2UtMQ==".to_string(), - "aW1hZ2UtMg==".to_string(), - ] - ); - } + assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); + assert_eq!(body["size"], "1024x1024"); + assert_eq!(body["n"], 4); + assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印"); + assert!(body.get("image").is_none()); +} + +#[test] +fn provider_urls_normalize_root_and_v1_base_urls() { + let root_settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, + base_url: "https://vector.example".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + request_deadline: None, + }; + let v1_settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, + base_url: "https://vector.example/v1".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + request_deadline: None, + }; + + assert_eq!( + images_generation_url(&root_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + images_generation_url(&v1_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + images_edit_url(&root_settings), + "https://vector.example/v1/images/edits" + ); + assert_eq!( + images_edit_url(&v1_settings), + "https://vector.example/v1/images/edits" + ); +} + +#[test] +fn data_url_and_base64_image_decoding_preserves_image_metadata() { + let data_url = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") + ); + + let reference = parse_reference_image_data_url(&data_url, 2, ImageProvider::VectorEngine) + .expect("data url should parse") + .expect("image data url should be accepted"); + assert_eq!(reference.file_name, "reference-2.png"); + assert_eq!(reference.mime_type, "image/png"); + assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest"); + + let image = + decode_generated_image_base64(BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str()) + .expect("base64 image should decode"); + assert_eq!(image.extension, "png"); + assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest"); +} + +#[test] +fn error_status_hints_and_audit_fields_are_structured() { + let audit = PlatformImageFailureAudit { + provider: VECTOR_ENGINE_PROVIDER, + endpoint: "https://vector.example/v1/images/generations".to_string(), + operation: "图片生成失败".to_string(), + failure_stage: "upstream_status", + status_code: Some(504), + status_class: Some("5xx"), + timeout: true, + retryable: true, + error_message: "上游超时".to_string(), + error_source: Some("read timeout".to_string()), + raw_excerpt: Some("{\"error\":\"timeout\"}".to_string()), + latency_ms: Some(987), + prompt_chars: Some(64), + reference_image_count: Some(2), + image_model: Some(GPT_IMAGE_2_MODEL), + }; + + let request_error = PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: "请求发送失败".to_string(), + endpoint: Some("https://vector.example/v1/images/generations".to_string()), + timeout: true, + connect: false, + request: true, + body: false, + status_code: None, + source: None, + audit: None, + }; + let invalid_config = PlatformImageError::InvalidConfig { + provider: VECTOR_ENGINE_PROVIDER, + message: "缺少配置".to_string(), + }; + let invalid_request = PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "请求不合法".to_string(), + }; + let upstream_timeout = PlatformImageError::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message: "upstream timeout".to_string(), + upstream_status: 502, + raw_excerpt: "deadline has elapsed".to_string(), + audit: Some(audit.clone()), + }; + + assert_eq!( + invalid_config.status_hint(), + PlatformImageStatusHint::ServiceUnavailable + ); + assert_eq!( + invalid_request.status_hint(), + PlatformImageStatusHint::BadRequest + ); + assert_eq!( + request_error.status_hint(), + PlatformImageStatusHint::GatewayTimeout + ); + assert_eq!( + upstream_timeout.status_hint(), + PlatformImageStatusHint::GatewayTimeout + ); + assert_eq!( + PlatformImageError::MissingImage { + provider: VECTOR_ENGINE_PROVIDER, + message: "缺图".to_string(), + audit: Some(audit.clone()), + } + .status_hint(), + PlatformImageStatusHint::BadGateway + ); + + let audit_ref = upstream_timeout.audit().expect("audit should be preserved"); + assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER); + assert_eq!( + audit_ref.endpoint, + "https://vector.example/v1/images/generations" + ); + assert_eq!(audit_ref.status_code, Some(504)); + assert_eq!(audit_ref.status_class, Some("5xx")); + assert!(audit_ref.timeout); + assert!(audit_ref.retryable); + assert_eq!(audit_ref.reference_image_count, Some(2)); + assert_eq!(audit_ref.image_model, Some(GPT_IMAGE_2_MODEL)); + assert!(invalid_config.audit().is_none()); + assert!(invalid_request.audit().is_none()); +} + +#[test] +fn extract_image_urls_and_b64_values_are_deduped() { + let payload = json!({ + "data": [ + {"image": "https://example.com/a.png"}, + {"url": "https://example.com/a.png"}, + {"image_url": "ftp://example.com/b.png"}, + {"url": "https://example.com/b.png"} + ], + "nested": { + "b64_json": ["YWJj", "ZGVm"], + "parts": [ + { + "inlineData": { + "mimeType": "image/png", + "data": "aW1hZ2UtMQ==" + } + }, + { + "inline_data": { + "mime_type": "image/jpeg", + "data": "aW1hZ2UtMg==" + } + }, + { + "inlineData": { + "mimeType": "text/plain", + "data": "bm90LWltYWdl" + } + } + ] + } + }); + + assert_eq!( + extract_image_urls(&payload), + vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string() + ] + ); + assert_eq!( + extract_b64_images(&payload), + vec![ + "YWJj".to_string(), + "ZGVm".to_string(), + "aW1hZ2UtMQ==".to_string(), + "aW1hZ2UtMg==".to_string(), + ] + ); } -- 2.52.0 From dedc58cce16861d9dc281696711e0461fc755002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:27:31 +0800 Subject: [PATCH 23/80] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20api-server=20?= =?UTF-8?q?=E7=9A=84=20GPT=5FIMAGE=5F2=5FC=5FMODEL=20=E9=87=8D=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openai_image_generation 重新导出 platform_image 的 GPT_IMAGE_2_C_MODEL 修复 editor_project 兼容历史 gpt-image-2-c 时引入的未解析导入与随之产生的模式绑定错误 --- server-rs/crates/api-server/src/openai_image_generation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 4c3cf2aea..a80d03749 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -26,7 +26,7 @@ use crate::{ pub(crate) use platform_image::{ GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, - GPT_IMAGE_2_MODEL, + GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, }; pub(crate) type OpenAiGeneratedImages = GeneratedImages; -- 2.52.0 From 2e2ed47bece99bf23eee9cf5a87b659f8a79510d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:29:11 +0800 Subject: [PATCH 24/80] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=9B=BE=E6=A0=87=20sp?= =?UTF-8?q?ritesheet=20=E8=AF=B7=E6=B1=82=E7=9A=84=20provider=5Fmodel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit editor_project_icon 的 EditorImageProviderRequest 增加 provider_model 字段 参考图存在时按编辑模型 gpt-image-2.5-sunburst-c 提交,nanobanana 与无参考图分支分别沿用自身模型 --- .../crates/api-server/src/editor_project_icon.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index c55e3cfad..5333bf89c 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -57,7 +57,10 @@ use crate::{ normalize_generated_image_asset_mime, }, http_error::AppError, - openai_image_generation::{build_openai_image_http_client, require_openai_image_settings}, + openai_image_generation::{ + GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, build_openai_image_http_client, + require_openai_image_settings, + }, platform_errors::{is_retryable_llm_error, map_llm_error, map_oss_error}, prompt::icon_spec::{PROMPT_MAX_LEN, build_extra_param_prompt, get_gen_icon_spec_prompt}, request_context::RequestContext, @@ -1719,6 +1722,15 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( &settings, EditorImageProviderRequest { model: generation_options.model, + provider_model: if generation_options.model + == EDITOR_IMAGE_MODEL_NANOBANANA2 + { + generation_options.model + } else if reference_images.is_empty() { + GPT_IMAGE_2_5_GENERATION_MODEL + } else { + GPT_IMAGE_2_5_EDIT_MODEL + }, prompt: prompt.as_str(), negative_prompt: None, size, -- 2.52.0 From ff4f42d2f25927ab0a527ea89041d9f32b4215e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:35:05 +0800 Subject: [PATCH 25/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E7=94=9F=E6=88=90=E6=A8=A1=E5=9E=8B=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=8C=96=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注释改为与实现一致:未显式传模型时归一为 gpt-image-2.5,而非继续走 gpt-image-2 --- server-rs/crates/api-server/src/editor_project.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 8d5b94b48..78c9164ac 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4002,7 +4002,7 @@ pub(crate) fn normalize_editor_generation_options( | EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS | EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS, ) => EDITOR_IMAGE_MODEL_NANOBANANA2, - // 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2; + // 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范统一归一到 gpt-image-2.5; // 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。 None => GPT_IMAGE_2_5_BUSINESS_NAME, _ => EDITOR_IMAGE_MODEL_NANOBANANA2, -- 2.52.0 From ae257a6ff891703bbefd626e196c5f99bbad6e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:36:03 +0800 Subject: [PATCH 26/80] =?UTF-8?q?=E6=8C=89=E7=AB=AF=E7=82=B9=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E7=BC=96=E8=BE=91=E5=99=A8=E7=94=9F=E6=88=90=E7=9A=84?= =?UTF-8?q?=20provider=5Fmodel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate_editor_image_for_owner 在参考图存在时改用 gpt-image-2.5-sunburst-c(编辑模型) 无参考图生成继续使用 gpt-image-2.5-flare-c,nanobanana 分支沿用自身模型 --- server-rs/crates/api-server/src/editor_project.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 78c9164ac..a49b673d1 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -3190,8 +3190,10 @@ where model: generation_options.model, provider_model: if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { generation_options.model - } else { + } else if reference_images.is_empty() { GPT_IMAGE_2_5_GENERATION_MODEL + } else { + GPT_IMAGE_2_5_EDIT_MODEL }, prompt: submitted_prompt.as_str(), negative_prompt, -- 2.52.0 From 8924bf335e6e4c44b3db217c6e98d003b6f8cc94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:36:28 +0800 Subject: [PATCH 27/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20UI=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=B4=A0=E6=9D=90=E6=8F=90=E5=8F=96=E7=9A=84=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI 设计图 spritesheet 提取走后端编辑端点,模型由 gpt-image-2.5-flare-c 改为 gpt-image-2.5-sunburst-c --- server-rs/crates/api-server/src/editor_project.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a49b673d1..8638d70ea 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -8516,7 +8516,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( create_openai_image_edit_with_references_and_model( &http_client, &settings, - GPT_IMAGE_2_5_GENERATION_MODEL, + GPT_IMAGE_2_5_EDIT_MODEL, prompt.as_str(), None, generation_options.provider_size.as_str(), -- 2.52.0 From 5ce267ae855dae7a4319a9da256423648676327d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:37:55 +0800 Subject: [PATCH 28/80] =?UTF-8?q?=E6=8C=89=E5=8F=82=E8=80=83=E5=9B=BE?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E8=A7=92=E8=89=B2=E4=B8=BB=E5=BD=A2=E8=B1=A1?= =?UTF-8?q?=E7=9A=84=20provider=20=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 角色主形象带参考图时走后端编辑端点,模型改用 gpt-image-2.5-sunburst-c 无参考图生成继续使用 gpt-image-2.5-flare-c --- .../crates/api-server/src/character_visual_assets.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index e4f62bb33..2eaa89e74 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -36,8 +36,8 @@ use crate::{ }, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_GENERATION_MODEL, - OpenAiImageSettings, build_openai_image_http_client, + DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, + GPT_IMAGE_2_5_GENERATION_MODEL, OpenAiImageSettings, build_openai_image_http_client, create_openai_image_generation_with_model, require_openai_image_settings, }, platform_errors::map_oss_error, @@ -946,10 +946,16 @@ async fn create_character_visual_generation_once( candidate_count: u32, reference_images: &[String], ) -> Result { + // 中文注释:参考图存在时后端会走编辑端点,因此必须提交编辑模型而不是生成模型。 + let provider_model = if reference_images.is_empty() { + GPT_IMAGE_2_5_GENERATION_MODEL + } else { + GPT_IMAGE_2_5_EDIT_MODEL + }; let generated = create_openai_image_generation_with_model( http_client, settings, - GPT_IMAGE_2_5_GENERATION_MODEL, + provider_model, prompt, Some(build_character_visual_negative_prompt().as_str()), size, -- 2.52.0 From 1a2e60bf052ee166f7158ce13edbd73924b7c61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:41:21 +0800 Subject: [PATCH 29/80] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E7=BC=BA=E5=B0=91=E5=8F=82=E8=80=83=E5=9B=BE?= =?UTF-8?q?=E6=97=B6=E7=9A=84=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform-image 新增 missing_reference_images_error 并在 create_image_edit_with_references_and_model 中复用 api-server 两个多参考图编辑入口在解析 provider 客户端前先校验参考图,返回 400 而不是 panic 恢复 vector_engine_multi_reference_edit_rejects_empty_references 期望的缺少参考图错误 --- .../api-server/src/openai_image_generation.rs | 32 ++++++++++++++++++- .../platform-image/src/image_provider/mod.rs | 1 + .../src/image_provider/runtime/error.rs | 15 ++++++++- .../src/image_provider/runtime/executor.rs | 10 +++--- .../src/image_provider/runtime/mod.rs | 2 +- server-rs/crates/platform-image/src/lib.rs | 3 +- 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index a80d03749..e276d2cea 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -5,7 +5,7 @@ use platform_image::{ VECTOR_ENGINE_PROVIDER, build_image_http_client, create_image_edit, create_image_edit_with_references, create_image_edit_with_references_and_model, create_image_generation, create_image_generation_with_model, - create_nanobanana_generate_content, + create_nanobanana_generate_content, missing_reference_images_error, }; #[cfg(test)] use platform_image::{build_image_request_body, images_edit_url, images_generation_url}; @@ -320,6 +320,21 @@ pub(crate) async fn create_openai_image_edit_with_references( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": reference_images.len(), }); + // 中文注释:先做本地参考图校验,避免 provider 客户端缺失时在校验前 panic。 + if reference_images.is_empty() { + return map_platform_image_result( + settings, + Err(missing_reference_images_error( + ImageProvider::Tiantoken, + failure_context, + )), + "image_edit_with_references", + failure_context, + request_payload, + started_at_micros, + ) + .await; + } let provider_client = settings.client_for_model(GPT_IMAGE_2_5_EDIT_MODEL); let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_EDIT_MODEL); let result = create_image_edit_with_references( @@ -364,6 +379,21 @@ pub(crate) async fn create_openai_image_edit_with_references_and_model( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": reference_images.len(), }); + // 中文注释:先做本地参考图校验,避免 provider 客户端缺失时在校验前 panic。 + if reference_images.is_empty() { + return map_platform_image_result( + settings, + Err(missing_reference_images_error( + ImageProvider::Tiantoken, + failure_context, + )), + "image_edit_with_references", + failure_context, + request_payload, + started_at_micros, + ) + .await; + } let provider_client = settings.client_for_model(model); let provider_settings = settings.provider_settings_for_model(model); let result = create_image_edit_with_references_and_model( diff --git a/server-rs/crates/platform-image/src/image_provider/mod.rs b/server-rs/crates/platform-image/src/image_provider/mod.rs index 118e9af4d..a0695bfcb 100644 --- a/server-rs/crates/platform-image/src/image_provider/mod.rs +++ b/server-rs/crates/platform-image/src/image_provider/mod.rs @@ -26,5 +26,6 @@ pub use runtime::{ create_image_edit, create_image_edit_with_references, create_image_edit_with_references_and_model, create_image_generation, create_image_generation_with_model, create_nanobanana_generate_content, download_remote_image, + missing_reference_images_error, }; pub use transport::{build_image_http_client, build_image_provider_client}; diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/error.rs b/server-rs/crates/platform-image/src/image_provider/runtime/error.rs index a58f2d247..b05e44117 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/error.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/error.rs @@ -1,6 +1,19 @@ use std::{error::Error, fmt}; -use crate::image_provider::{audit::PlatformImageFailureAudit, util::is_timeout_message}; +use crate::image_provider::{ + audit::PlatformImageFailureAudit, types::ImageProvider, util::is_timeout_message, +}; + +/// 图片编辑缺少参考图时的统一错误;调用方可在解析 provider 客户端之前先行校验。 +pub fn missing_reference_images_error( + provider: ImageProvider, + failure_context: &str, +) -> PlatformImageError { + PlatformImageError::InvalidRequest { + provider: provider.as_str(), + message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), + } +} #[derive(Clone, Debug)] pub enum PlatformImageError { diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index a98687c1c..808fc6873 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -19,7 +19,7 @@ use crate::image_provider::{ map_curl_error, send_image_json_request_with_curl, send_image_multipart_edit_request_with_curl, }, - error::PlatformImageError, + error::{PlatformImageError, missing_reference_images_error}, image_source::resolve_reference_images, request::{ build_image_edit_request_log_params, build_image_request_body_with_model, @@ -499,10 +499,10 @@ pub async fn create_image_edit_with_references_and_model( } })?; if reference_images.is_empty() { - return Err(PlatformImageError::InvalidRequest { - provider: settings.provider.as_str(), - message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), - }); + return Err(missing_reference_images_error( + settings.provider, + failure_context, + )); } if reference_images.len() > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES { return Err(PlatformImageError::InvalidRequest { diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs b/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs index 88a04c640..63bf83afb 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs @@ -11,7 +11,7 @@ pub(crate) use crate::image_provider::transport::curl as curl_transport; pub use audit::PlatformImageFailureAudit; pub(crate) use audit::build_failure_audit; pub(crate) use budget::{effective_request_timeout_ms, request_budget_exhausted_error}; -pub use error::{PlatformImageError, PlatformImageStatusHint}; +pub use error::{PlatformImageError, PlatformImageStatusHint, missing_reference_images_error}; pub use executor::{ create_image_edit, create_image_edit_with_references, create_image_edit_with_references_and_model, create_image_generation, diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index 654062eac..7f7a69d5b 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -15,7 +15,8 @@ pub use image_provider::{ create_image_edit_with_references, create_image_edit_with_references_and_model, create_image_generation, create_image_generation_with_model, create_nanobanana_generate_content, download_remote_image, images_edit_url, - images_generation_url, nanobanana_generate_content_url, resolve_image_provider, + images_generation_url, missing_reference_images_error, nanobanana_generate_content_url, + resolve_image_provider, }; pub use pixel_art_snapper::{ PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT, -- 2.52.0 From 0c54cca81cb16f2875818847f631274343a83122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:43:12 +0800 Subject: [PATCH 30/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E7=BC=BA=E7=9C=81=E6=A8=A1=E5=9E=8B=E7=9A=84?= =?UTF-8?q?=E8=AE=A1=E4=BB=B7=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec_model_mud_points 在模型为空时显式回退 GPT Image 2.5 生成档,避免落到 nanobanana 档 补中文注释说明与 normalize_editor_generation_options 的缺省模型保持一致 --- .../crates/api-server/src/editor_generation_config.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 610731d80..df2850cc5 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -161,7 +161,12 @@ impl EditorGenerationPricingConfig { if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() { return price_mud_points; } - let normalized_model = normalize_editor_image_model(model); + // 中文注释:生成规范缺省模型与 normalize_editor_generation_options 保持一致, + // 未显式传模型时按 GPT Image 2.5 生成档计价,不要落到 nanobanana 档。 + let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) { + None => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + Some(_) => normalize_editor_image_model(model), + }; read_tier_price( &self.models, normalized_model, -- 2.52.0 From 42fdb57efebddfb4ba5045c777e68c327bdfd09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:46:47 +0800 Subject: [PATCH 31/80] =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E6=A8=A1=E5=9E=8B=E4=B8=8E=20provider=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executor 三个请求入口改用 ensure_provider_matches_model,模型不属于当前 provider 时直接返回 InvalidRequest 新增单测覆盖 provider 不一致的快速失败,并把 GPT 模型相关集成测试的 settings 调整为 tiantoken --- .../src/image_provider/runtime/executor.rs | 91 +++++++++++++++---- .../platform-image/tests/image_provider.rs | 8 +- 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index 808fc6873..3cafa3f57 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -69,12 +69,7 @@ pub async fn create_image_generation_with_model( failure_context: &str, ) -> Result { let requested_model = normalize_image_model(model); - resolve_image_provider(requested_model).map_err(|message| { - PlatformImageError::InvalidRequest { - provider: settings.provider.as_str(), - message: format!("{failure_context}:{message}:{requested_model}"), - } - })?; + ensure_provider_matches_model(settings, requested_model, failure_context)?; if !reference_images.is_empty() { let resolved_references = resolve_reference_images( http_client, @@ -268,10 +263,7 @@ pub async fn create_nanobanana_generate_content( failure_context: &str, ) -> Result { let model = normalize_image_model(model); - resolve_image_provider(model).map_err(|message| PlatformImageError::InvalidRequest { - provider: settings.provider.as_str(), - message: format!("{failure_context}:{message}:{model}"), - })?; + ensure_provider_matches_model(settings, model, failure_context)?; if reference_images.len() > VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES { return Err(PlatformImageError::InvalidRequest { provider: settings.provider.as_str(), @@ -492,12 +484,7 @@ pub async fn create_image_edit_with_references_and_model( failure_context: &str, ) -> Result { let requested_model = normalize_image_model(model); - resolve_image_provider(requested_model).map_err(|message| { - PlatformImageError::InvalidRequest { - provider: settings.provider.as_str(), - message: format!("{failure_context}:{message}:{requested_model}"), - } - })?; + ensure_provider_matches_model(settings, requested_model, failure_context)?; if reference_images.is_empty() { return Err(missing_reference_images_error( settings.provider, @@ -704,6 +691,34 @@ pub async fn create_image_edit_with_references_and_model( } } +/// 校验请求模型与 settings 绑定的 provider 是否一致。 +/// +/// executor 用 `settings.base_url`/`settings.api_key` 发请求,如果调用方传入了属于 +/// 另一个 provider 的模型,就会把错误的密钥/网关当成目标端点。这里显式 fail fast, +/// 避免静默地把请求发到不匹配的 provider。 +fn ensure_provider_matches_model( + settings: &ImageProviderSettings, + model: &str, + failure_context: &str, +) -> Result<(), PlatformImageError> { + let resolved = + resolve_image_provider(model).map_err(|message| PlatformImageError::InvalidRequest { + provider: settings.provider.as_str(), + message: format!("{failure_context}:{message}:{model}"), + })?; + if resolved != settings.provider { + return Err(PlatformImageError::InvalidRequest { + provider: settings.provider.as_str(), + message: format!( + "{failure_context}:模型 {model} 属于 {},与当前 {provider} 设置不一致", + resolved.as_str(), + provider = settings.provider.as_str() + ), + }); + } + Ok(()) +} + fn preferred_image_upstream_model(requested_model: &str) -> &str { // Provider routing is selected by api-server at the task boundary. Keep the // concrete value intact here so retries stay on the same model and legacy @@ -922,7 +937,7 @@ mod tests { } } - fn test_settings() -> ImageProviderSettings { + fn vector_engine_settings() -> ImageProviderSettings { ImageProviderSettings { provider: ImageProvider::VectorEngine, base_url: "http://127.0.0.1:9".to_string(), @@ -932,12 +947,22 @@ mod tests { } } + fn tiantoken_settings() -> ImageProviderSettings { + ImageProviderSettings { + provider: ImageProvider::Tiantoken, + base_url: "http://127.0.0.1:9".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + request_deadline: None, + } + } + #[tokio::test] async fn gpt_image_edit_rejects_six_references_before_network_send() { let references = (0..6).map(reference_image).collect::>(); let error = create_image_edit_with_references_and_model( &reqwest::Client::new(), - &test_settings(), + &tiantoken_settings(), GPT_IMAGE_2_MODEL, "测试提示词", None, @@ -957,7 +982,7 @@ mod tests { let references = (0..15).map(reference_image).collect::>(); let error = create_nanobanana_generate_content( &reqwest::Client::new(), - &test_settings(), + &vector_engine_settings(), crate::image_provider::constants::NANOBANANA_2_MODEL, "测试提示词", None, @@ -975,7 +1000,7 @@ mod tests { #[tokio::test] async fn expired_deadline_stops_generation_before_network_send() { let settings = ImageProviderSettings { - provider: ImageProvider::VectorEngine, + provider: ImageProvider::Tiantoken, base_url: "http://127.0.0.1:9".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, @@ -1007,6 +1032,32 @@ mod tests { ); } + #[tokio::test] + async fn image_generation_rejects_model_from_another_provider() { + let error = create_image_generation( + &reqwest::Client::new(), + &vector_engine_settings(), + "测试提示词", + None, + "1024x1024", + 1, + &[], + "测试图片生成失败", + ) + .await + .expect_err("provider mismatch must fail fast"); + + match error { + PlatformImageError::InvalidRequest { message, .. } => { + assert!( + message.contains("设置不一致"), + "unexpected message: {message}" + ); + } + other => panic!("expected InvalidRequest, got {other:?}"), + } + } + #[test] fn image_provider_send_retry_policy_allows_four_retries_before_final_attempt() { assert_eq!(VECTOR_ENGINE_SEND_MAX_ATTEMPTS, 5); diff --git a/server-rs/crates/platform-image/tests/image_provider.rs b/server-rs/crates/platform-image/tests/image_provider.rs index 210977600..884c9359c 100644 --- a/server-rs/crates/platform-image/tests/image_provider.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -245,7 +245,7 @@ async fn image_edit_retries_send_timeout_once_and_succeeds() { }); let settings = ImageProviderSettings { - provider: ImageProvider::VectorEngine, + provider: ImageProvider::Tiantoken, base_url: format!("http://{server_addr}/v1"), api_key: "test-key".to_string(), request_timeout_ms: 40, @@ -346,7 +346,7 @@ async fn image_provider_deadline_clips_stalled_attempt_and_prevents_retry() { }); let mut settings = ImageProviderSettings { - provider: ImageProvider::VectorEngine, + provider: ImageProvider::Tiantoken, base_url: format!("http://{server_addr}/v1"), api_key: "test-key".to_string(), request_timeout_ms: 5_000, @@ -506,7 +506,7 @@ async fn image_generation_stays_on_model_after_upstream_502() { }); let settings = ImageProviderSettings { - provider: ImageProvider::VectorEngine, + provider: ImageProvider::Tiantoken, base_url: format!("http://{server_addr}/v1"), api_key: "test-key".to_string(), request_timeout_ms: 1_000, @@ -793,7 +793,7 @@ async fn start_http_response_sequence( fn test_image_provider_settings(base_url: String) -> ImageProviderSettings { ImageProviderSettings { - provider: ImageProvider::VectorEngine, + provider: ImageProvider::Tiantoken, base_url, api_key: "test-key".to_string(), request_timeout_ms: 1_000, -- 2.52.0 From 939c35a9b96f70c03b55b23371daed87ef1f60ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:48:11 +0800 Subject: [PATCH 32/80] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=BC=BA=E7=9C=81?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=A8=A1=E5=9E=8B=E7=9A=84=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=8C=96=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalize_image_model 的空白模型回退改为 GPT_IMAGE_2_5_GENERATION_MODEL,与 build_image_request_body 默认值一致 补充单测断言空白模型与默认生成模型产出同一请求体 --- .../src/image_provider/protocol/request.rs | 7 ++++++- server-rs/crates/platform-image/tests/image_provider.rs | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs index b60a93f2b..0210b149b 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs @@ -87,9 +87,14 @@ pub fn build_nanobanana_generate_content_request_body( }) } +/// 归一化调用方传入的模型字符串。 +/// +/// 只处理缺省场景:空/空白输入落到当前默认生成模型 `GPT_IMAGE_2_5_GENERATION_MODEL`, +/// 与 `build_image_request_body` 的默认值保持一致;显式传入的模型(含历史 +/// `gpt-image-2` 等旧值)原样保留,交给 `resolve_image_provider` 做路由。 pub fn normalize_image_model(model: &str) -> &str { match model.trim() { - "" => GPT_IMAGE_2_MODEL, + "" => GPT_IMAGE_2_5_GENERATION_MODEL, value => value, } } diff --git a/server-rs/crates/platform-image/tests/image_provider.rs b/server-rs/crates/platform-image/tests/image_provider.rs index 884c9359c..a8239df71 100644 --- a/server-rs/crates/platform-image/tests/image_provider.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -59,6 +59,15 @@ fn image_provider_clamps_gpt_image_2_explicit_pixel_sizes_to_its_supported_pixel assert_eq!(poster["size"], "1280x720"); } +#[test] +fn image_provider_blank_model_falls_back_to_default_generation_model() { + let blank = build_image_request_body_with_model(" ", "空模型", None, "1024x1024", 1, &[]); + let default = build_image_request_body("空模型", None, "1024x1024", 1, &[]); + + assert_eq!(blank["model"], GPT_IMAGE_2_5_GENERATION_MODEL); + assert_eq!(blank["model"], default["model"]); +} + #[test] fn image_provider_normalizes_2k_landscape_spec_size() { let body = build_image_request_body("生成规范图", None, "2048x1152", 1, &[]); -- 2.52.0 From bd0a06e3d3ca62e4211999f8f58334be302655fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 13:55:30 +0800 Subject: [PATCH 33/80] =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=9A=84=E5=8E=86=E5=8F=B2=20gpt-image-2=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api-server 的图片类 Agent 工具在反序列化前把 gpt-image-2 / gpt-image-2-c 归一为 gpt-image-2.5,旧持久化工具调用不再被校验直接拒绝 EditImageTool 计价改用 gpt-image-2.5 业务模型常量,并新增别名归一单测、更新受影响的校验与计价断言 --- .../api-server/src/editor_agent/tool.rs | 133 ++++++++++++++---- 1 file changed, 103 insertions(+), 30 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 0805fb4d4..a1ec7b5c0 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -28,7 +28,7 @@ use platform_editor_agent::agent::tools::generate_video::{ }; use platform_editor_agent::framework::error::PromptError; use platform_editor_agent::framework::tool::{Tool, ToolDyn, null_tool_args_as_missing}; -use platform_image::GPT_IMAGE_2_MODEL; +use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL}; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Value, json}; @@ -224,6 +224,45 @@ fn parse_internal( }) } +/// 把持久化的历史 GPT Image 2 模型别名(`gpt-image-2` / `gpt-image-2-c`)改写为当前 +/// 业务模型 `gpt-image-2.5`。 +/// +/// 平台层图片工具只接受 `gemini-3.1-flash-image-preview` 与 `gpt-image-2.5`,但历史会话 +/// 里仍可能保存着 `gpt-image-2` 的工具调用参数。这里在进入强类型校验前做一次别名归一, +/// 既让旧调用继续可用,也把计价统一到当前业务模型档位。 +fn normalize_legacy_image_model_args(value: &Value) -> Value { + let Some(model) = value.get("model").and_then(Value::as_str) else { + return value.clone(); + }; + if !matches!(model.trim(), GPT_IMAGE_2_MODEL | GPT_IMAGE_2_C_MODEL) { + return value.clone(); + } + let mut normalized = value.clone(); + if let Some(fields) = normalized.as_object_mut() { + fields.insert( + "model".to_string(), + Value::String(GPT_IMAGE_2_5_BUSINESS_NAME.to_string()), + ); + } + normalized +} + +/// 图片类工具的 `validate_args` 入口:先归一历史模型别名,再走强类型反序列化。 +fn parse_invalid_image_args( + tool_name: &str, + value: &Value, +) -> Result { + parse_invalid_args(tool_name, &normalize_legacy_image_model_args(value)) +} + +/// 图片类工具的 `pricing` 入口:即使调用方直接传入原始 args,也要先归一历史模型别名。 +fn parse_internal_image_args( + label: &str, + value: &Value, +) -> Result { + parse_internal(label, &normalize_legacy_image_model_args(value)) +} + /// SFX V2 的显式 `duration: null` 表示自动时长,必须原样保留,不能被通用的 /// “顶层 null 当缺省”兼容层恢复成手动 5 秒。但其余字段仍要走该兼容层: /// `model` 是无 Option 的 String,LLM 传 `model: null` 时若不剥离会直接反序列化失败。 @@ -311,7 +350,7 @@ fn editor_agent_image_mud_points( impl EditorAgentTool for GenerateImageTool { fn validate_args(&self, args: &Value) -> Result { - let args: GenerateImageToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateImageToolArgs = parse_invalid_image_args(Self::NAME, args)?; GenerateImageTool::validate_args(self, &args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; serialize_normalized_args(Self::NAME, &args) @@ -322,7 +361,7 @@ impl EditorAgentTool for GenerateImageTool { pricing: &EditorGenerationPricingConfig, args: &Value, ) -> Result { - let args: GenerateImageToolArgs = parse_internal("generate image args", args)?; + let args: GenerateImageToolArgs = parse_internal_image_args("generate image args", args)?; Ok(editor_agent_image_mud_points( pricing, None, @@ -337,7 +376,8 @@ impl EditorAgentTool for GenerateImageTool { pricing: &EditorGenerationPricingConfig, ) -> Result { let price_mud_points = self.pricing(pricing, args)?; - let args: GenerateImageToolArgs = parse_internal("generate image display args", args)?; + let args: GenerateImageToolArgs = + parse_internal_image_args("generate image display args", args)?; let mut display_args = EditorAgentToolCallDisplayArgs::default(); push_image_generation_display_args( &mut display_args, @@ -358,7 +398,7 @@ impl EditorAgentTool for GenerateImageTool { context: &EditorAgentPrepareJobContext<'_>, ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; - let args: GenerateImageToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateImageToolArgs = parse_invalid_image_args(Self::NAME, args)?; let title = args.prompt.clone(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; let payload = EditorImageGenerationRequest { @@ -397,7 +437,7 @@ impl EditorAgentTool for GenerateImageTool { args: &Value, result: &Value, ) -> Result { - let args: GenerateImageToolArgs = parse_internal("generate image args", args)?; + let args: GenerateImageToolArgs = parse_internal_image_args("generate image args", args)?; let result: EditorImageGenerationResult = parse_internal("generate image result", result)?; Ok(GenerateImageTool::format_execute_message( self, &args, &result, @@ -418,7 +458,7 @@ impl EditorAgentTool for GenerateImageTool { impl EditorAgentTool for GenerateCharacterTool { fn validate_args(&self, args: &Value) -> Result { - let args: GenerateCharacterToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateCharacterToolArgs = parse_invalid_image_args(Self::NAME, args)?; GenerateCharacterTool::validate_args(self, &args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; serialize_normalized_args(Self::NAME, &args) @@ -429,7 +469,8 @@ impl EditorAgentTool for GenerateCharacterTool { pricing: &EditorGenerationPricingConfig, args: &Value, ) -> Result { - let args: GenerateCharacterToolArgs = parse_internal("generate character args", args)?; + let args: GenerateCharacterToolArgs = + parse_internal_image_args("generate character args", args)?; Ok(editor_agent_image_mud_points( pricing, Some("character"), @@ -445,7 +486,7 @@ impl EditorAgentTool for GenerateCharacterTool { ) -> Result { let price_mud_points = self.pricing(pricing, args)?; let args: GenerateCharacterToolArgs = - parse_internal("generate character display args", args)?; + parse_internal_image_args("generate character display args", args)?; let mut display_args = EditorAgentToolCallDisplayArgs::default(); push_image_generation_display_args( &mut display_args, @@ -466,7 +507,7 @@ impl EditorAgentTool for GenerateCharacterTool { context: &EditorAgentPrepareJobContext<'_>, ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; - let args: GenerateCharacterToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateCharacterToolArgs = parse_invalid_image_args(Self::NAME, args)?; let title = args.prompt.clone(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; let payload = EditorImageGenerationRequest { @@ -505,7 +546,8 @@ impl EditorAgentTool for GenerateCharacterTool { args: &Value, result: &Value, ) -> Result { - let args: GenerateCharacterToolArgs = parse_internal("generate character args", args)?; + let args: GenerateCharacterToolArgs = + parse_internal_image_args("generate character args", args)?; let result: EditorImageGenerationResult = parse_internal("generate character result", result)?; Ok(GenerateCharacterTool::format_execute_message( @@ -528,7 +570,7 @@ impl EditorAgentTool for GenerateCharacterTool { impl EditorAgentTool for GenerateUiDesignTool { fn validate_args(&self, args: &Value) -> Result { - let args: GenerateUiDesignToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateUiDesignToolArgs = parse_invalid_image_args(Self::NAME, args)?; GenerateUiDesignTool::validate_args(self, &args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; serialize_normalized_args(Self::NAME, &args) @@ -539,7 +581,8 @@ impl EditorAgentTool for GenerateUiDesignTool { pricing: &EditorGenerationPricingConfig, args: &Value, ) -> Result { - let args: GenerateUiDesignToolArgs = parse_internal("generate UI design args", args)?; + let args: GenerateUiDesignToolArgs = + parse_internal_image_args("generate UI design args", args)?; Ok(editor_agent_image_mud_points( pricing, Some("ui-design"), @@ -555,7 +598,7 @@ impl EditorAgentTool for GenerateUiDesignTool { ) -> Result { let price_mud_points = self.pricing(pricing, args)?; let args: GenerateUiDesignToolArgs = - parse_internal("generate UI design display args", args)?; + parse_internal_image_args("generate UI design display args", args)?; let mut display_args = EditorAgentToolCallDisplayArgs::default(); push_image_generation_display_args( &mut display_args, @@ -576,7 +619,7 @@ impl EditorAgentTool for GenerateUiDesignTool { context: &EditorAgentPrepareJobContext<'_>, ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; - let args: GenerateUiDesignToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateUiDesignToolArgs = parse_invalid_image_args(Self::NAME, args)?; let title = args.prompt.clone(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; let payload = EditorImageGenerationRequest { @@ -615,7 +658,8 @@ impl EditorAgentTool for GenerateUiDesignTool { args: &Value, result: &Value, ) -> Result { - let args: GenerateUiDesignToolArgs = parse_internal("generate UI design args", args)?; + let args: GenerateUiDesignToolArgs = + parse_internal_image_args("generate UI design args", args)?; let result: EditorImageGenerationResult = parse_internal("generate UI design result", result)?; Ok(GenerateUiDesignTool::format_execute_message( @@ -638,7 +682,7 @@ impl EditorAgentTool for GenerateUiDesignTool { impl EditorAgentTool for EditImageTool { fn validate_args(&self, args: &Value) -> Result { - let args: EditImageToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: EditImageToolArgs = parse_invalid_image_args(Self::NAME, args)?; if let Some(error) = EditImageTool::validate_args(self, &args) { return Err(EditorAgentToolError::invalid_args(error.to_string())); } @@ -650,11 +694,11 @@ impl EditorAgentTool for EditImageTool { pricing: &EditorGenerationPricingConfig, args: &Value, ) -> Result { - let _: EditImageToolArgs = parse_internal("edit image args", args)?; + let _: EditImageToolArgs = parse_internal_image_args("edit image args", args)?; Ok(editor_agent_image_mud_points( pricing, Some("quick-edit"), - GPT_IMAGE_2_MODEL, + GPT_IMAGE_2_5_BUSINESS_NAME, Some("1K"), )) } @@ -665,7 +709,7 @@ impl EditorAgentTool for EditImageTool { pricing: &EditorGenerationPricingConfig, ) -> Result { let price_mud_points = self.pricing(pricing, args)?; - let args: EditImageToolArgs = parse_internal("edit image display args", args)?; + let args: EditImageToolArgs = parse_internal_image_args("edit image display args", args)?; let mut display_args = EditorAgentToolCallDisplayArgs::default(); push_string_display_arg(&mut display_args, "prompt", "修改要求", args.prompt); push_string_display_arg(&mut display_args, "model", "模型", args.model); @@ -694,7 +738,7 @@ impl EditorAgentTool for EditImageTool { context: &EditorAgentPrepareJobContext<'_>, ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; - let args: EditImageToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: EditImageToolArgs = parse_invalid_image_args(Self::NAME, args)?; let source_reference_id = self .context .image_metadata(&args.object_image_id) @@ -739,7 +783,7 @@ impl EditorAgentTool for EditImageTool { args: &Value, result: &Value, ) -> Result { - let args: EditImageToolArgs = parse_internal("edit image args", args)?; + let args: EditImageToolArgs = parse_internal_image_args("edit image args", args)?; let result: EditorImageEditResult = parse_internal("edit image result", result)?; Ok(EditImageTool::format_execute_message(self, args, result)) } @@ -785,7 +829,7 @@ fn resolve_icon_spec_reference_id<'a>( impl EditorAgentTool for GenerateIconSpritesheetTool { fn validate_args(&self, args: &Value) -> Result { - let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateIconSpritesheetToolArgs = parse_invalid_image_args(Self::NAME, args)?; let args = GenerateIconSpritesheetTool::normalize_args(self, args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?; @@ -798,7 +842,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { args: &Value, ) -> Result { let args: GenerateIconSpritesheetToolArgs = - parse_internal("generate icon spritesheet args", args)?; + parse_internal_image_args("generate icon spritesheet args", args)?; Ok(editor_agent_image_mud_points( pricing, Some("icon"), @@ -814,7 +858,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { ) -> Result { let price_mud_points = self.pricing(pricing, args)?; let args: GenerateIconSpritesheetToolArgs = - parse_internal("generate icon spritesheet display args", args)?; + parse_internal_image_args("generate icon spritesheet display args", args)?; let mut display_args = EditorAgentToolCallDisplayArgs::default(); push_string_display_arg( &mut display_args, @@ -855,7 +899,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { context: &EditorAgentPrepareJobContext<'_>, ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; - let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateIconSpritesheetToolArgs = parse_invalid_image_args(Self::NAME, args)?; let reference_id = resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?.to_string(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; @@ -901,7 +945,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { result: &Value, ) -> Result { let args: GenerateIconSpritesheetToolArgs = - parse_internal("generate icon spritesheet args", args)?; + parse_internal_image_args("generate icon spritesheet args", args)?; let result: EditorIconSpritesheetResult = parse_internal("generate icon spritesheet result", result)?; Ok(GenerateIconSpritesheetTool::format_execute_message( @@ -1248,7 +1292,9 @@ mod tests { use spacetime_client::{EditorCanvasRecord, EditorCanvasViewportRecord}; use super::*; - use crate::editor_generation_config::load_editor_generation_pricing_from_paths; + use crate::editor_generation_config::{ + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, load_editor_generation_pricing_from_paths, + }; fn pricing() -> EditorGenerationPricingConfig { load_editor_generation_pricing_from_paths(None).expect("default editor pricing should load") @@ -1348,7 +1394,7 @@ mod tests { "unknown": "drop-me" })) .expect("typed UI validation should pass"); - assert_eq!(normalized["model"], GPT_IMAGE_2_MODEL); + assert_eq!(normalized["model"], GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(normalized["aspect_ratio"], "1:1"); assert_eq!(normalized["image_size"], "1K"); assert!(normalized.get("unknown").is_none()); @@ -1493,6 +1539,31 @@ mod tests { ); } + #[test] + fn dyn_validation_normalizes_legacy_gpt_image_2_alias() { + let edit = editor_agent_tool(EditImageTool::NAME, &context_with_image("image-1")) + .expect("edit tool should resolve"); + let normalized = edit + .validate_args(&json!({ + "object_image_id": "image-1", + "prompt": "把背景换成夜晚", + "model": GPT_IMAGE_2_MODEL + })) + .expect("legacy edit args should validate"); + assert_eq!(normalized["model"], GPT_IMAGE_2_5_BUSINESS_NAME); + + let image = editor_agent_tool(GenerateImageTool::NAME, &EditorToolContext::default()) + .expect("image tool should resolve"); + let normalized = image + .validate_args(&json!({ + "prompt": "生成图片", + "model": GPT_IMAGE_2_C_MODEL, + "image_size": "2K" + })) + .expect("legacy generation alias should validate"); + assert_eq!(normalized["model"], GPT_IMAGE_2_5_BUSINESS_NAME); + } + #[test] fn dyn_pricing_keeps_all_existing_tool_formulas() { let pricing = pricing(); @@ -1510,6 +1581,7 @@ mod tests { ), ( GenerateUiDesignTool::NAME, + // 历史持久化的 `gpt-image-2` 会先归一为 `gpt-image-2.5`,所以计价仍走当前档位。 json!({ "prompt": "界面", "model": platform_image::GPT_IMAGE_2_MODEL, "image_size": "2K" }), 5, ), @@ -1552,9 +1624,10 @@ mod tests { #[test] fn dyn_pricing_uses_the_supplied_runtime_snapshot() { let mut pricing = pricing(); + // 历史 `gpt-image-2` 输入会归一为 `gpt-image-2.5-flare-c`,所以要改这个档位的价格。 pricing .models - .get_mut(GPT_IMAGE_2_MODEL) + .get_mut(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) .expect("gpt image pricing should exist") .prices .insert("2K".to_string(), 37); -- 2.52.0 From fa84a364d6ffecef8720bfc598a150d459d149d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 14:20:29 +0800 Subject: [PATCH 34/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=AE=A3=E5=8F=91?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E7=94=9F=E6=88=90=E9=94=81=E5=AE=9A=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 源码文本断言从 Some(GPT_IMAGE_2_MODEL) 更新为 Some(GPT_IMAGE_2_5_BUSINESS_NAME),与当前锁定实现一致 用例改名为 publication_material_generation_is_locked_to_gpt_image_2_5 --- server-rs/crates/api-server/src/editor_project.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 8638d70ea..b7485c46f 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -18102,7 +18102,7 @@ mod tests { } #[test] - fn publication_material_generation_is_locked_to_gpt_image_2() { + fn publication_material_generation_is_locked_to_gpt_image_2_5() { let source = concat!( include_str!("editor_project_icon.rs"), include_str!("editor_project.rs") @@ -18114,7 +18114,7 @@ mod tests { "let effective_price_mud_points", &[ "is_publication_material_generation", - "Some(GPT_IMAGE_2_MODEL)", + "Some(GPT_IMAGE_2_5_BUSINESS_NAME)", ], ); } -- 2.52.0 From 4415723f7fccc4540aac35e0aadeb22a2d91b56c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 14:21:33 +0800 Subject: [PATCH 35/80] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7=E8=B7=AF=E7=94=B1=E7=94=A8=E4=BE=8B=E5=88=B0?= =?UTF-8?q?=20GPT=20Image=202.5=20=E5=85=AC=E5=BC=80=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公开定价断言改为读取业务模型名 gpt-image-2.5 的生成档价格 补充断言公开投影不暴露 gpt-image-2 与两个 provider 具体 key --- server-rs/crates/api-server/src/app.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d4d081884..40887c04e 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -5712,14 +5712,29 @@ mod tests { payload["models"]["gemini-3.1-flash-image-preview"]["prices"]["1K"], Value::Number(12.into()) ); + // 公开投影只暴露业务模型名 gpt-image-2.5(取生成档价格), + // 不外泄具体 provider key,也不再暴露历史 gpt-image-2。 assert_eq!( - payload["models"]["gpt-image-2"]["prices"]["1K"], + payload["models"]["gpt-image-2.5"]["prices"]["1K"], Value::Number(3.into()) ); assert_eq!( - payload["models"]["gpt-image-2"]["prices"]["2K"], + payload["models"]["gpt-image-2.5"]["prices"]["2K"], Value::Number(5.into()) ); + let public_models = payload["models"] + .as_object() + .expect("公开定价必须暴露 models 对象"); + for hidden_model in [ + "gpt-image-2", + "gpt-image-2.5-flare-c", + "gpt-image-2.5-sunburst-c", + ] { + assert!( + !public_models.contains_key(hidden_model), + "公开定价不应暴露 {hidden_model}" + ); + } assert_eq!( payload["models"]["audio1.0"]["price"], Value::Number(5.into()) -- 2.52.0 From 28e6e67e984cc594999d4c923f4b5a444c29b6e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 14:23:18 +0800 Subject: [PATCH 36/80] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7=E4=BF=9D=E5=AD=98=E7=94=A8=E4=BE=8B=E7=9A=84?= =?UTF-8?q?=20GPT=20Image=202.5=20=E6=A1=A3=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台保存 payload 用 gpt-image-2.5-flare-c 与 gpt-image-2.5-sunburst-c 替换历史单 key,保存重新返回 200 公开定价断言改为读取业务模型名 gpt-image-2.5 的生成档价格,并新增 admin 定价读回具体 key 的断言 --- server-rs/crates/api-server/src/app.rs | 52 +++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 40887c04e..c2fb8e7d4 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -5780,10 +5780,14 @@ mod tests { "unit": "perGeneration", "prices": { "0.5K": 9, "1K": 18, "2K": 36 } }, - "gpt-image-2": { + "gpt-image-2.5-flare-c": { "unit": "perGeneration", "prices": { "1K": 31, "2K": 62 } }, + "gpt-image-2.5-sunburst-c": { + "unit": "perGeneration", + "prices": { "1K": 41, "2K": 82 } + }, "seedance2.0-fast": { "unit": "perSecond", "prices": { "480p": 11, "720p": 22, "1080p": 44 } @@ -5823,6 +5827,7 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let public_response = app + .clone() .oneshot( Request::builder() .uri("/api/editor/generation-pricing") @@ -5840,10 +5845,24 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("public pricing payload should be json"); + // 公开投影按业务模型名暴露生成档价格;具体 provider key 不下发普通前端。 assert_eq!( - payload["models"]["gpt-image-2"]["prices"]["2K"], + payload["models"]["gpt-image-2.5"]["prices"]["2K"], Value::Number(62.into()) ); + let public_models = payload["models"] + .as_object() + .expect("公开定价必须暴露 models 对象"); + for hidden_model in [ + "gpt-image-2", + "gpt-image-2.5-flare-c", + "gpt-image-2.5-sunburst-c", + ] { + assert!( + !public_models.contains_key(hidden_model), + "公开定价不应暴露 {hidden_model}" + ); + } assert_eq!( payload["models"]["audio1.0"]["unit"], Value::String("perGeneration".to_string()) @@ -5856,6 +5875,35 @@ mod tests { payload["models"]["seedance2.0"]["prices"]["720p"], Value::Number(26.into()) ); + + // admin 定价接口是 concrete provider key 的明确例外,读回的是保存时的原始 key。 + let admin_response = app + .oneshot( + Request::builder() + .uri("/admin/api/editor-generation-pricing") + .header("authorization", format!("Bearer {admin_token}")) + .body(Body::empty()) + .expect("admin pricing request should build"), + ) + .await + .expect("admin pricing request should succeed"); + assert_eq!(admin_response.status(), StatusCode::OK); + let admin_body = admin_response + .into_body() + .collect() + .await + .expect("admin pricing body should collect") + .to_bytes(); + let admin_payload: Value = + serde_json::from_slice(&admin_body).expect("admin pricing payload should be json"); + assert_eq!( + admin_payload["models"]["gpt-image-2.5-flare-c"]["prices"]["2K"], + Value::Number(62.into()) + ); + assert_eq!( + admin_payload["models"]["gpt-image-2.5-sunburst-c"]["prices"]["2K"], + Value::Number(82.into()) + ); } /// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。 -- 2.52.0 From 6318baf7c69d5805cbdd8fa97a3afbd4bf6d9871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 15:17:45 +0800 Subject: [PATCH 37/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=BC=BA=E7=9C=81?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=A8=A1=E5=9E=8B=E6=A0=A1=E9=AA=8C=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E7=9A=84=20fixture=20=E6=A8=A1=E5=9E=8B=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 editor_generation_pricing_rejects_missing_required_image_size 的 fixture 从已退役的 gpt-image-2 换成 gpt-image-2.5-flare-c / gpt-image-2.5-sunburst-c - 同步断言文案为 models.gpt-image-2.5-flare-c 缺少 2K 的泥点配置,使缺档校验仍覆盖真实必填模型 --- .../api-server/src/editor_generation_config.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index df2850cc5..7754fa746 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -1108,10 +1108,14 @@ mod tests { "unit": "perGeneration", "prices": { "0.5K": 8, "1K": 12, "2K": 24 } }, - "gpt-image-2": { + "gpt-image-2.5-flare-c": { "unit": "perGeneration", "prices": { "1K": 20 } }, + "gpt-image-2.5-sunburst-c": { + "unit": "perGeneration", + "prices": { "1K": 3, "2K": 5 } + }, "seedance2.0-fast": { "unit": "perSecond", "prices": { "480p": 10, "720p": 20, "1080p": 40 } @@ -1145,6 +1149,10 @@ mod tests { ) .expect_err("missing 2K price should fail"); - assert!(error.to_string().contains("gpt-image-2 缺少 2K")); + assert!( + error + .to_string() + .contains("models.gpt-image-2.5-flare-c 缺少 2K 的泥点配置") + ); } } -- 2.52.0 From 04591098978f352e6715ba79a6442724c2d4e762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 15:18:40 +0800 Subject: [PATCH 38/80] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20SpacetimeDB=20?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7=20record=20=E7=BC=BA=E5=B0=91=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=A8=A1=E5=9E=8B=E5=9B=9E=E5=A1=AB=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 backfill_legacy_gpt_image_2_5_pricing 提升为 pub(crate) 并补充文档说明其覆盖磁盘 override 与 SpacetimeDB record 两条加载路径 - editor_generation_pricing_from_record 在 validate 前先对旧 record 做受控 backfill,避免升级前只写单个 gpt-image-2 key 时被必填校验拒绝 - 新增用例覆盖旧 record 自动补齐两个 GPT Image 2.5 key,以及显式配置不被回填覆盖 - 更新兼容回填的 TODO 注释,说明移除条件还需包含 SpacetimeDB record --- .../src/editor_generation_config.rs | 9 +- server-rs/crates/api-server/src/state.rs | 141 +++++++++++++++++- 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 7754fa746..e3383d23b 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -393,7 +393,11 @@ fn load_editor_generation_pricing_from_candidates( Ok(config) } -fn backfill_legacy_gpt_image_2_5_pricing( +/// 兼容升级前只含单个历史 `gpt-image-2` key 的持久化定价配置。 +/// +/// 磁盘 override 与 SpacetimeDB record 两条加载路径都要调用,补齐缺失的 +/// `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 后才允许 `validate()`。 +pub(crate) fn backfill_legacy_gpt_image_2_5_pricing( config: &mut EditorGenerationPricingConfig, fallback: &EditorGenerationPricingConfig, source: &str, @@ -408,7 +412,8 @@ fn backfill_legacy_gpt_image_2_5_pricing( return Ok(()); } // TODO: compatibility backfill for legacy single-key pricing; remove once - // all persisted overrides contain the two explicit GPT Image 2.5 keys. + // every persisted override and SpacetimeDB record contains the two explicit + // GPT Image 2.5 keys. let pricing = config .models .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 49d721a48..82eebe5e1 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -495,7 +495,14 @@ fn editor_generation_pricing_from_record( })?; models.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing); } - let config = EditorGenerationPricingConfig { models }; + let mut config = EditorGenerationPricingConfig { models }; + // 中文注释:升级前写入的 record 可能只有单个历史 gpt-image-2 key, + // 与磁盘 override 一样先做受控 backfill,再进入必填校验。 + crate::editor_generation_config::backfill_legacy_gpt_image_2_5_pricing( + &mut config, + legacy_fallback, + "SpacetimeDB 模型定价配置", + )?; config.validate()?; Ok(config) } @@ -2875,6 +2882,138 @@ mod tests { ); } + #[test] + fn editor_generation_pricing_typed_record_backfills_legacy_gpt_image_keys() { + use crate::editor_generation_config::{ + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + }; + + let fallback = crate::editor_generation_config::parse_editor_generation_pricing_json( + crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON, + "test default pricing", + ) + .expect("default pricing should parse"); + let mut models = + editor_generation_pricing_to_records(&fallback).expect("pricing should map to records"); + // 中文注释:模拟升级前持久化的旧 record:只有单个历史 gpt-image-2 key。 + models.retain(|pricing| { + pricing.model != EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION + && pricing.model != EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT + }); + let legacy_pricing = models + .iter() + .find(|pricing| pricing.model == "gpt-image-2") + .cloned() + .expect("legacy record kept the single gpt-image-2 key"); + let record = EditorGenerationPricingConfigRecord { + config_id: "global".to_string(), + models, + updated_by_admin_user_id: Some("admin:test".to_string()), + updated_at: "2026-09-18T00:00:00Z".to_string(), + updated_at_micros: 2, + }; + + let actual = editor_generation_pricing_from_record(record, &fallback) + .expect("legacy single-key record should be backfilled instead of rejected"); + + assert_eq!(legacy_pricing.unit.as_str(), "perGeneration"); + let expected = EditorGenerationModelPricing { + unit: EditorGenerationPricingUnit::PerGeneration, + price: legacy_pricing.price, + prices: legacy_pricing + .prices + .iter() + .map(|tier| (tier.key.clone(), tier.price)) + .collect(), + }; + assert_eq!( + actual + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION), + Some(&expected) + ); + assert_eq!( + actual.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT), + Some(&expected) + ); + } + + #[test] + fn editor_generation_pricing_typed_record_keeps_explicit_gpt_image_keys() { + use crate::editor_generation_config::{ + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + }; + + let fallback = crate::editor_generation_config::parse_editor_generation_pricing_json( + crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON, + "test default pricing", + ) + .expect("default pricing should parse"); + let mut models = + editor_generation_pricing_to_records(&fallback).expect("pricing should map to records"); + for pricing in models.iter_mut() { + if pricing.model == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION { + pricing.prices = vec![ + EditorGenerationPricingTierRecord { + key: "1K".to_string(), + price: 71, + }, + EditorGenerationPricingTierRecord { + key: "2K".to_string(), + price: 72, + }, + ]; + } + if pricing.model == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT { + pricing.prices = vec![ + EditorGenerationPricingTierRecord { + key: "1K".to_string(), + price: 81, + }, + EditorGenerationPricingTierRecord { + key: "2K".to_string(), + price: 82, + }, + ]; + } + if pricing.model == "gpt-image-2" { + pricing.prices = vec![ + EditorGenerationPricingTierRecord { + key: "1K".to_string(), + price: 1, + }, + EditorGenerationPricingTierRecord { + key: "2K".to_string(), + price: 2, + }, + ]; + } + } + let record = EditorGenerationPricingConfigRecord { + config_id: "global".to_string(), + models, + updated_by_admin_user_id: Some("admin:test".to_string()), + updated_at: "2026-09-18T00:00:00Z".to_string(), + updated_at_micros: 3, + }; + + let actual = editor_generation_pricing_from_record(record, &fallback) + .expect("explicit GPT Image 2.5 keys should be preserved"); + + let generation = actual + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + .expect("generation key should exist"); + assert_eq!(generation.prices.get("1K"), Some(&71)); + assert_eq!(generation.prices.get("2K"), Some(&72)); + let edit = actual + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) + .expect("edit key should exist"); + assert_eq!(edit.prices.get("1K"), Some(&81)); + assert_eq!(edit.prices.get("2K"), Some(&82)); + } + #[test] fn editor_generation_pricing_upsert_input_uses_runtime_service_bootstrap_secret() { let mut config = AppConfig::default(); -- 2.52.0 From 1c1deff7322e9eb587db4f22f6150c26a41c4d1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 15:33:14 +0800 Subject: [PATCH 39/80] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=20LLM=20mock=20=E7=94=A8=E4=BE=8B=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=9E=84=E9=80=A0=20Tiantoken=20client=20=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppConfig 新增 tiantoken_base_url / tiantoken_api_key 字段,from_env 只读 TIANTOKEN_* 环境变量,不回退 VectorEngine 凭据 - AppState 改用配置字段构造编辑器 LLM 与 Tiantoken 图片 client,删除现读环境变量的 config::tiantoken_* 访问器 - editor_background_music_prompt_assist / editor_sound_effect_prompt_assist / sound_effect_translation 三处 mock LLM 夹具改用 tiantoken_* 字段,恢复 28 条失败用例 - config 用例改为断言 from_env 的 Tiantoken 凭据来源,state 用例改名为从 Tiantoken 设置构造编辑器 LLM client - 同步更新后端架构文档与 decision-log 记录 --- .../shared-memory/decision-log.md | 8 +++ ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- server-rs/crates/api-server/src/config.rs | 65 +++++++++++++------ .../editor_background_music_prompt_assist.rs | 4 +- .../src/editor_sound_effect_prompt_assist.rs | 4 +- server-rs/crates/api-server/src/state.rs | 12 ++-- .../sound_effect_translation.rs | 4 +- 7 files changed, 65 insertions(+), 34 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0655b4a35..8e080d060 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8857,3 +8857,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - **启动**:api-server 启动时同时构造 VectorEngine 与 Tiantoken 两个 required client;各自只读取自己的环境变量,任一配置缺失即启动失败,不延迟到首次请求。 - **重试**:只在同一个 concrete model 内 retry,禁止跨 model、跨 provider fallback。 - **关联文档**:[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`](../plans/【里程碑】GPT%20Image%202.5%20provider边界重构-2026-09-18.md)。 + +## 2026-09-19 Tiantoken 凭据落到 AppConfig 字段并修复编辑器 LLM 测试夹具 + +- **决策**:Tiantoken 凭据仍然只从 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 读取,不回退 VectorEngine;读取点从 `AppState::new` 前移到 `AppConfig::from_env()`,落到 `AppConfig.tiantoken_base_url` / `AppConfig.tiantoken_api_key` 两个字段,与其它 provider 的配置形态一致,`AppState` 构造与测试构造复用同一条路径;删除 `config::tiantoken_base_url()` / `config::tiantoken_api_key()` 两个现读环境变量的访问器。 +- **原因**:2026-09-18 的「双 provider 启动边界」改成现读环境变量后,`editor_background_music_prompt_assist`、`editor_sound_effect_prompt_assist`、`vector_engine_audio_generation/sound_effect_translation` 三处 mock LLM 夹具仍用 `AppConfig.vector_engine_*` 构造状态,28 条用例拿到 503 `editor_llm_unavailable`。改用进程环境变量做夹具会让并行用例互相踩 `TIANTOKEN_*`,因此把凭据落到 config 字段而不是在测试里 set_var。 +- **边界**:ADR 的「不得回退 VectorEngine 或其 API key」仍然成立,`from_env` 只读 `TIANTOKEN_*`,并有用例固定该行为。 +- **验证**:`cargo test -p api-server` 1060 passed / 0 failed / 6 ignored;`cargo fmt --all --check`、`npm run check:encoding`、`git diff --check` 通过。 +- **关联文档**:[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index e30cea3ff..754790caa 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -258,7 +258,7 @@ npm run check:server-rs-ddd - 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 - 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,并由客户端 GET-only 对账;本次请求不得附加 `resultPersistenceStarted`。`AlreadyApplied` 仅在 early guard 与最终 procedure 并发相遇时作为底层幂等兜底,复用既有 commit 且不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 - 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 -- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent 文本链路使用 Tiantoken,使用 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 Tiantoken base URL 规范化到 `/v1`。VectorEngine 只保留给 Suno 音乐任务;后续排障时优先确认 Tiantoken `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 +- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent 文本链路使用 Tiantoken,启动时由 `AppConfig::from_env` 读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 落到 `AppConfig.tiantoken_base_url` / `AppConfig.tiantoken_api_key`(只读自己的环境变量,不回退 VectorEngine 凭据),再由 `AppState` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 Tiantoken base URL 规范化到 `/v1`。VectorEngine 只保留给 Suno 音乐任务;后续排障时优先确认 Tiantoken `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`,通用 `/api/llm/chat/completions` 代理可使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时由 Tiantoken 凭据承接。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent 客户端来源。 - 当前 provider 路由:`TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 承载原 VectorEngine 的文本和图片能力;旧版 Vidu 音效代码已移除;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐及 Suno 音效任务;新编辑器 SFX V2 继续独立使用 ElevenLabs。 diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 04135970f..704a27304 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -204,6 +204,12 @@ pub struct AppConfig { pub vector_engine_api_key: Option, pub vector_engine_image_request_timeout_ms: u64, pub vector_engine_audio_request_timeout_ms: u64, + /// Tiantoken 是 GPT Image 2.5 图片链路和创意 Agent 文本链路的 provider。 + /// + /// 只从 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 读取,刻意不回退 VectorEngine 凭据; + /// 启动时落到 `AppConfig` 字段,使 `AppState` 构造与测试构造复用同一条路径。 + pub tiantoken_base_url: String, + pub tiantoken_api_key: Option, pub elevenlabs_base_url: String, pub elevenlabs_api_key: Option, pub elevenlabs_request_timeout_ms: u64, @@ -510,6 +516,8 @@ impl Default for AppConfig { vector_engine_api_key: None, vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, vector_engine_audio_request_timeout_ms: 180_000, + tiantoken_base_url: String::new(), + tiantoken_api_key: None, elevenlabs_base_url: String::new(), elevenlabs_api_key: None, elevenlabs_request_timeout_ms: DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS, @@ -1277,6 +1285,12 @@ impl AppConfig { config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]); + // Tiantoken 只读取自己的环境变量,不从 VectorEngine 配置回退;启动时冻结进 AppConfig, + // 供 AppState 构造编辑器 LLM / 图片 client 时复用同一条配置路径。 + config.tiantoken_base_url = + read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]).unwrap_or_default(); + config.tiantoken_api_key = read_first_non_empty_env(&["TIANTOKEN_API_KEY"]); + if let Some(tiantoken_image_request_timeout_ms) = read_first_positive_u64_env(&[ "TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS", "VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", @@ -1428,17 +1442,6 @@ impl AppConfig { } } -/// Tiantoken 是 GPT Image 2.5 图片任务的独立 provider;凭证不得回退到 VectorEngine。 -pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String { - let _ = config; - read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]).unwrap_or_default() -} - -pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option { - let _ = config; - read_first_non_empty_env(&["TIANTOKEN_API_KEY"]) -} - fn read_first_non_empty_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key).ok().and_then(|value| { @@ -1744,7 +1747,6 @@ mod tests { DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS, DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode, LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role, - tiantoken_api_key, tiantoken_base_url, }; use std::{ fs, @@ -1838,31 +1840,52 @@ mod tests { } #[test] - fn tiantoken_provider_prefers_new_env_names_over_legacy_vector_engine_config() { + fn tiantoken_credentials_never_fall_back_to_vector_engine_env() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); - let mut config = AppConfig::default(); - config.vector_engine_base_url = "https://vector.example.invalid".to_string(); - config.vector_engine_api_key = Some("legacy-vector-key".to_string()); unsafe { std::env::set_var("TIANTOKEN_BASE_URL", " https://api.tiantoken.example/ "); std::env::set_var("TIANTOKEN_API_KEY", " tiantoken-key "); + std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.example.invalid"); + std::env::set_var("VECTOR_ENGINE_API_KEY", "legacy-vector-key"); } + let config = AppConfig::from_env(); + + assert_eq!(config.tiantoken_base_url, "https://api.tiantoken.example/"); + assert_eq!(config.tiantoken_api_key.as_deref(), Some("tiantoken-key")); assert_eq!( - tiantoken_base_url(&config), - "https://api.tiantoken.example/" + config.vector_engine_base_url, + "https://vector.example.invalid" + ); + assert_eq!( + config.vector_engine_api_key.as_deref(), + Some("legacy-vector-key") ); - assert_eq!(tiantoken_api_key(&config).as_deref(), Some("tiantoken-key")); unsafe { std::env::remove_var("TIANTOKEN_BASE_URL"); std::env::remove_var("TIANTOKEN_API_KEY"); } - assert_eq!(tiantoken_base_url(&config), ""); - assert_eq!(tiantoken_api_key(&config), None); + let config = AppConfig::from_env(); + + assert_eq!(config.tiantoken_base_url, ""); + assert_eq!(config.tiantoken_api_key, None); + assert_eq!( + config.vector_engine_base_url, + "https://vector.example.invalid" + ); + assert_eq!( + config.vector_engine_api_key.as_deref(), + Some("legacy-vector-key") + ); + + unsafe { + std::env::remove_var("VECTOR_ENGINE_BASE_URL"); + std::env::remove_var("VECTOR_ENGINE_API_KEY"); + } } #[test] diff --git a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs index 1af1817e0..5c5609d3d 100644 --- a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs +++ b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs @@ -1480,8 +1480,8 @@ mod tests { fn editor_llm_test_config(base_url: String) -> AppConfig { AppConfig { - vector_engine_base_url: base_url, - vector_engine_api_key: Some("test-vector-engine-key".to_string()), + tiantoken_base_url: base_url, + tiantoken_api_key: Some("test-tiantoken-key".to_string()), llm_max_retries: 0, ..AppConfig::default() } diff --git a/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs b/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs index 83b5d96c6..2fdf03b94 100644 --- a/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs +++ b/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs @@ -574,8 +574,8 @@ mod tests { fn editor_llm_test_config(base_url: String, max_retries: u32) -> AppConfig { AppConfig { - vector_engine_base_url: base_url, - vector_engine_api_key: Some("test-vector-engine-key".to_string()), + tiantoken_base_url: base_url, + tiantoken_api_key: Some("test-tiantoken-key".to_string()), llm_max_retries: max_retries, ..AppConfig::default() } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 82eebe5e1..26dec0941 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -615,8 +615,8 @@ impl AppState { config.editor_generation_pricing_override_path.clone(), ) .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; - let tiantoken_base_url = crate::config::tiantoken_base_url(&config); - let tiantoken_api_key = crate::config::tiantoken_api_key(&config); + let tiantoken_base_url = config.tiantoken_base_url.clone(); + let tiantoken_api_key = config.tiantoken_api_key.clone(); let vector_engine_image_client = build_required_image_provider_client( ImageProvider::VectorEngine, config.vector_engine_base_url.clone(), @@ -3176,13 +3176,13 @@ mod tests { } #[test] - fn app_state_builds_editor_agent_llm_client_from_vector_engine_settings() { + fn app_state_builds_editor_agent_llm_client_from_tiantoken_settings() { let mut config = AppConfig::default(); config.llm_api_key = None; config.llm_max_retries = 2; config.llm_retry_backoff_ms = 120_000; - config.vector_engine_base_url = "https://api.vectorengine.test".to_string(); - config.vector_engine_api_key = Some("ve-key".to_string()); + config.tiantoken_base_url = "https://api.tiantoken.test".to_string(); + config.tiantoken_api_key = Some("tiantoken-key".to_string()); let state = AppState::new(config).expect("state should build"); let client = state @@ -3195,7 +3195,7 @@ mod tests { ); assert_eq!( client.config().chat_completions_url(), - "https://api.vectorengine.test/v1/chat/completions" + "https://api.tiantoken.test/v1/chat/completions" ); assert!(!client.config().official_fallback()); assert_eq!( diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs index bbec7d800..c4b37ce83 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs @@ -578,8 +578,8 @@ mod tests { fn editor_llm_test_state(base_url: String, max_retries: u32) -> AppState { AppState::new(AppConfig { - vector_engine_base_url: base_url, - vector_engine_api_key: Some("test-vector-engine-key".to_string()), + tiantoken_base_url: base_url, + tiantoken_api_key: Some("test-tiantoken-key".to_string()), llm_max_retries: max_retries, ..AppConfig::default() }) -- 2.52.0 From 6e9f991a769e3a1f650bd452e2b8252b1165742b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 15:16:56 +0800 Subject: [PATCH 40/80] =?UTF-8?q?=E6=B8=85=E7=90=86=E7=8C=AB=E6=8F=92?= =?UTF-8?q?=E7=94=BB=E8=84=9A=E6=9C=AC=E7=9A=84=E5=8D=95=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除单元素模型循环和无操作异常捕获 保留原有请求与错误语义 --- .../generate-anthro-cat-illustrations.mjs | 62 ++++++++----------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs index 97bbae547..712366cf8 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs @@ -256,42 +256,34 @@ async function fetchJson(url, options, timeoutMs) { } async function requestImagePayload(env, entry) { - for (const model of [preferredImageModel]) { - const requestBody = { - model, - prompt: buildPrompt(entry), - n: 1, - size: '1024x1024', - }; - try { - const payload = await fetchJson( - buildVectorEngineImagesGenerationUrl(env.baseUrl), - { - method: 'POST', - headers: { - Authorization: `Bearer ${env.apiKey}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }, - env.timeoutMs, - ); - const base64Image = decodeStrictBase64Image( - extractBase64Images(payload)[0], - ); - if (extractImageUrls(payload)[0] || base64Image) { - return payload; - } - const error = new Error(`VectorEngine returned no image for ${entry.id}`); - error.vectorEngineResponseParse = true; - error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); - throw error; - } catch (error) { - throw error; - } + const model = preferredImageModel; + const requestBody = { + model, + prompt: buildPrompt(entry), + n: 1, + size: '1024x1024', + }; + const payload = await fetchJson( + buildVectorEngineImagesGenerationUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + env.timeoutMs, + ); + const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]); + if (extractImageUrls(payload)[0] || base64Image) { + return payload; } - throw new Error(`VectorEngine returned no image for ${entry.id}`); + const error = new Error(`VectorEngine returned no image for ${entry.id}`); + error.vectorEngineResponseParse = true; + error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); + throw error; } async function downloadUrl(url, timeoutMs) { -- 2.52.0 From 6f19e155ca991f775b33c7e8bb9cfe6ac0267e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 15:23:55 +0800 Subject: [PATCH 41/80] =?UTF-8?q?=E6=B8=85=E7=90=86=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=A0=B7=E4=BE=8B=E8=84=9A=E6=9C=AC=E7=9A=84=E5=8D=95=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=AF=B7=E6=B1=82=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除单元素模型循环和无操作异常捕获 保留原有请求与错误语义 --- .../scripts/generate-template-samples.mjs | 64 ++++++++----------- 1 file changed, 27 insertions(+), 37 deletions(-) diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs index aa39d5bb7..07799aea4 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs @@ -226,44 +226,34 @@ async function fetchJson(url, options, timeoutMs) { } async function requestImagePayload(env, template) { - for (const model of [preferredImageModel]) { - const requestBody = { - model, - prompt: buildPrompt(template), - n: 1, - size: '1024x1024', - }; - try { - const payload = await fetchJson( - buildVectorEngineImagesGenerationUrl(env.baseUrl), - { - method: 'POST', - headers: { - Authorization: `Bearer ${env.apiKey}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }, - env.timeoutMs, - ); - const base64Image = decodeStrictBase64Image( - extractBase64Images(payload)[0], - ); - if (extractImageUrls(payload)[0] || base64Image) { - return payload; - } - const error = new Error( - `VectorEngine returned no image for ${template.id}`, - ); - error.vectorEngineResponseParse = true; - error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); - throw error; - } catch (error) { - throw error; - } + const model = preferredImageModel; + const requestBody = { + model, + prompt: buildPrompt(template), + n: 1, + size: '1024x1024', + }; + const payload = await fetchJson( + buildVectorEngineImagesGenerationUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + env.timeoutMs, + ); + const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]); + if (extractImageUrls(payload)[0] || base64Image) { + return payload; } - throw new Error(`VectorEngine returned no image for ${template.id}`); + const error = new Error(`VectorEngine returned no image for ${template.id}`); + error.vectorEngineResponseParse = true; + error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); + throw error; } async function downloadUrl(url, timeoutMs) { -- 2.52.0 From e880f58723e336e3182226214f32971c266dc989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 15:27:34 +0800 Subject: [PATCH 42/80] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E7=8C=AB=E6=8F=92?= =?UTF-8?q?=E7=94=BB=E8=AF=95=E8=BF=90=E8=A1=8C=E7=BB=93=E6=9E=9C=E7=9A=84?= =?UTF-8?q?=E7=A9=BA=E5=9B=9E=E9=80=80=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 干运行输出只保留实际请求信息 --- .../scripts/generate-anthro-cat-illustrations.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs index 712366cf8..0ab4af282 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs @@ -361,7 +361,6 @@ if (dryRun) { requests: selectedPrompts.map((entry) => ({ id: entry.id, title: entry.title, - fallbackModel: null, body: { model: preferredImageModel, prompt: buildPrompt(entry), -- 2.52.0 From 786898f7f8b76889fff989f559946d92f7641f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 15:34:29 +0800 Subject: [PATCH 43/80] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=A0=B7=E4=BE=8B=E8=AF=95=E8=BF=90=E8=A1=8C=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E7=9A=84=E7=A9=BA=E5=9B=9E=E9=80=80=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 干运行输出只保留实际请求信息 --- .../gpt-image-2-apimart/scripts/generate-template-samples.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs index 07799aea4..796745998 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs @@ -335,7 +335,6 @@ if (dryRun) { requests: selectedTemplates.map((template) => ({ id: template.id, title: template.title, - fallbackModel: null, body: { model: preferredImageModel, prompt: buildPrompt(template), -- 2.52.0 From ef34e0ed642a4dfa8fcde086d0b80a411b839d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 19:11:03 +0800 Subject: [PATCH 44/80] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20nanobanana=20?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=99=A8=E7=9A=84=20provider=20=E6=A0=87?= =?UTF-8?q?=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取消 create_nanobanana_generate_content 中硬编码的 VectorEngine 错误、重试与审计统一读取 settings.provider --- .../src/image_provider/runtime/executor.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index 3cafa3f57..ee0b24d05 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -305,7 +305,7 @@ pub async fn create_nanobanana_generate_content( effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) else { return Err(request_budget_exhausted_error( - ImageProvider::VectorEngine, + settings.provider, request_url.as_str(), failure_context, None, @@ -329,7 +329,7 @@ pub async fn create_nanobanana_generate_content( attempt, ) { if retry_image_upstream_status_after_delay( - ImageProvider::VectorEngine, + settings.provider, "nanobanana_generate_content", request_url.as_str(), attempt, @@ -352,7 +352,7 @@ pub async fn create_nanobanana_generate_content( Err(error) => { if should_retry_image_curl_send_error(&error, attempt) { if retry_image_send_after_delay( - ImageProvider::VectorEngine, + settings.provider, "nanobanana_generate_content", request_url.as_str(), "request_send", @@ -375,7 +375,7 @@ pub async fn create_nanobanana_generate_content( } } return Err(map_curl_error( - ImageProvider::VectorEngine, + settings.provider, format!("{failure_context}:创建 nanobanana2 图片生成任务失败").as_str(), request_url.as_str(), "request_send", @@ -410,7 +410,7 @@ pub async fn create_nanobanana_generate_content( handle_image_response( http_client, request_url.as_str(), - ImageProvider::VectorEngine, + settings.provider, response_status, response_text.as_str(), None, -- 2.52.0 From 4dc199cf3322512a468bcf85716f572c97a452fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 19:12:55 +0800 Subject: [PATCH 45/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20nanobanana=20?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E7=A9=BA=E6=A8=A1=E5=9E=8B=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E8=90=BD=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nanobanana_generate_content_url 空模型回落到 nanobanana 模型 新增空模型路径的单元测试 --- .../src/image_provider/protocol/request.rs | 12 +++++++----- .../platform-image/tests/image_provider.rs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs index 0210b149b..d1d07867a 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs @@ -286,11 +286,13 @@ pub fn nanobanana_generate_content_url(settings: &ImageProviderSettings, model: .base_url .trim_end_matches("/v1") .trim_end_matches('/'); - format!( - "{}/v1beta/models/{}:generateContent", - base_url, - normalize_image_model(model) - ) + // 中文注释:nanobanana endpoint 只服务 Gemini 模型,空模型字符串应回落到 + // nanobanana 模型,而不是 normalize_image_model 默认的 GPT 生成模型。 + let model = match model.trim() { + "" => NANOBANANA_2_MODEL, + value => value, + }; + format!("{}/v1beta/models/{}:generateContent", base_url, model) } pub(crate) fn build_image_edit_request_log_params( diff --git a/server-rs/crates/platform-image/tests/image_provider.rs b/server-rs/crates/platform-image/tests/image_provider.rs index a8239df71..893fab966 100644 --- a/server-rs/crates/platform-image/tests/image_provider.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -211,6 +211,22 @@ fn nanobanana_generate_content_url_uses_model_path() { ); } +#[test] +fn nanobanana_generate_content_url_blank_model_falls_back_to_nanobanana_model() { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, + base_url: "https://vector.example".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + request_deadline: None, + }; + + assert_eq!( + nanobanana_generate_content_url(&settings, " "), + "https://vector.example/v1beta/models/gemini-3.1-flash-image-preview:generateContent" + ); +} + #[tokio::test] async fn image_edit_retries_send_timeout_once_and_succeeds() { let listener = TcpListener::bind("127.0.0.1:0") -- 2.52.0 From 1a1f124acddacb44ba3e222896bc353d0af8d83d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 19:16:02 +0800 Subject: [PATCH 46/80] =?UTF-8?q?=E5=85=AC=E5=BC=80=E5=AE=9A=E4=BB=B7?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E9=9A=90=E8=97=8F=E6=97=A7=20GPT=20Image=202?= =?UTF-8?q?-C=20=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit public_projection 同步移除 gpt-image-2-c 键 --- server-rs/crates/api-server/src/editor_generation_config.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index e3383d23b..f5ecc820a 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -114,6 +114,7 @@ impl EditorGenerationPricingConfig { models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION); models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT); models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2); + models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_C); Self { models } } -- 2.52.0 From ab472b456204ac6fab26b0d318357aa88869feaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 19:16:28 +0800 Subject: [PATCH 47/80] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A0=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E7=9A=84=E6=97=A7=E5=9B=BE=E7=89=87=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 create_openai_image_generation 及其未再用到的导入 --- .../api-server/src/openai_image_generation.rs | 46 +------------------ 1 file changed, 2 insertions(+), 44 deletions(-) diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index e276d2cea..8e94ed52f 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -4,8 +4,8 @@ use platform_image::{ NANOBANANA_2_MODEL, PlatformImageError, PlatformImageStatusHint, ReferenceImage, VECTOR_ENGINE_PROVIDER, build_image_http_client, create_image_edit, create_image_edit_with_references, create_image_edit_with_references_and_model, - create_image_generation, create_image_generation_with_model, - create_nanobanana_generate_content, missing_reference_images_error, + create_image_generation_with_model, create_nanobanana_generate_content, + missing_reference_images_error, }; #[cfg(test)] use platform_image::{build_image_request_body, images_edit_url, images_generation_url}; @@ -129,48 +129,6 @@ pub(crate) fn build_openai_image_http_client( build_image_http_client(&settings.provider_settings()).map_err(map_platform_image_error) } -pub(crate) async fn create_openai_image_generation( - _http_client: &reqwest::Client, - settings: &OpenAiImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - candidate_count: u32, - reference_images: &[String], - failure_context: &str, -) -> Result { - let started_at_micros = current_utc_micros(); - let request_payload = json!({ - "size": size, - "candidateCount": candidate_count, - "promptChars": prompt.chars().count(), - "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), - "referenceImageCount": reference_images.len(), - }); - let provider_client = settings.client_for_model(GPT_IMAGE_2_5_GENERATION_MODEL); - let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_GENERATION_MODEL); - let result = create_image_generation( - provider_client.http_client(), - &provider_settings, - prompt, - negative_prompt, - size, - candidate_count, - reference_images, - failure_context, - ) - .await; - map_platform_image_result( - settings, - result, - "image_generation", - failure_context, - request_payload, - started_at_micros, - ) - .await -} - #[allow(clippy::too_many_arguments)] pub(crate) async fn create_openai_image_generation_with_model( _http_client: &reqwest::Client, -- 2.52.0 From 98bfa5efd094ef49cdf851df660f256a6840fcd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 19:17:08 +0800 Subject: [PATCH 48/80] =?UTF-8?q?=E6=8C=89=E6=A8=A1=E5=9E=8B=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=9B=BE=E7=89=87=E7=BC=96=E8=BE=91=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E5=8F=82=E8=80=83=E5=9B=BE=E7=9A=84=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_openai_image_edit_with_references_and_model 错误归属与路由一致 --- .../crates/api-server/src/openai_image_generation.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 8e94ed52f..35a4b25e5 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -5,7 +5,7 @@ use platform_image::{ VECTOR_ENGINE_PROVIDER, build_image_http_client, create_image_edit, create_image_edit_with_references, create_image_edit_with_references_and_model, create_image_generation_with_model, create_nanobanana_generate_content, - missing_reference_images_error, + missing_reference_images_error, resolve_image_provider, }; #[cfg(test)] use platform_image::{build_image_request_body, images_edit_url, images_generation_url}; @@ -338,13 +338,12 @@ pub(crate) async fn create_openai_image_edit_with_references_and_model( "referenceImageCount": reference_images.len(), }); // 中文注释:先做本地参考图校验,避免 provider 客户端缺失时在校验前 panic。 + // provider 归属与下方非空参考图路径保持一致,按模型解析而不是固定 Tiantoken。 if reference_images.is_empty() { + let provider = resolve_image_provider(model).unwrap_or(ImageProvider::Tiantoken); return map_platform_image_result( settings, - Err(missing_reference_images_error( - ImageProvider::Tiantoken, - failure_context, - )), + Err(missing_reference_images_error(provider, failure_context)), "image_edit_with_references", failure_context, request_payload, -- 2.52.0 From 4cbbcecaa25ac7413eb66e8374064f2d611a2e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 19:21:23 +0800 Subject: [PATCH 49/80] =?UTF-8?q?=E5=87=8F=E5=B0=91=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8F=82=E6=95=B0=E5=BD=92=E4=B8=80=E7=9A=84?= =?UTF-8?q?=E5=86=97=E4=BD=99=20JSON=20=E6=8B=B7=E8=B4=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalize_legacy_image_model_args 改为按需返回 Cow 未命中归一时复用原始参数视图,避免多一次深拷贝 --- .../crates/api-server/src/editor_agent/tool.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index b9d7b6d31..0548addbd 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::{Display, Formatter}; use platform_editor_agent::agent::asset::ImageId; @@ -230,12 +231,12 @@ fn parse_internal( /// 平台层图片工具只接受 `gemini-3.1-flash-image-preview` 与 `gpt-image-2.5`,但历史会话 /// 里仍可能保存着 `gpt-image-2` 的工具调用参数。这里在进入强类型校验前做一次别名归一, /// 既让旧调用继续可用,也把计价统一到当前业务模型档位。 -fn normalize_legacy_image_model_args(value: &Value) -> Value { +fn normalize_legacy_image_model_args(value: &Value) -> Cow<'_, Value> { let Some(model) = value.get("model").and_then(Value::as_str) else { - return value.clone(); + return Cow::Borrowed(value); }; if !matches!(model.trim(), GPT_IMAGE_2_MODEL | GPT_IMAGE_2_C_MODEL) { - return value.clone(); + return Cow::Borrowed(value); } let mut normalized = value.clone(); if let Some(fields) = normalized.as_object_mut() { @@ -244,7 +245,7 @@ fn normalize_legacy_image_model_args(value: &Value) -> Value { Value::String(GPT_IMAGE_2_5_BUSINESS_NAME.to_string()), ); } - normalized + Cow::Owned(normalized) } /// 图片类工具的 `validate_args` 入口:先归一历史模型别名,再走强类型反序列化。 @@ -252,7 +253,8 @@ fn parse_invalid_image_args( tool_name: &str, value: &Value, ) -> Result { - parse_invalid_args(tool_name, &normalize_legacy_image_model_args(value)) + let normalized = normalize_legacy_image_model_args(value); + parse_invalid_args(tool_name, &normalized) } /// 图片类工具的 `pricing` 入口:即使调用方直接传入原始 args,也要先归一历史模型别名。 @@ -260,7 +262,8 @@ fn parse_internal_image_args( label: &str, value: &Value, ) -> Result { - parse_internal(label, &normalize_legacy_image_model_args(value)) + let normalized = normalize_legacy_image_model_args(value); + parse_internal(label, &normalized) } /// SFX V2 的显式 `duration: null` 表示自动时长,必须原样保留,不能被通用的 -- 2.52.0 From 2d679122cf4fcc5bd47584a2937bde5d33251983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 19:24:00 +0800 Subject: [PATCH 50/80] =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=9B=BE=E7=89=87=E6=A8=A1=E5=9E=8B=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E4=B8=8E=E5=BD=92=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽取 LEGACY_EDITOR_IMAGE_MODELS 作为单一来源 normalizeEditorImageModel 与 isLegacyEditorImageModel 复用该集合 --- .../image-editor/ImageCanvasGenerationModel.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 8b6fa5273..599f3a3dc 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -53,6 +53,11 @@ const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([ 'nanobanana2', 'nano-banana', ]); +// 历史 GPT Image 2 别名统一映射到当前业务模型,判定与归一复用同一集合。 +const LEGACY_EDITOR_IMAGE_MODELS = new Set([ + IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_C, +]); export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2_5; export const SPEC_GENERATION_ASPECT_RATIO = '16:9'; export const SPEC_GENERATION_IMAGE_SIZE = '2K'; @@ -639,21 +644,14 @@ export function normalizeEditorImageModel(model: string | null | undefined) { if (IMAGE_MODEL_NANOBANANA_ALIASES.has(normalizedModel)) { return IMAGE_MODEL_NANOBANANA2; } - if ( - normalizedModel === IMAGE_MODEL_GPT_IMAGE_2 || - normalizedModel === IMAGE_MODEL_GPT_IMAGE_2_C - ) { + if (LEGACY_EDITOR_IMAGE_MODELS.has(normalizedModel)) { return IMAGE_MODEL_GPT_IMAGE_2_5; } return normalizedModel; } export function isLegacyEditorImageModel(model: string | null | undefined) { - const normalizedModel = model?.trim(); - return ( - normalizedModel === IMAGE_MODEL_GPT_IMAGE_2 || - normalizedModel === IMAGE_MODEL_GPT_IMAGE_2_C - ); + return LEGACY_EDITOR_IMAGE_MODELS.has(model?.trim() ?? ''); } export function getEditorImageModelDisplayName( -- 2.52.0 From 44798ba380f55673234a97ad5a25b35b3d3463bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 20:01:43 +0800 Subject: [PATCH 51/80] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=9B=BE=E7=89=87=20pr?= =?UTF-8?q?ovider=20=E8=B7=AF=E7=94=B1=E7=99=BD=E5=90=8D=E5=8D=95=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E4=B8=9A=E5=8A=A1=E6=A8=A1=E5=9E=8B=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_image_provider 不再接受 gpt-image-2.5 业务名 业务名必须先在任务边界解析成 flare-c / sunburst-c 具体 key 新增白名单回归测试 --- .../src/image_provider/protocol/request.rs | 13 +++++--- .../platform-image/tests/image_provider.rs | 33 +++++++++++++++---- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs index d1d07867a..e5132386d 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs @@ -99,14 +99,19 @@ pub fn normalize_image_model(model: &str) -> &str { } } -/// Resolves a persisted/concrete model to its owning provider without rewriting -/// the model string. Legacy GPT Image 2 values remain readable, but are routed -/// to the current Tiantoken task boundary instead of VectorEngine. +/// Resolves a concrete provider model to its owning provider without rewriting the +/// model string. +/// +/// 中文注释:白名单是 provider 边界,不放业务名和已退役模型—— +/// `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 走 Tiantoken, +/// nanobanana 走 VectorEngine。业务模型名 `gpt-image-2.5` +/// (`GPT_IMAGE_2_5_BUSINESS_NAME`) 必须在任务边界解析成具体 key;`gpt-image-2-c` +/// 只是历史 fallback / 审计字符串,永远不会作为 provider 或业务 model 传入, +/// 命中即拒绝。`gpt-image-2` 仍按历史可读值接受。 pub fn resolve_image_provider(model: &str) -> Result { match normalize_image_model(model) { GPT_IMAGE_2_MODEL | GPT_IMAGE_2_C_MODEL - | GPT_IMAGE_2_5_BUSINESS_NAME | GPT_IMAGE_2_5_GENERATION_MODEL | GPT_IMAGE_2_5_EDIT_MODEL => Ok(ImageProvider::Tiantoken), NANOBANANA_2_MODEL => Ok(ImageProvider::VectorEngine), diff --git a/server-rs/crates/platform-image/tests/image_provider.rs b/server-rs/crates/platform-image/tests/image_provider.rs index 893fab966..f4d84c3f4 100644 --- a/server-rs/crates/platform-image/tests/image_provider.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -1,10 +1,11 @@ use platform_image::image_provider::{ - GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, - GPT_IMAGE_2_MODEL, ImageProvider, ImageProviderSettings, PlatformImageError, ReferenceImage, - VECTOR_ENGINE_PROVIDER, build_image_http_client, build_image_request_body, - build_image_request_body_with_model, build_nanobanana_generate_content_request_body, - create_image_edit, create_image_generation, create_nanobanana_generate_content, - images_edit_url, images_generation_url, nanobanana_generate_content_url, + GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, + GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, ImageProvider, ImageProviderSettings, + PlatformImageError, ReferenceImage, VECTOR_ENGINE_PROVIDER, build_image_http_client, + build_image_request_body, build_image_request_body_with_model, + build_nanobanana_generate_content_request_body, create_image_edit, create_image_generation, + create_nanobanana_generate_content, images_edit_url, images_generation_url, + nanobanana_generate_content_url, resolve_image_provider, }; use std::{ sync::{ @@ -227,6 +228,26 @@ fn nanobanana_generate_content_url_blank_model_falls_back_to_nanobanana_model() ); } +#[test] +fn resolve_image_provider_rejects_business_model_and_accepts_concrete_models() { + // 中文注释:业务模型名不是 provider model,必须在 api-server 任务边界先解析成具体 model。 + assert!(resolve_image_provider(GPT_IMAGE_2_5_BUSINESS_NAME).is_err()); + + assert_eq!( + resolve_image_provider(GPT_IMAGE_2_5_GENERATION_MODEL), + Ok(ImageProvider::Tiantoken) + ); + assert_eq!( + resolve_image_provider(GPT_IMAGE_2_5_EDIT_MODEL), + Ok(ImageProvider::Tiantoken) + ); + assert_eq!( + resolve_image_provider("gemini-3.1-flash-image-preview"), + Ok(ImageProvider::VectorEngine) + ); + assert!(resolve_image_provider("unknown-model").is_err()); +} + #[tokio::test] async fn image_edit_retries_send_timeout_once_and_succeeds() { let listener = TcpListener::bind("127.0.0.1:0") -- 2.52.0 From 784f38220b622d66ab01e0bc28a44304e0ccf344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 20:02:14 +0800 Subject: [PATCH 52/80] =?UTF-8?q?=E5=9B=BE=E7=89=87=20provider=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E6=8B=92=E7=BB=9D=E5=B7=B2=E9=80=80=E5=BD=B9=E7=9A=84?= =?UTF-8?q?=20gpt-image-2-c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpt-image-2-c 仅保留历史 fallback 与审计字符串 resolve_image_provider 白名单不再接受该值 补充拒绝断言 --- .../platform-image/src/image_provider/protocol/request.rs | 7 +++---- server-rs/crates/platform-image/tests/image_provider.rs | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs index e5132386d..fb6f8878c 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs @@ -110,10 +110,9 @@ pub fn normalize_image_model(model: &str) -> &str { /// 命中即拒绝。`gpt-image-2` 仍按历史可读值接受。 pub fn resolve_image_provider(model: &str) -> Result { match normalize_image_model(model) { - GPT_IMAGE_2_MODEL - | GPT_IMAGE_2_C_MODEL - | GPT_IMAGE_2_5_GENERATION_MODEL - | GPT_IMAGE_2_5_EDIT_MODEL => Ok(ImageProvider::Tiantoken), + GPT_IMAGE_2_MODEL | GPT_IMAGE_2_5_GENERATION_MODEL | GPT_IMAGE_2_5_EDIT_MODEL => { + Ok(ImageProvider::Tiantoken) + } NANOBANANA_2_MODEL => Ok(ImageProvider::VectorEngine), _ => Err("不支持的图片模型"), } diff --git a/server-rs/crates/platform-image/tests/image_provider.rs b/server-rs/crates/platform-image/tests/image_provider.rs index f4d84c3f4..2111bfc13 100644 --- a/server-rs/crates/platform-image/tests/image_provider.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -232,6 +232,8 @@ fn nanobanana_generate_content_url_blank_model_falls_back_to_nanobanana_model() fn resolve_image_provider_rejects_business_model_and_accepts_concrete_models() { // 中文注释:业务模型名不是 provider model,必须在 api-server 任务边界先解析成具体 model。 assert!(resolve_image_provider(GPT_IMAGE_2_5_BUSINESS_NAME).is_err()); + // 中文注释:`gpt-image-2-c` 只是历史 fallback / 审计字符串,永不作为 provider model。 + assert!(resolve_image_provider(GPT_IMAGE_2_C_MODEL).is_err()); assert_eq!( resolve_image_provider(GPT_IMAGE_2_5_GENERATION_MODEL), -- 2.52.0 From 8988e91ee9822e0465bc498648a9a7d02989d04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 20:06:24 +0800 Subject: [PATCH 53/80] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E5=9B=BE=E7=89=87=20pr?= =?UTF-8?q?ovider=20=E5=90=AF=E5=8A=A8=E6=8A=A5=E9=94=99=E4=B8=8E=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺少 BASE_URL / API_KEY 时点名对应环境变量 启动成功时记录 provider、base_url 与超时配置 --- server-rs/crates/api-server/src/state.rs | 32 +++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 2fe39f32e..8496a5a94 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -2518,6 +2518,7 @@ fn build_required_image_provider_client( api_key: Option, request_timeout_ms: u64, ) -> Result { + let (base_url_env, api_key_env) = required_image_provider_env_vars(provider); #[cfg(test)] let (base_url, api_key) = ( if base_url.trim().is_empty() { @@ -2532,8 +2533,9 @@ fn build_required_image_provider_client( let base_url = base_url.trim().trim_end_matches('/'); if base_url.is_empty() { return Err(AppStateInitError::DependencyUnavailable(format!( - "{} 图片 provider 缺少 BASE_URL 配置", - provider.as_str() + "{} 图片 provider 缺少 BASE_URL 配置:请设置 {}。两个图片 provider 都在启动时构造,缺失即阻止 api-server 启动,不做 provider 回退。", + provider.as_str(), + base_url_env ))); } let api_key = api_key @@ -2542,18 +2544,36 @@ fn build_required_image_provider_client( .filter(|value| !value.is_empty()) .ok_or_else(|| { AppStateInitError::DependencyUnavailable(format!( - "{} 图片 provider 缺少 API_KEY 配置", - provider.as_str() + "{} 图片 provider 缺少 API_KEY 配置:请设置 {}。两个图片 provider 都在启动时构造,缺失即阻止 api-server 启动,不做 provider 回退。", + provider.as_str(), + api_key_env )) })?; - build_image_provider_client(ImageProviderSettings { + let client = build_image_provider_client(ImageProviderSettings { provider, base_url: base_url.to_string(), api_key: api_key.to_string(), request_timeout_ms: request_timeout_ms.max(1), request_deadline: None, }) - .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string())) + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; + info!( + provider = provider.as_str(), + base_url = %base_url, + request_timeout_ms = request_timeout_ms.max(1), + env = base_url_env, + api_key_env, + "图片 provider client 已启用" + ); + Ok(client) +} + +/// 图片 provider 在启动期必须齐备的环境变量名,用于给出可直接执行的报错与启动日志。 +fn required_image_provider_env_vars(provider: ImageProvider) -> (&'static str, &'static str) { + match provider { + ImageProvider::VectorEngine => ("VECTOR_ENGINE_BASE_URL", "VECTOR_ENGINE_API_KEY"), + ImageProvider::Tiantoken => ("TIANTOKEN_BASE_URL", "TIANTOKEN_API_KEY"), + } } fn build_oss_client(config: &AppConfig) -> Result, AppStateInitError> { -- 2.52.0 From fc53f362e40965e1c7816a00d0e8c6a75e34eb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 20:07:25 +0800 Subject: [PATCH 54/80] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=9B=BE=E7=89=87=20pr?= =?UTF-8?q?ovider=20=E8=B7=AF=E7=94=B1=E7=99=BD=E5=90=8D=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=97=A5=E5=BF=97=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 补充业务名与 gpt-image-2-c 的拒绝规则 决策记录补充启动报错点名环境变量与启动日志 --- ...】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md | 5 ++++- docs/project-memory/shared-memory/decision-log.md | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md index 3b061f49e..3512799e5 100644 --- a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md +++ b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md @@ -6,6 +6,8 @@ 图片协议执行逻辑保持 provider-neutral:请求 body、multipart、尺寸约束、重试、响应解码和审计由共享 image executor 承担;VectorEngine 与 Tiantoken client 只提供相同协议所需的 base URL、API key 和 provider identity。platform-image 根据 concrete model 做严格白名单路由:`gpt-image-2.5-flare-c` 与 `gpt-image-2.5-sunburst-c` 走 Tiantoken,`gemini-3.1-flash-image-preview`(nanobanana)走 VectorEngine;未知 model 直接拒绝。已持久化的 `gpt-image-2` / `gpt-image-2-c` 只在新任务提交边界按兼容规则解析为当前 GPT Image 2.5 任务,不改写历史资源,也不进入旧 VectorEngine 图片路由。 +provider 白名单同时拒绝两类非 provider model:业务模型名 `gpt-image-2.5` 必须先由 api-server 在任务边界解析成 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c`;`gpt-image-2-c` 自兜底移除后只是历史 fallback / 审计字符串,永不作为 provider 或业务 model 传入,命中即拒绝。`gpt-image-2` 继续作为历史可读值接受。历史值仍只出现在审计与提交边界兼容层,不进入 provider 路由。 + 普通主站前端只接触业务模型和新生成展示名 `GPT Image 2.5`,admin Web/API 可以查看和编辑两个具体定价 key;普通生成即使因参考图使用 edits multipart,仍按生成 concrete model。旧 `gpt-image-2-c` 审计记录原样保留,新代码不再跨模型或跨 provider fallback。 主站前端只把原本明确使用 GPT Image 2 的专用新任务改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为保持不变。画布参数或编辑布局读到 `gpt-image-2` / `gpt-image-2-c` 时,在使用端解析为 `gpt-image-2.5` 并触发参数迁移警告,原始资源、审计、metadata 和历史 fixture 不回写。AGC 现有资源生成界面与请求默认保持不变,不因本决策新增模型字段、选择器或尺寸行为。 @@ -15,5 +17,6 @@ - 定价配置的活动 key 是两个具体 provider model;旧单 key 配置只允许受控 backfill,并留下兼容 TODO。 - 新任务的同模型重试固定使用 api-server dispatch 的具体 model,不切换到另一个 model。 - 两套 provider client 在 api-server 启动阶段同时构造;任一 required provider 配置缺失,启动失败而不是延迟到首次图片请求。 -- provider routing 只依据 concrete model 的白名单;provider client 不复制共享协议执行逻辑。 +- 两套 provider client 的缺失配置报错点名对应环境变量(`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`、`TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`),构造成功后用启动日志记录已启用的 provider。 +- provider routing 只依据 concrete model 的白名单,业务名与已退役的 `gpt-image-2-c` 一律拒绝;provider client 不复制共享协议执行逻辑。 - 公开资源、`generationInputs` 和普通前端契约不包含具体 provider key;admin 定价管理是明确例外。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 843cd3283..d9980980e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,18 @@ # 决策记录 +## 2026-09-21 图片 provider 路由白名单拒绝业务名与已退役的 gpt-image-2-c + +- 背景:GPT Image 2.5 迁移后,`resolve_image_provider` 仍把业务模型名 `gpt-image-2.5` 与历史 `gpt-image-2-c` 一起判为 Tiantoken,等于把「非 provider model」留在 provider 边界的白名单里。`gpt-image-2-c` 自 2026-07-21 起只是首选模型失败时的兜底 provider model,兜底移除后仅剩历史审计字符串;api-server 的图片请求一律传具体 `provider_model`(`gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` / nanobanana)。 +- 决策:`resolve_image_provider` 白名单只接受现役具体 provider model;业务名 `gpt-image-2.5` 与 `gpt-image-2-c` 命中即拒绝(`不支持的图片模型`)。业务名仍由 api-server 在任务边界解析成具体 key;`gpt-image-2-c` 只作为历史 fallback / 审计字符串存在,永不参与路由;`gpt-image-2` 继续作为历史可读值接受。 +- 影响范围:`server-rs/crates/platform-image/src/image_provider/protocol/request.rs` 与其集成测试。提交边界(画布参数、Agent 工具参数、External v1 请求 DTO、前端历史布局恢复)对 `gpt-image-2` / `gpt-image-2-c` 的兼容解析保持不变。 +- 验证:`cargo test -p platform-image` 22 项通过,新增 `resolve_image_provider_rejects_business_model_and_accepts_concrete_models`;`cargo test -p api-server` 1115 项通过。 + +## 2026-09-21 图片 provider 启动期缺配置的报错点名环境变量 + +- 背景:两个图片 provider client 都在 `AppState::new` 构造,缺 base URL / API key 即阻止 api-server 启动;旧报错只写「tiantoken 图片 provider 缺少 BASE_URL 配置」,没有点名变量名,而 `TIANTOKEN_*` 已不再从 `VECTOR_ENGINE_*` 回退,运维迁移时不易定位。 +- 决策:保持「不回退、缺失即启动失败」,但报错文案补上具体变量名(`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 或 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`);client 构造成功后用 `info` 记录 provider、base_url、超时与对应 env 变量名,便于确认实际生效的 provider 配置。 +- 验证:`cargo check -p api-server` 通过;`cargo test -p api-server` 1115 项通过(0 失败)。 + ## 2026-09-21 渠道进安装身份:不同渠道的 AGC 包体在同一台设备并存 - 背景:渠道此前只决定更新端点(`plugins.updater.endpoints`)与渲染层平台 origin,`productName` / `identifier` 与渠道无关,于是所有渠道共用 `%LOCALAPPDATA%\陶泥儿` 安装目录、同一个卸载项(`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\陶泥儿`,另有 `HKCU\Software\genarrative\陶泥儿`)以及同一份 `%APPDATA%\world.genarrative.ai-game-creator` 数据目录。本机 0.1.48 安装实测:主程序二进制里只有 1 处 `agc/dev-win/latest.json`、0 处 release 端点,说明渠道在产物里只体现为端点。后果是后装的渠道静默顶掉先装的渠道,并接管更新端点、平台服务器与本地登录态/项目数据。 -- 2.52.0 From f3c362b1ec03e2ef25d43146be2366d9f8381ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 20:31:16 +0800 Subject: [PATCH 55/80] =?UTF-8?q?=E6=95=B4=E4=BD=93=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=B7=B2=E9=80=80=E5=BD=B9=E7=9A=84=20gpt-image-2-c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 platform-image 的 GPT_IMAGE_2_C_MODEL 常量、re-export、尺寸语义与审计标签 - 移除 api-server 三处任务边界对 gpt-image-2-c 的兼容解析分支 - 移除主站前端历史模型常量与恢复分支,并同步其测试 - 同步 External v1 OpenAPI 描述、ADR、CONTEXT、pitfalls 与后端/运维文档 - 数据库既有 gpt-image-2-c 审计字符串保持原样,不回写不改写 --- .../references/api-operations.md | 2 +- CONTEXT.md | 4 +-- ... Image 2.5模型路由与历史值兼容-2026-09-18.md | 12 +++++---- .../genarrative-external-v1.openapi.json | 6 ++--- .../shared-memory/decision-log.md | 10 +++---- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +-- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +-- .../api-server/src/editor_agent/tool.rs | 8 +++--- .../src/editor_generation_config.rs | 4 --- .../crates/api-server/src/editor_project.rs | 7 +++-- .../api-server/src/openai_image_generation.rs | 2 +- .../src/image_provider/constants.rs | 1 - .../platform-image/src/image_provider/mod.rs | 2 +- .../src/image_provider/protocol/request.rs | 10 +++---- .../src/image_provider/runtime/executor.rs | 4 +-- server-rs/crates/platform-image/src/lib.rs | 11 ++++---- .../platform-image/tests/image_provider.rs | 27 ++++++------------- .../ImageCanvasGenerationModel.test.ts | 27 ------------------- .../ImageCanvasGenerationModel.ts | 6 +---- 20 files changed, 51 insertions(+), 102 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index bdbab115f..b45dc02ce 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -102,7 +102,7 @@ Use OpenAPI as the final authority; these common values are a routing aid: - Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it. - External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. -- Image `model`: `gpt-image-2.5`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. Persisted `gpt-image-2` / `gpt-image-2-c` are legacy values resolved only when submitting a new task. +- Image `model`: `gpt-image-2.5`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. Persisted `gpt-image-2` is a legacy value resolved only when submitting a new task; the retired `gpt-image-2-c` is no longer accepted and is handled as an unsupported value. - Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`. - Image `imageSize`: `0.5K`, `1K`, `2K`. - Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`. diff --git a/CONTEXT.md b/CONTEXT.md index 6ac4610f9..b5eb0941a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -66,8 +66,8 @@ _Avoid_: 把具体模型写入普通前端 DTO、让未知字符串自动选择 _Avoid_: 在首次请求时才创建 client、在 provider client 中复制尺寸/重试/审计逻辑、跨 provider credential fallback **历史模型值**: -已持久化的 `gpt-image-2` 或 `gpt-image-2-c` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。 -_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端 +已持久化的 `gpt-image-2` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。已退役的 `gpt-image-2-c` 已从代码整体删除,只有数据库里的历史审计字符串原样保留,任何入口传入该值都按不支持的值处理。 +_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端、为已删除的 `gpt-image-2-c` 重新加回常量或解析分支 **GPT Image 2.5 新生成展示名**: `GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。 diff --git a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md index 3512799e5..7bacf73b5 100644 --- a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md +++ b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md @@ -4,13 +4,15 @@ 新任务使用业务模型值 `gpt-image-2.5`,api-server 按任务显式选择具体 model:生成使用 `gpt-image-2.5-flare-c`,编辑使用 `gpt-image-2.5-sunburst-c`。这两个 GPT Image 2.5 model 必须通过启动时构造的 Tiantoken client 发送;Tiantoken client 只读取显式配置的 `TIANTOKEN_BASE_URL`(部署值由环境设置为 `https://api.tiantoken.com`)和独立 `TIANTOKEN_API_KEY`,缺失即阻止 api-server 启动,不得回退到 VectorEngine 或其 API key。 -图片协议执行逻辑保持 provider-neutral:请求 body、multipart、尺寸约束、重试、响应解码和审计由共享 image executor 承担;VectorEngine 与 Tiantoken client 只提供相同协议所需的 base URL、API key 和 provider identity。platform-image 根据 concrete model 做严格白名单路由:`gpt-image-2.5-flare-c` 与 `gpt-image-2.5-sunburst-c` 走 Tiantoken,`gemini-3.1-flash-image-preview`(nanobanana)走 VectorEngine;未知 model 直接拒绝。已持久化的 `gpt-image-2` / `gpt-image-2-c` 只在新任务提交边界按兼容规则解析为当前 GPT Image 2.5 任务,不改写历史资源,也不进入旧 VectorEngine 图片路由。 +图片协议执行逻辑保持 provider-neutral:请求 body、multipart、尺寸约束、重试、响应解码和审计由共享 image executor 承担;VectorEngine 与 Tiantoken client 只提供相同协议所需的 base URL、API key 和 provider identity。platform-image 根据 concrete model 做严格白名单路由:`gpt-image-2.5-flare-c` 与 `gpt-image-2.5-sunburst-c` 走 Tiantoken,`gemini-3.1-flash-image-preview`(nanobanana)走 VectorEngine;未知 model 直接拒绝。已持久化的 `gpt-image-2` 只在新任务提交边界按兼容规则解析为当前 GPT Image 2.5 任务,不改写历史资源,也不进入旧 VectorEngine 图片路由。 -provider 白名单同时拒绝两类非 provider model:业务模型名 `gpt-image-2.5` 必须先由 api-server 在任务边界解析成 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c`;`gpt-image-2-c` 自兜底移除后只是历史 fallback / 审计字符串,永不作为 provider 或业务 model 传入,命中即拒绝。`gpt-image-2` 继续作为历史可读值接受。历史值仍只出现在审计与提交边界兼容层,不进入 provider 路由。 +provider 白名单同时拒绝非 provider model:业务模型名 `gpt-image-2.5` 必须先由 api-server 在任务边界解析成 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c`;`gpt-image-2` 继续作为历史可读值接受。两者都不进入 provider 路由。 -普通主站前端只接触业务模型和新生成展示名 `GPT Image 2.5`,admin Web/API 可以查看和编辑两个具体定价 key;普通生成即使因参考图使用 edits multipart,仍按生成 concrete model。旧 `gpt-image-2-c` 审计记录原样保留,新代码不再跨模型或跨 provider fallback。 +`gpt-image-2-c` 已整体删除:它自兜底移除后只剩历史审计字符串,从未进入业务模型、持久化模型字段或前端契约,因此不再保留任何常量、解析分支、尺寸语义或 provider 路由。数据库里既有的审计字符串保持原样,不参与新任务。 -主站前端只把原本明确使用 GPT Image 2 的专用新任务改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为保持不变。画布参数或编辑布局读到 `gpt-image-2` / `gpt-image-2-c` 时,在使用端解析为 `gpt-image-2.5` 并触发参数迁移警告,原始资源、审计、metadata 和历史 fixture 不回写。AGC 现有资源生成界面与请求默认保持不变,不因本决策新增模型字段、选择器或尺寸行为。 +普通主站前端只接触业务模型和新生成展示名 `GPT Image 2.5`,admin Web/API 可以查看和编辑两个具体定价 key;普通生成即使因参考图使用 edits multipart,仍按生成 concrete model。新代码不再跨模型或跨 provider fallback,已退役的 `gpt-image-2-c` 审计记录原样保留在数据库,代码不再引用该值。 + +主站前端只把原本明确使用 GPT Image 2 的专用新任务改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为保持不变。画布参数或编辑布局读到 `gpt-image-2` 时,在使用端解析为 `gpt-image-2.5` 并触发参数迁移警告,原始资源、审计、metadata 和历史 fixture 不回写。AGC 现有资源生成界面与请求默认保持不变,不因本决策新增模型字段、选择器或尺寸行为。 ## Consequences @@ -18,5 +20,5 @@ provider 白名单同时拒绝两类非 provider model:业务模型名 `gpt-im - 新任务的同模型重试固定使用 api-server dispatch 的具体 model,不切换到另一个 model。 - 两套 provider client 在 api-server 启动阶段同时构造;任一 required provider 配置缺失,启动失败而不是延迟到首次图片请求。 - 两套 provider client 的缺失配置报错点名对应环境变量(`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`、`TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`),构造成功后用启动日志记录已启用的 provider。 -- provider routing 只依据 concrete model 的白名单,业务名与已退役的 `gpt-image-2-c` 一律拒绝;provider client 不复制共享协议执行逻辑。 +- provider routing 只依据 concrete model 的白名单,业务名与已退役的 `gpt-image-2-c` 一律拒绝(`gpt-image-2-c` 已从代码中整体删除,传入即按未知 model 处理);provider client 不复制共享协议执行逻辑。 - 公开资源、`generationInputs` 和普通前端契约不包含具体 provider key;admin 定价管理是明确例外。 diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 225972bb9..dece62ebb 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3017,7 +3017,7 @@ }, "model": { "type": "string", - "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 / gpt-image-2-c 仅作为历史值在新任务提交边界兼容解析。未传时沿用编辑器默认。" + "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 仅作为历史值在新任务提交边界兼容解析;已退役的 gpt-image-2-c 不再接受,命中按不支持的值处理。未传时沿用编辑器默认。" }, "aspectRatio": { "type": "string", @@ -3170,7 +3170,7 @@ }, "model": { "type": "string", - "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 / gpt-image-2-c 仅作为历史值兼容解析。" + "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 仅作为历史值兼容解析;已退役的 gpt-image-2-c 不再接受,命中按不支持的值处理。" }, "aspectRatio": { "type": "string", @@ -3488,7 +3488,7 @@ "model": { "type": "string", "default": "gemini-3.1-flash-image-preview", - "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 / gpt-image-2-c 仅作为历史值兼容解析。未传时默认使用 nanobanana。" + "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 仅作为历史值兼容解析;已退役的 gpt-image-2-c 不再接受,命中按不支持的值处理。未传时默认使用 nanobanana。" }, "referenceImageSrcs": { "type": "array", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d9980980e..a5f3c1561 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,11 +1,11 @@ # 决策记录 -## 2026-09-21 图片 provider 路由白名单拒绝业务名与已退役的 gpt-image-2-c +## 2026-09-21 图片 provider 白名单收紧,并整体删除 gpt-image-2-c -- 背景:GPT Image 2.5 迁移后,`resolve_image_provider` 仍把业务模型名 `gpt-image-2.5` 与历史 `gpt-image-2-c` 一起判为 Tiantoken,等于把「非 provider model」留在 provider 边界的白名单里。`gpt-image-2-c` 自 2026-07-21 起只是首选模型失败时的兜底 provider model,兜底移除后仅剩历史审计字符串;api-server 的图片请求一律传具体 `provider_model`(`gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` / nanobanana)。 -- 决策:`resolve_image_provider` 白名单只接受现役具体 provider model;业务名 `gpt-image-2.5` 与 `gpt-image-2-c` 命中即拒绝(`不支持的图片模型`)。业务名仍由 api-server 在任务边界解析成具体 key;`gpt-image-2-c` 只作为历史 fallback / 审计字符串存在,永不参与路由;`gpt-image-2` 继续作为历史可读值接受。 -- 影响范围:`server-rs/crates/platform-image/src/image_provider/protocol/request.rs` 与其集成测试。提交边界(画布参数、Agent 工具参数、External v1 请求 DTO、前端历史布局恢复)对 `gpt-image-2` / `gpt-image-2-c` 的兼容解析保持不变。 -- 验证:`cargo test -p platform-image` 22 项通过,新增 `resolve_image_provider_rejects_business_model_and_accepts_concrete_models`;`cargo test -p api-server` 1115 项通过。 +- 背景:GPT Image 2.5 迁移后,`resolve_image_provider` 仍把业务模型名 `gpt-image-2.5` 与 `gpt-image-2-c` 一起判为 Tiantoken,等于把「非 provider model」留在 provider 边界白名单里。`gpt-image-2-c` 自 2026-07-21 起只是首选模型失败时的兜底 provider model(兜底移除后仅剩历史审计字符串),从未进入业务模型、持久化 `model` 字段或前端契约——api-server 持久化的模型一律取自 `generation_options.model`(业务值),前端在 2.5 迁移前只写 `gpt-image-2`。 +- 决策:`resolve_image_provider` 白名单只接受现役具体 provider model,业务名 `gpt-image-2.5` 命中即拒绝,必须先在任务边界解析成具体 key;`gpt-image-2` 继续作为历史可读值接受。`gpt-image-2-c` 不再只做「拒绝」,而是整体删除:常量、re-export、`is_gpt_image_2_family_model` 尺寸语义、`auditable_image_model` 审计标签、api-server 的三处提交边界解析、前端常量与历史恢复分支全部移除;数据库既有审计字符串原样保留,代码不再引用该值。 +- 影响范围:`platform-image`(constants / lib / image_provider mod / protocol request / runtime executor / 集成测试)、`api-server`(`openai_image_generation`、`editor_project`、`editor_generation_config`、`editor_agent/tool`)、主站前端 `ImageCanvasGenerationModel` 与其测试、External v1 OpenAPI 描述、ADR 与本文档。前端与 External v1 传入 `gpt-image-2-c` 现在按「不支持的值」处理(走既有未知值分支),不再解析为 GPT Image 2.5。 +- 验证:`cargo test -p platform-image` 全绿(含新增 `resolve_image_provider_rejects_business_model_and_accepts_concrete_models`);`cargo test -p api-server` 1115 项;`npx vitest run src/components/image-editor/ImageCanvasGenerationModel.test.ts` 34 项通过;`check:encoding`、`check:doc-index` 通过。 ## 2026-09-21 图片 provider 启动期缺配置的报错点名环境变量 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index db6ea4beb..45b343a80 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2503,7 +2503,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。 - 原因:2026-05-21 后 GPT-image-2 图片生成按 VectorEngine 创建/编辑接口分流;2026-07-05 后创意 Agent 文本链路也改为 VectorEngine Chat Completions `gpt-5.4-mini`,APIMart 不再作为当前创意 Agent 来源。 -- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,业务 / 计费与 provider 首发模型均为 `gpt-image-2`,仅在符合条件的 provider 失败后切到兜底模型 `gpt-image-2-c`。 +- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,新任务业务模型为 `gpt-image-2.5`,provider 具体模型为 `gpt-image-2.5-flare-c`(生成)/ `gpt-image-2.5-sunburst-c`(编辑),只在同一具体模型内重试。历史兜底模型 `gpt-image-2-c` 已从代码整体删除,不再存在任何 fallback 路径,请求体里出现该值即视为异常。 - 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。 - 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 1f4945baf..317869fa3 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -184,7 +184,7 @@ npm run check:server-rs-ddd 2. Adapter 输入应显式包含 provider、prompt、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source job、metadata 和可选透明背景后处理。 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 -5. 图片 provider 协议不再放在玩法模块里实现。产品、计费、DTO、持久化和 GPT Image 2.5 创建 / 编辑请求统一使用业务模型 `gpt-image-2.5`,api-server 在提交边界分派 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c`;已持久化的 `gpt-image-2` / `gpt-image-2-c` 只按兼容规则读取,不改写历史值。URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/image_provider/`;其中 `runtime/executor.rs` 负责共享 provider-neutral 执行编排,`transport/` 负责 HTTP client 与 curl 传输,`protocol/` 负责请求体、路径和响应 JSON 字段。raw image edit 的 multipart 与严格尺寸校验位于独立的 `server-rs/crates/platform-image/src/raw_image_edit/`。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 +5. 图片 provider 协议不再放在玩法模块里实现。产品、计费、DTO、持久化和 GPT Image 2.5 创建 / 编辑请求统一使用业务模型 `gpt-image-2.5`,api-server 在提交边界分派 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c`;已持久化的 `gpt-image-2` 只按兼容规则读取,不改写历史值;已退役的 `gpt-image-2-c` 已从代码整体删除,传入即按不支持的值处理。URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/image_provider/`;其中 `runtime/executor.rs` 负责共享 provider-neutral 执行编排,`transport/` 负责 HTTP client 与 curl 传输,`protocol/` 负责请求体、路径和响应 JSON 字段。raw image edit 的 multipart 与严格尺寸校验位于独立的 `server-rs/crates/platform-image/src/raw_image_edit/`。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。generated 私有对象上传时必须由 OSS 对象头承载浏览器 / CDN 缓存策略,默认写入 `Cache-Control: public, max-age=31536000, immutable`,不得改成 api-server 本地磁盘静态资源兜底。 7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 @@ -318,7 +318,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 错误边界固定如下:`StreamUnavailable` 只表示流式响应已给出 `tool_use` / `tool_calls` 完成原因但没有聚合出任何工具 slot,供调用方回退非流式,它不承担截断语义;`EmptyResponse` 表示最终文本和工具调用都为空,纯工具响应合法;`Deserialize` 覆盖 JSON / SSE / UTF-8 解析失败、缺少 `choices[0]`、流式工具身份缺失、流式工具槽位身份冲突、流式参数不完整,以及上述工具流未收尾截断。Anthropic 仍不支持 `web_search`、图片内容和纯 system 消息,必须至少有一条非 system 文本消息。 -- 图片生成:VectorEngine 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;逻辑 SKU 与 provider 首选模型均固定为 `gpt-image-2`,只在明确模型不可用、408 / 非拒绝类 429 / 5xx、响应解析失败或非拒绝类缺图时切换兜底模型 `gpt-image-2-c`。401 / 403、普通参数或安全拒绝、本地配置 / 参考图错误、无法确认上游是否已受理的发送错误、request budget 耗尽和生成成功后的图片下载失败不得切模型。一次业务请求总发送上限仍为 5 次;切换兜底模型会消耗后续 attempt,不允许两个模型各重试 5 次。`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id、结果摘要和 recovered failure 数量;首选模型失败但兜底模型成功时,首选失败仍落 `external_api_call_failure`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`fallback_from_model`、`fallback_to_model`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 +- 图片生成:VectorEngine 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;逻辑 SKU 固定为业务模型 `gpt-image-2.5`,provider 具体模型固定为 `gpt-image-2.5-flare-c`(生成)/ `gpt-image-2.5-sunburst-c`(编辑);历史兜底模型 `gpt-image-2-c` 已从代码整体删除,只在同一具体模型内重试,不做跨模型 fallback。401 / 403、普通参数或安全拒绝、本地配置 / 参考图错误、无法确认上游是否已受理的发送错误、request budget 耗尽和生成成功后的图片下载失败不得切模型。一次业务请求总发送上限仍为 5 次;切换兜底模型会消耗后续 attempt,不允许两个模型各重试 5 次。`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id、结果摘要和 recovered failure 数量;失败 attempt 仍落 `external_api_call_failure`,后续 attempt 成功时在运行摘要里计入 recovered failure。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 - 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ffa6bbb70..daa996a20 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -238,13 +238,13 @@ spacetime sql "SELECT * FROM runtime_setting LIMIT 1" --server http:/ 本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.8.3`,CLI / standalone commit 固定核对为 `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。若版本或 commit 错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;其它版本可执行 `spacetime version install && spacetime version use `,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.8.3 + 8e410d28...` 同时匹配;旧版本或旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。 -本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git;同时确认 VectorEngine 的 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 已配置,因为两个图片 client 都在 api-server 启动时构造,任一缺失都会阻止启动。VectorEngine 配置仍保留给 nanobanana 与 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。新生成任务使用业务模型 `gpt-image-2.5`,api-server 显式分派 `gpt-image-2.5-flare-c`(generate)或 `gpt-image-2.5-sunburst-c`(edit);已持久化的 `gpt-image-2` / `gpt-image-2-c` 只在提交边界按兼容规则解析,不改写历史值,也不回退到旧 GPT Image 2 路由。图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 +本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git;同时确认 VectorEngine 的 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 已配置,因为两个图片 client 都在 api-server 启动时构造,任一缺失都会阻止启动。VectorEngine 配置仍保留给 nanobanana 与 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。新生成任务使用业务模型 `gpt-image-2.5`,api-server 显式分派 `gpt-image-2.5-flare-c`(generate)或 `gpt-image-2.5-sunburst-c`(edit);已持久化的 `gpt-image-2` 只在提交边界按兼容规则解析,不改写历史值,也不回退到旧 GPT Image 2 路由;已退役的 `gpt-image-2-c` 已从代码整体删除,传入即按不支持的值处理。图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 编辑器 ElevenLabs 音效生成只从服务端读取 `ELEVENLABS_BASE_URL`、`ELEVENLABS_API_KEY` 和 `ELEVENLABS_REQUEST_TIMEOUT_MS`,timeout 默认 `180000ms`;base URL 或 Key 缺失时失败关闭,不回退 Vidu。生产 API 与 external-generation worker 通过共享 API env 取得同一配置,模板见 `deploy/env/api-server.env.example`;Key 不得进入 Web/Vite 环境、命令参数、日志、fixture 或仓库。普通测试只使用 loopback mock,禁止把真实付费请求作为 T3 自动验收。 SFX V2 发布必须使用维护窗:先关闭 SFX 入队,再对显式目标执行只读 `spacetime sql --server --format json "SELECT job_id, status, request_payload_json FROM external_generation_job WHERE job_kind = 'editor_sound_effect_generation' AND (status = 'pending' OR status = 'running')"`;结果非零时保持旧 Worker drain,不得删除任务或让新 Worker 解析旧 Vidu payload。禁止依赖默认 server,禁止使用 `--root-dir`。清零后先部署共享 env 已对齐的 api-server / external-generation worker,检查 `/healthz` 和 Worker 启动,再部署 Web 并小流量开放 SFX。灰度对账 job 完成数、退款数、ElevenLabs POST 数、完成资源数和孤儿资源;翻译失败仍调用 provider、单 job provider POST 大于一次、成功退款或失败未退款均应立即停止放量。回滚先停止入队并收口 V2 pending / running job,不自动切回 Vidu,不执行 SpacetimeDB schema 或数据回滚。完整清单见 `docs/【实施记录】SFX生成优化V2.0T6测试与发布门禁-2026-08-07.md`。 -VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会在一次业务请求总上限 5 次内处理;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。首个 provider attempt 使用 `gpt-image-2`;明确模型不可用、408 / 非拒绝类 429 / 5xx、响应解析失败或非拒绝类缺图时,下一 attempt 直接切兜底模型 `gpt-image-2-c`,之后只在剩余次数内重试兜底模型。发送 / 连接错误无法确认上游是否已受理,只重试同一首选模型,不切模型;认证、普通参数、安全拒绝、图片下载和 budget 错误同样不切。worker 从 job 开始的同一时钟起点计算绝对 deadline,常规保留最后 `60` 秒给审计、OSS 和终态写回;job 预算小于 `120` 秒时保留一半。VectorEngine 单次 attempt timeout 取配置值和剩余 provider 预算的较小值;退避或模型切换后已没有下一次 attempt 的预算时立即停止。该 deadline 覆盖参考图、provider 请求 / 响应和响应图片下载的整次 provider future,但只在 worker 进程内通过 `RequestContext` 传递;普通 HTTP / `inline` 没有该 deadline,继续保持原有 timeout 和重试行为。日志中 `VectorEngine 首选图片模型失败,切换兼容模型` 会携带 `fallback_from_model` / `fallback_to_model`;即使回退成功,首选模型错误仍写入 `external_api_call_failure`,成功运行摘要的 `recoveredFailureCount` 同时递增。排查生产失败时应同时统计 fallback / retry 日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。这项收口不修改 lease 续租 / fencing、迟到写回仲裁、attempt 耗尽与原子退款语义。 +VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会在一次业务请求总上限 5 次内处理;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。每次 provider attempt 都按任务确定的具体模型发送(`gpt-image-2.5-flare-c` 生成 / `gpt-image-2.5-sunburst-c` 编辑 / `gemini-3.1-flash-image-preview` nanobanana);明确模型不可用、408 / 非拒绝类 429 / 5xx、响应解析失败或非拒绝类缺图时只在同一具体模型内重试,不切模型,历史兜底模型 `gpt-image-2-c` 已从代码整体删除。发送 / 连接错误无法确认上游是否已受理,同样只重试同一模型;认证、普通参数、安全拒绝、图片下载和 budget 错误不重试。worker 从 job 开始的同一时钟起点计算绝对 deadline,常规保留最后 `60` 秒给审计、OSS 和终态写回;job 预算小于 `120` 秒时保留一半。VectorEngine 单次 attempt timeout 取配置值和剩余 provider 预算的较小值;退避后已没有下一次 attempt 的预算时立即停止。该 deadline 覆盖参考图、provider 请求 / 响应和响应图片下载的整次 provider future,但只在 worker 进程内通过 `RequestContext` 传递;普通 HTTP / `inline` 没有该 deadline,继续保持原有 timeout 和重试行为。重试过程中的失败 attempt 仍写入 `external_api_call_failure`;即使后续 attempt 成功,成功运行摘要的 `recoveredFailureCount` 同时递增。排查生产失败时应同时统计 retry 日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。这项收口不修改 lease 续租 / fencing、迟到写回仲裁、attempt 耗尽与原子退款语义。 图片编辑器生成属于持久队列长任务:提交接口返回 job 后,前端通过 `/api/runtime/external-generation/jobs/{jobId}` 与编辑器项目资源状态收敛。生产排查小程序或 WebView `Failed to fetch` 时,若 Nginx access log 为 `499`、`upstream_status=-`,先按提交请求的 `request_id`、job id、worker 日志和 `external_api_call_failure` 对齐真实任务,不把客户端断开直接判定为 provider 失败。 diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 0548addbd..e5e3b69f2 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -29,7 +29,7 @@ use platform_editor_agent::agent::tools::generate_video::{ }; use platform_editor_agent::framework::error::PromptError; use platform_editor_agent::framework::tool::{Tool, ToolDyn, null_tool_args_as_missing}; -use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL}; +use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_MODEL}; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Value, json}; @@ -225,7 +225,7 @@ fn parse_internal( }) } -/// 把持久化的历史 GPT Image 2 模型别名(`gpt-image-2` / `gpt-image-2-c`)改写为当前 +/// 把持久化的历史 GPT Image 2 模型别名(`gpt-image-2`)改写为当前 /// 业务模型 `gpt-image-2.5`。 /// /// 平台层图片工具只接受 `gemini-3.1-flash-image-preview` 与 `gpt-image-2.5`,但历史会话 @@ -235,7 +235,7 @@ fn normalize_legacy_image_model_args(value: &Value) -> Cow<'_, Value> { let Some(model) = value.get("model").and_then(Value::as_str) else { return Cow::Borrowed(value); }; - if !matches!(model.trim(), GPT_IMAGE_2_MODEL | GPT_IMAGE_2_C_MODEL) { + if model.trim() != GPT_IMAGE_2_MODEL { return Cow::Borrowed(value); } let mut normalized = value.clone(); @@ -1562,7 +1562,7 @@ mod tests { let normalized = image .validate_args(&json!({ "prompt": "生成图片", - "model": GPT_IMAGE_2_C_MODEL, + "model": GPT_IMAGE_2_MODEL, "image_size": "2K" })) .expect("legacy generation alias should validate"); diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index f5ecc820a..90bc1259f 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -17,7 +17,6 @@ pub(crate) const EDITOR_GENERATION_PRICING_DEFAULT_JSON: &str = include_str!("../config/editor-generation-pricing.default.json"); const EDITOR_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; -const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_C: &str = "gpt-image-2-c"; pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS: &str = "gpt-image-2.5"; pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION: &str = "gpt-image-2.5-flare-c"; pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT: &str = "gpt-image-2.5-sunburst-c"; @@ -114,7 +113,6 @@ impl EditorGenerationPricingConfig { models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION); models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT); models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2); - models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_C); Self { models } } @@ -537,7 +535,6 @@ fn normalize_editor_image_model(model: Option<&str>) -> &'static str { match model.map(str::trim).filter(|value| !value.is_empty()) { Some( EDITOR_IMAGE_MODEL_GPT_IMAGE_2 - | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_C | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, ) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, @@ -554,7 +551,6 @@ fn normalize_editor_image_edit_model(model: Option<&str>) -> &'static str { Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, Some( EDITOR_IMAGE_MODEL_GPT_IMAGE_2 - | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_C | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, ) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 95188801b..6749dd47f 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -114,9 +114,9 @@ use crate::{ http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, - GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, - OpenAiGeneratedImages, OpenAiImageSettings, OpenAiReferenceImage, - build_openai_image_http_client, create_openai_image_edit_with_references_and_model, + GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, + OpenAiImageSettings, OpenAiReferenceImage, build_openai_image_http_client, + create_openai_image_edit_with_references_and_model, create_openai_image_generation_with_model, create_openai_nanobanana_generate_content, require_openai_image_settings, }, @@ -4097,7 +4097,6 @@ pub(crate) fn normalize_editor_generation_options( // new task is submitted. Some( GPT_IMAGE_2_MODEL - | GPT_IMAGE_2_C_MODEL | GPT_IMAGE_2_5_BUSINESS_NAME | "gpt-image-2.5-flare-c" | "gpt-image-2.5-sunburst-c", diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 35a4b25e5..a297eb560 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -26,7 +26,7 @@ use crate::{ pub(crate) use platform_image::{ GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, + GPT_IMAGE_2_MODEL, }; pub(crate) type OpenAiGeneratedImages = GeneratedImages; diff --git a/server-rs/crates/platform-image/src/image_provider/constants.rs b/server-rs/crates/platform-image/src/image_provider/constants.rs index 8c101ae71..ea933b8bd 100644 --- a/server-rs/crates/platform-image/src/image_provider/constants.rs +++ b/server-rs/crates/platform-image/src/image_provider/constants.rs @@ -1,5 +1,4 @@ pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; -pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c"; /// Current business model exposed to callers for new image tasks. pub const GPT_IMAGE_2_5_BUSINESS_NAME: &str = "gpt-image-2.5"; /// Provider/pricing key for new generation tasks. diff --git a/server-rs/crates/platform-image/src/image_provider/mod.rs b/server-rs/crates/platform-image/src/image_provider/mod.rs index a0695bfcb..0e826b196 100644 --- a/server-rs/crates/platform-image/src/image_provider/mod.rs +++ b/server-rs/crates/platform-image/src/image_provider/mod.rs @@ -13,7 +13,7 @@ pub(crate) use transport::curl as curl_transport; pub use constants::TIANTOKEN_PROVIDER; pub use constants::{ GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_PROVIDER, + GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_PROVIDER, }; pub use protocol::{ build_image_request_body, build_image_request_body_with_model, diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs index fb6f8878c..12c5af908 100644 --- a/server-rs/crates/platform-image/src/image_provider/protocol/request.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs @@ -3,8 +3,8 @@ use serde_json::{Map, Value, json}; use crate::image_provider::{ constants::{ GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE, - GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, + GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE, GPT_IMAGE_2_MAX_PIXELS, + GPT_IMAGE_2_MIN_PIXELS, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, }, types::{ImageProvider, ImageProviderSettings, ReferenceImage}, }; @@ -105,9 +105,8 @@ pub fn normalize_image_model(model: &str) -> &str { /// 中文注释:白名单是 provider 边界,不放业务名和已退役模型—— /// `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 走 Tiantoken, /// nanobanana 走 VectorEngine。业务模型名 `gpt-image-2.5` -/// (`GPT_IMAGE_2_5_BUSINESS_NAME`) 必须在任务边界解析成具体 key;`gpt-image-2-c` -/// 只是历史 fallback / 审计字符串,永远不会作为 provider 或业务 model 传入, -/// 命中即拒绝。`gpt-image-2` 仍按历史可读值接受。 +/// (`GPT_IMAGE_2_5_BUSINESS_NAME`) 必须在任务边界解析成具体 key;已删除的 +/// `gpt-image-2-c` 命中即拒绝。`gpt-image-2` 仍按历史可读值接受。 pub fn resolve_image_provider(model: &str) -> Result { match normalize_image_model(model) { GPT_IMAGE_2_MODEL | GPT_IMAGE_2_5_GENERATION_MODEL | GPT_IMAGE_2_5_EDIT_MODEL => { @@ -122,7 +121,6 @@ pub(crate) fn is_gpt_image_2_family_model(model: &str) -> bool { matches!( normalize_image_model(model), GPT_IMAGE_2_MODEL - | GPT_IMAGE_2_C_MODEL | GPT_IMAGE_2_5_BUSINESS_NAME | GPT_IMAGE_2_5_GENERATION_MODEL | GPT_IMAGE_2_5_EDIT_MODEL diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index ee0b24d05..fa86a2753 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -11,8 +11,7 @@ use crate::image_provider::{ }, constants::{ GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, - VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, + GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, }, curl_transport::{ @@ -735,7 +734,6 @@ fn finish_image_model_fallback_error( fn auditable_image_model(model: &str) -> Option<&'static str> { match model { - GPT_IMAGE_2_C_MODEL => Some(GPT_IMAGE_2_C_MODEL), GPT_IMAGE_2_MODEL => Some(GPT_IMAGE_2_MODEL), GPT_IMAGE_2_5_BUSINESS_NAME => Some(GPT_IMAGE_2_5_BUSINESS_NAME), GPT_IMAGE_2_5_GENERATION_MODEL => Some(GPT_IMAGE_2_5_GENERATION_MODEL), diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index 7f7a69d5b..4a38f68ea 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -6,12 +6,11 @@ pub mod raw_image_edit; pub use image_provider::{ DownloadedImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, - GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, - ImageProvider, ImageProviderClient, ImageProviderSettings, NANOBANANA_2_MODEL, - PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage, - TIANTOKEN_PROVIDER, VECTOR_ENGINE_PROVIDER, build_image_http_client, - build_image_provider_client, build_image_request_body, - build_nanobanana_generate_content_request_body, create_image_edit, + GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, ImageProvider, + ImageProviderClient, ImageProviderSettings, NANOBANANA_2_MODEL, PlatformImageError, + PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage, TIANTOKEN_PROVIDER, + VECTOR_ENGINE_PROVIDER, build_image_http_client, build_image_provider_client, + build_image_request_body, build_nanobanana_generate_content_request_body, create_image_edit, create_image_edit_with_references, create_image_edit_with_references_and_model, create_image_generation, create_image_generation_with_model, create_nanobanana_generate_content, download_remote_image, images_edit_url, diff --git a/server-rs/crates/platform-image/tests/image_provider.rs b/server-rs/crates/platform-image/tests/image_provider.rs index 2111bfc13..9d7cb256c 100644 --- a/server-rs/crates/platform-image/tests/image_provider.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -1,11 +1,11 @@ use platform_image::image_provider::{ GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, ImageProvider, ImageProviderSettings, - PlatformImageError, ReferenceImage, VECTOR_ENGINE_PROVIDER, build_image_http_client, - build_image_request_body, build_image_request_body_with_model, - build_nanobanana_generate_content_request_body, create_image_edit, create_image_generation, - create_nanobanana_generate_content, images_edit_url, images_generation_url, - nanobanana_generate_content_url, resolve_image_provider, + GPT_IMAGE_2_MODEL, ImageProvider, ImageProviderSettings, PlatformImageError, ReferenceImage, + VECTOR_ENGINE_PROVIDER, build_image_http_client, build_image_request_body, + build_image_request_body_with_model, build_nanobanana_generate_content_request_body, + create_image_edit, create_image_generation, create_nanobanana_generate_content, + images_edit_url, images_generation_url, nanobanana_generate_content_url, + resolve_image_provider, }; use std::{ sync::{ @@ -33,7 +33,6 @@ fn image_provider_module_exposes_provider_protocol_helpers() { let body = build_image_request_body("雾海神殿", Some("文字,水印"), "16:9", 9, &[]); assert_eq!(GPT_IMAGE_2_MODEL, "gpt-image-2"); - assert_eq!(GPT_IMAGE_2_C_MODEL, "gpt-image-2-c"); assert_eq!(VECTOR_ENGINE_PROVIDER, "vector-engine"); assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); assert_eq!(body["size"], "1536x1024"); @@ -114,17 +113,7 @@ fn image_provider_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { 1, &[], ); - let fallback_gpt_body = build_image_request_body_with_model( - GPT_IMAGE_2_C_MODEL, - "小尺寸图", - None, - "640x640", - 1, - &[], - ); - assert_eq!(gpt_body["size"], "816x816"); - assert_eq!(fallback_gpt_body["size"], "816x816"); assert_eq!(nanobanana_body["size"], "640x640"); assert_eq!(oversized_gpt_body["size"], "2880x2880"); } @@ -232,8 +221,8 @@ fn nanobanana_generate_content_url_blank_model_falls_back_to_nanobanana_model() fn resolve_image_provider_rejects_business_model_and_accepts_concrete_models() { // 中文注释:业务模型名不是 provider model,必须在 api-server 任务边界先解析成具体 model。 assert!(resolve_image_provider(GPT_IMAGE_2_5_BUSINESS_NAME).is_err()); - // 中文注释:`gpt-image-2-c` 只是历史 fallback / 审计字符串,永不作为 provider model。 - assert!(resolve_image_provider(GPT_IMAGE_2_C_MODEL).is_err()); + // 中文注释:已删除的 gpt-image-2-c 不再被任何层接受。 + assert!(resolve_image_provider("gpt-image-2-c").is_err()); assert_eq!( resolve_image_provider(GPT_IMAGE_2_5_GENERATION_MODEL), diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index fdfb344ca..a8c4632ae 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -49,7 +49,6 @@ import { getGenerationFrameLabel, IMAGE_MODEL_GPT_IMAGE_2, IMAGE_MODEL_GPT_IMAGE_2_5, - IMAGE_MODEL_GPT_IMAGE_2_C, IMAGE_MODEL_NANOBANANA2, isNormalizedCanvasGenerationInputs, isQuickEditSupportedLayer, @@ -726,32 +725,6 @@ describe('ImageCanvasGenerationModel', () => { ).toBe(IMAGE_MODEL_NANOBANANA2); }); - it('restores the concrete legacy GPT image value to GPT Image 2.5 with a warning', () => { - const decoded = decodeCanvasGenerationInputs({ - version: 2, - action: 'image.generate', - fields: [ - { id: 'prompt', title: '生成提示词', value: '旧模型素材' }, - { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2_C }, - { id: 'aspectRatio', title: '比例', value: '1:1' }, - { id: 'imageSize', title: '尺寸', value: '1K' }, - ], - references: [], - }); - - expect(decoded).toMatchObject({ - ok: true, - inputs: { - fields: expect.arrayContaining([ - { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2_5 }, - ]), - }, - warnings: expect.arrayContaining([ - expect.objectContaining({ fieldIds: ['model'] }), - ]), - }); - }); - it('falls invalid image and workflow parameters back within V2', () => { const image = decodeCanvasGenerationInputs({ version: 2, diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 599f3a3dc..2051dd23c 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -44,7 +44,6 @@ import { // 中文注释:与 api-server/src/editor_generation_config.rs 保持同名模型定价语义,前端仅作为展示兜底。 export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; -export const IMAGE_MODEL_GPT_IMAGE_2_C = 'gpt-image-2-c'; export const IMAGE_MODEL_GPT_IMAGE_2_5 = 'gpt-image-2.5'; export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2; @@ -54,10 +53,7 @@ const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([ 'nano-banana', ]); // 历史 GPT Image 2 别名统一映射到当前业务模型,判定与归一复用同一集合。 -const LEGACY_EDITOR_IMAGE_MODELS = new Set([ - IMAGE_MODEL_GPT_IMAGE_2, - IMAGE_MODEL_GPT_IMAGE_2_C, -]); +const LEGACY_EDITOR_IMAGE_MODELS = new Set([IMAGE_MODEL_GPT_IMAGE_2]); export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2_5; export const SPEC_GENERATION_ASPECT_RATIO = '16:9'; export const SPEC_GENERATION_IMAGE_SIZE = '2K'; -- 2.52.0 From c38e4b41752a6d2116a2277aa37b7a790344afdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:00:08 +0800 Subject: [PATCH 56/80] =?UTF-8?q?=E7=94=9F=E6=88=90=E5=B8=A6=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=9B=BE=E7=BB=9F=E4=B8=80=E6=8C=89=E7=BC=96=E8=BE=91?= =?UTF-8?q?=20concrete=20model=20=E6=89=A7=E8=A1=8C=E4=B8=8E=E8=AE=A1?= =?UTF-8?q?=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 editor_image_generation_concrete_model 作为生成类任务选择 concrete model 的唯一入口,dispatch 与计价共用同一判据 - image_generation_mud_points 增加 has_reference_images,命中编辑 concrete model 时改走 image_edit_model_mud_points - editor_project/editor_project_icon 的 provider_model 改用共享解析函数,替换三处重复的带参考图 if 表达式 - character_visual_assets 的角色主形象生成改用共享解析函数 - 图标素材图集、UI 素材提取、图标规范带 referenceId、Agent 生成类工具带 referenceImageIds 的计价同步切到编辑档 - EditImageTool 计价从生成档改为编辑档,与直连快速编辑、raw edit 对齐 - normalize_editor_generation_options 的具体 provider key 改用常量替换字符串字面量 - 补充参考图档位路由与 Agent 工具档位路由单测 --- .../api-server/src/character_visual_assets.rs | 18 +- .../api-server/src/editor_agent/tool.rs | 76 ++++++++- .../src/editor_generation_config.rs | 154 +++++++++++++++++- .../crates/api-server/src/editor_project.rs | 31 ++-- .../api-server/src/editor_project_icon.rs | 25 +-- 5 files changed, 255 insertions(+), 49 deletions(-) diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index 2eaa89e74..b266e3326 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -34,11 +34,12 @@ use crate::{ build_character_visual_negative_prompt, build_character_visual_prompt, build_fallback_moderation_safe_character_visual_prompt, }, + editor_generation_config::editor_image_generation_concrete_model, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, - GPT_IMAGE_2_5_GENERATION_MODEL, OpenAiImageSettings, build_openai_image_http_client, - create_openai_image_generation_with_model, require_openai_image_settings, + DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, OpenAiImageSettings, + build_openai_image_http_client, create_openai_image_generation_with_model, + require_openai_image_settings, }, platform_errors::map_oss_error, request_context::RequestContext, @@ -940,18 +941,15 @@ async fn create_character_visual_generation( async fn create_character_visual_generation_once( http_client: &reqwest::Client, settings: &OpenAiImageSettings, - _model: &str, + model: &str, prompt: &str, size: &str, candidate_count: u32, reference_images: &[String], ) -> Result { - // 中文注释:参考图存在时后端会走编辑端点,因此必须提交编辑模型而不是生成模型。 - let provider_model = if reference_images.is_empty() { - GPT_IMAGE_2_5_GENERATION_MODEL - } else { - GPT_IMAGE_2_5_EDIT_MODEL - }; + // 中文注释:参考图存在时后端会走编辑端点,因此必须提交编辑 concrete model 而不是生成模型。 + let provider_model = + editor_image_generation_concrete_model(Some(model), !reference_images.is_empty()); let generated = create_openai_image_generation_with_model( http_client, settings, diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index e5e3b69f2..4255b0b62 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -347,8 +347,9 @@ fn editor_agent_image_mud_points( kind: Option<&str>, model: &str, image_size: Option<&str>, + has_reference_images: bool, ) -> u32 { - pricing.image_generation_mud_points(kind, Some(model), image_size) + pricing.image_generation_mud_points(kind, Some(model), image_size, has_reference_images) } impl EditorAgentTool for GenerateImageTool { @@ -370,6 +371,7 @@ impl EditorAgentTool for GenerateImageTool { None, args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -479,6 +481,7 @@ impl EditorAgentTool for GenerateCharacterTool { Some("character"), args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -591,6 +594,7 @@ impl EditorAgentTool for GenerateUiDesignTool { Some("ui-design"), args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -697,13 +701,10 @@ impl EditorAgentTool for EditImageTool { pricing: &EditorGenerationPricingConfig, args: &Value, ) -> Result { - let _: EditImageToolArgs = parse_internal_image_args("edit image args", args)?; - Ok(editor_agent_image_mud_points( - pricing, - Some("quick-edit"), - GPT_IMAGE_2_5_BUSINESS_NAME, - Some("1K"), - )) + let args: EditImageToolArgs = parse_internal_image_args("edit image args", args)?; + // 中文注释:修改图片按编辑 concrete model 执行,计价必须走编辑档, + // 不能沿用生成档(admin 分开调价时生成档价格即错价)。 + Ok(pricing.image_edit_model_mud_points(Some(args.model.as_str()), Some("1K"))) } fn build_display_args( @@ -851,6 +852,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { Some("icon"), args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -1298,7 +1300,8 @@ mod tests { use super::*; use crate::editor_generation_config::{ - EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, load_editor_generation_pricing_from_paths, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + load_editor_generation_pricing_from_paths, }; fn pricing() -> EditorGenerationPricingConfig { @@ -1626,6 +1629,61 @@ mod tests { } } + #[test] + fn dyn_pricing_routes_edit_tools_and_referenced_generation_to_the_edit_tier() { + let mut pricing = pricing(); + pricing + .models + .get_mut(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) + .expect("edit tier pricing should exist") + .prices + .insert("1K".to_string(), 41); + pricing + .models + .get_mut(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + .expect("generation tier pricing should exist") + .prices + .insert("1K".to_string(), 7); + + let context = context_with_image("image-1"); + let edit = + editor_agent_tool(EditImageTool::NAME, &context).expect("edit tool should resolve"); + let edit_args = edit + .validate_args(&json!({ "object_image_id": "image-1", "prompt": "修改" })) + .expect("edit args should validate"); + assert_eq!(edit.pricing(&pricing, &edit_args).expect("pricing"), 41); + + let generate = editor_agent_tool(GenerateImageTool::NAME, &context) + .expect("image tool should resolve"); + let without_reference = generate + .validate_args(&json!({ + "prompt": "生成图片", + "model": GPT_IMAGE_2_5_BUSINESS_NAME, + "image_size": "1K" + })) + .expect("generation args should validate"); + assert_eq!( + generate + .pricing(&pricing, &without_reference) + .expect("pricing"), + 7 + ); + let with_reference = generate + .validate_args(&json!({ + "prompt": "生成图片", + "model": GPT_IMAGE_2_5_BUSINESS_NAME, + "image_size": "1K", + "reference_image_ids": ["image-1"] + })) + .expect("referenced generation args should validate"); + assert_eq!( + generate + .pricing(&pricing, &with_reference) + .expect("pricing"), + 41 + ); + } + #[test] fn dyn_pricing_uses_the_supplied_runtime_snapshot() { let mut pricing = pricing(); diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 90bc1259f..b846f949a 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -180,10 +180,18 @@ impl EditorGenerationPricingConfig { kind: Option<&str>, model: Option<&str>, image_size: Option<&str>, + has_reference_images: bool, ) -> u32 { if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() { return price_mud_points; } + // 中文注释:生成任务带参考图时执行端改走编辑 concrete model,计价必须同步切到编辑档, + // 否则会出现「生成档扣费 + 编辑档执行/审计」的分裂。 + if editor_image_generation_concrete_model(model, has_reference_images) + == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT + { + return self.image_edit_model_mud_points(model, image_size); + } match kind.map(str::trim) { Some("spec") => self.spec_model_mud_points(model), Some("character" | "icon" | "ui-design" | "publication-material" | "scene") => { @@ -490,8 +498,14 @@ pub(crate) fn editor_image_generation_mud_points( kind: Option<&str>, model: Option<&str>, image_size: Option<&str>, + has_reference_images: bool, ) -> u32 { - default_runtime_pricing().image_generation_mud_points(kind, model, image_size) + default_runtime_pricing().image_generation_mud_points( + kind, + model, + image_size, + has_reference_images, + ) } #[cfg(test)] @@ -558,6 +572,27 @@ fn normalize_editor_image_edit_model(model: Option<&str>) -> &'static str { } } +/// 生成类图片任务最终发送给 provider 的 concrete model。 +/// +/// 中文注释:GPT Image 2.5 生成任务只要带参考图,执行端就必须改走 edits multipart, +/// 因此执行、审计与计价统一使用编辑 concrete model `gpt-image-2.5-sunburst-c`; +/// nanobanana 的生成与编辑共用同一个 concrete model,不受参考图影响。 +/// 这里是生成类任务选择 concrete model 的唯一入口,dispatch 与计价都不得再各写一份判断。 +pub(crate) fn editor_image_generation_concrete_model( + model: Option<&str>, + has_reference_images: bool, +) -> &'static str { + match model.map(str::trim).filter(|value| !value.is_empty()) { + Some( + EDITOR_IMAGE_MODEL_NANOBANANA2 + | EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS + | EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS, + ) => EDITOR_IMAGE_MODEL_NANOBANANA2, + _ if has_reference_images => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, + _ => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + } +} + fn normalize_editor_generation_image_price_size( model: &str, image_size: Option<&str>, @@ -808,7 +843,8 @@ mod tests { config.image_generation_mud_points( Some("character"), Some("gpt-image-2"), - Some("2K") + Some("2K"), + false ), queued_price ); @@ -955,7 +991,8 @@ mod tests { editor_image_generation_mud_points( Some("image"), Some("gemini-3.1-flash-image-preview"), - Some("0.5K") + Some("0.5K"), + false ), 8 ); @@ -963,7 +1000,8 @@ mod tests { editor_image_generation_mud_points( Some("character"), Some("gemini-3.1-flash-image-preview"), - Some("1K") + Some("1K"), + false ), 12 ); @@ -971,24 +1009,124 @@ mod tests { editor_image_generation_mud_points( Some("scene"), Some("gemini-3.1-flash-image-preview"), - Some("1K") + Some("1K"), + false ), 12 ); assert_eq!( - editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("1K")), + editor_image_generation_mud_points( + Some("ui-design"), + Some("gpt-image-2"), + Some("1K"), + false + ), 3 ); assert_eq!( - editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("2K")), + editor_image_generation_mud_points( + Some("ui-design"), + Some("gpt-image-2"), + Some("2K"), + false + ), 5 ); assert_eq!( - editor_image_generation_mud_points(Some("spec"), Some("gpt-image-2"), Some("2K")), + editor_image_generation_mud_points( + Some("spec"), + Some("gpt-image-2"), + Some("2K"), + false + ), 5 ); } + #[test] + fn editor_image_generation_with_references_prices_at_the_edit_tier() { + let mut config = default_runtime_pricing(); + for (model, price_1k, price_2k) in [ + (EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, 7, 9), + (EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, 41, 43), + ] { + let prices = &mut config + .models + .get_mut(model) + .expect("gpt image pricing should exist") + .prices; + prices.insert("1K".to_string(), price_1k); + prices.insert("2K".to_string(), price_2k); + } + + assert_eq!( + editor_image_generation_concrete_model( + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + false + ), + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION + ); + assert_eq!( + editor_image_generation_concrete_model( + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + true + ), + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT + ); + assert_eq!( + editor_image_generation_concrete_model(Some(EDITOR_IMAGE_MODEL_NANOBANANA2), true), + EDITOR_IMAGE_MODEL_NANOBANANA2 + ); + + // GPT Image 2.5 生成带参考图按编辑 concrete model 执行,计价必须同步切到编辑档。 + assert_eq!( + config.image_generation_mud_points( + Some("character"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("1K"), + true + ), + 41 + ); + assert_eq!( + config.image_generation_mud_points( + Some("character"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("1K"), + false + ), + 7 + ); + assert_eq!( + config.image_generation_mud_points( + Some("spec"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("2K"), + true + ), + 43 + ); + assert_eq!( + config.image_generation_mud_points( + Some("spec"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("2K"), + false + ), + 9 + ); + // nanobanana 生成与编辑共用一个 concrete model,参考图不改变计价档位。 + assert_eq!( + config.image_generation_mud_points( + None, + Some(EDITOR_IMAGE_MODEL_NANOBANANA2), + Some("1K"), + true + ), + 12 + ); + } + #[test] fn editor_video_and_character_animation_share_model_resolution_rates() { assert_eq!( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 6749dd47f..90ea6fcb9 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -77,6 +77,7 @@ use crate::{ with_editor_generation_durable_billing_boundary, }, auth::{AuthenticatedAccessToken, optional_access_token_from_headers}, + editor_generation_config::editor_image_generation_concrete_model, editor_generation_queue::{ EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, @@ -2920,6 +2921,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( normalized_kind, Some(generation_options.model), Some(generation_options.image_size), + editor_generation_has_reference_images(payload.reference_image_srcs.as_deref()), ), ); let source_entity_id = editor_generation_source_entity_id( @@ -3011,6 +3013,7 @@ pub(crate) async fn validate_editor_image_generation_parameters_for_owner( normalized_kind, Some(generation_options.model), Some(generation_options.image_size), + editor_generation_has_reference_images(payload.reference_image_srcs.as_deref()), ); Ok(()) } @@ -3222,6 +3225,7 @@ where normalized_kind, Some(generation_options.model), Some(generation_options.image_size), + !reference_images.is_empty(), ) }; let prompt_generation_inputs = payload.generation_inputs.take(); @@ -3291,13 +3295,10 @@ where &settings, EditorImageProviderRequest { model: generation_options.model, - provider_model: if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - generation_options.model - } else if reference_images.is_empty() { - GPT_IMAGE_2_5_GENERATION_MODEL - } else { - GPT_IMAGE_2_5_EDIT_MODEL - }, + provider_model: editor_image_generation_concrete_model( + Some(generation_options.model), + !reference_images.is_empty(), + ), prompt: submitted_prompt.as_str(), negative_prompt, size: provider_request_size.as_ref(), @@ -4098,8 +4099,8 @@ pub(crate) fn normalize_editor_generation_options( Some( GPT_IMAGE_2_MODEL | GPT_IMAGE_2_5_BUSINESS_NAME - | "gpt-image-2.5-flare-c" - | "gpt-image-2.5-sunburst-c", + | GPT_IMAGE_2_5_GENERATION_MODEL + | GPT_IMAGE_2_5_EDIT_MODEL, ) => GPT_IMAGE_2_5_BUSINESS_NAME, Some( EDITOR_IMAGE_MODEL_NANOBANANA2 @@ -12082,7 +12083,8 @@ async fn resolve_editor_ui_design_asset_extraction_price( "message": error.to_string(), })) })? - .image_generation_mud_points(Some("icon"), model, image_size); + // 中文注释:UI 素材提取始终以 UI 设计图作为来源图,GPT Image 2.5 固定走编辑 concrete model。 + .image_generation_mud_points(Some("icon"), model, image_size, true); Ok(expected_price_mud_points) } @@ -13278,6 +13280,14 @@ pub(crate) fn normalize_editor_reference_image_sources(sources: Option<&[String] .collect() } +/// 生成类任务是否带参考图。 +/// +/// 中文注释:dispatch、审计与计价必须共用同一个判据,否则又会出现 +/// 「生成档计价 + 编辑档执行」的分裂。 +pub(crate) fn editor_generation_has_reference_images(sources: Option<&[String]>) -> bool { + !normalize_editor_reference_image_sources(sources).is_empty() +} + pub(crate) fn ensure_editor_reference_image_source_limit( sources: Option<&[String]>, limit: usize, @@ -20058,6 +20068,7 @@ mod tests { Some("icon"), None, Some("1K"), + true, ), spritesheet_resource: None, spritesheet_asset: None, diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 8c18b7e7c..1e252094e 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -42,6 +42,7 @@ use crate::{ execute_billable_asset_operation_with_cost, with_editor_generation_durable_billing_boundary, }, auth::AuthenticatedAccessToken, + editor_generation_config::editor_image_generation_concrete_model, editor_generation_queue::{ EDITOR_ICON_SPEC_GENERATION_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EditorGenerationQueuedResponse, editor_generation_queue_state, @@ -246,7 +247,8 @@ pub(crate) async fn resolve_editor_icon_spritesheet_price( "message": error.to_string(), })) })? - .image_generation_mud_points(Some("icon"), model, image_size)) + // 中文注释:图标素材图集始终以图标规范图为参考图,GPT Image 2.5 固定走编辑 concrete model。 + .image_generation_mud_points(Some("icon"), model, image_size, true)) } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -660,7 +662,7 @@ pub(crate) async fn enqueue_icon_spec_generation_for_owner( payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; validate_icon_spec_reference_for_owner(state, caller, &payload).await?; - let price_mud_points = resolve_icon_spec_price(state).await?; + let price_mud_points = resolve_icon_spec_price(state, payload.reference_id.is_some()).await?; enqueue_editor_generation_job_for_caller( state, request_context, @@ -740,7 +742,10 @@ async fn validate_icon_spec_reference_for_owner( Ok(()) } -async fn resolve_icon_spec_price(state: &AppState) -> Result { +async fn resolve_icon_spec_price( + state: &AppState, + has_reference_images: bool, +) -> Result { Ok(state .editor_generation_pricing() .await @@ -753,6 +758,7 @@ async fn resolve_icon_spec_price(state: &AppState) -> Result { Some("spec"), Some(ICON_SPEC_MODEL), Some(ICON_SPEC_IMAGE_SIZE), + has_reference_images, )) } @@ -1774,15 +1780,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( &settings, EditorImageProviderRequest { model: generation_options.model, - provider_model: if generation_options.model - == EDITOR_IMAGE_MODEL_NANOBANANA2 - { - generation_options.model - } else if reference_images.is_empty() { - GPT_IMAGE_2_5_GENERATION_MODEL - } else { - GPT_IMAGE_2_5_EDIT_MODEL - }, + provider_model: editor_image_generation_concrete_model( + Some(generation_options.model), + !reference_images.is_empty(), + ), prompt: prompt.as_str(), negative_prompt: None, size, -- 2.52.0 From a06c7d7f1e105b260393a5a5944c7a87b0b03348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:00:21 +0800 Subject: [PATCH 57/80] =?UTF-8?q?raw=20image=20edit=20=E5=8F=91=E9=80=81?= =?UTF-8?q?=E5=89=8D=E5=A4=8D=E7=94=A8=E5=85=B1=E4=BA=AB=20provider=20?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - platform-image 导出 ensure_provider_matches_model 供 crate 内复用 - create_raw_image_edit 在构造请求前校验 settings.provider 与固定的编辑 concrete model 一致 --- .../crates/platform-image/src/image_provider/runtime/mod.rs | 1 + server-rs/crates/platform-image/src/raw_image_edit/mod.rs | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs b/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs index 63bf83afb..b78e51476 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs @@ -12,6 +12,7 @@ pub use audit::PlatformImageFailureAudit; pub(crate) use audit::build_failure_audit; pub(crate) use budget::{effective_request_timeout_ms, request_budget_exhausted_error}; pub use error::{PlatformImageError, PlatformImageStatusHint, missing_reference_images_error}; +pub(crate) use executor::ensure_provider_matches_model; pub use executor::{ create_image_edit, create_image_edit_with_references, create_image_edit_with_references_and_model, create_image_generation, diff --git a/server-rs/crates/platform-image/src/raw_image_edit/mod.rs b/server-rs/crates/platform-image/src/raw_image_edit/mod.rs index ab7a93c3b..96928bf8d 100644 --- a/server-rs/crates/platform-image/src/raw_image_edit/mod.rs +++ b/server-rs/crates/platform-image/src/raw_image_edit/mod.rs @@ -12,7 +12,8 @@ use crate::image_provider::{ images_edit_url, runtime::{ ImageProvider, ImageProviderSettings, PlatformImageError, build_failure_audit, - effective_request_timeout_ms, request_budget_exhausted_error, truncate_raw, + effective_request_timeout_ms, ensure_provider_matches_model, + request_budget_exhausted_error, truncate_raw, }, }; @@ -125,6 +126,9 @@ pub async fn create_raw_image_edit( failure_context: &str, ) -> Result { let provider_label = settings.provider.as_str(); + // 中文注释:raw edit 只发送 GPT Image 2.5 编辑 concrete model,先按共享白名单校验 + // provider,避免把 Tiantoken 的模型发到 VectorEngine 端点(或反之)。 + ensure_provider_matches_model(settings, GPT_IMAGE_2_5_EDIT_MODEL, failure_context)?; validate_raw_image_edit_dimensions(options.width, options.height) .map_err(|error| invalid_input(settings.provider, failure_context, error.to_string()))?; let url = images_edit_url(settings); -- 2.52.0 From f8f0479488b6d04b89f2b0aadbac8f06f5d4959c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:00:40 +0800 Subject: [PATCH 58/80] =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=85=9C=E5=BA=95=20ta?= =?UTF-8?q?sk=20id=20=E5=89=8D=E7=BC=80=E8=B7=9F=E9=9A=8F=E5=AE=9E?= =?UTF-8?q?=E9=99=85=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - executor 新增 image_task_id_prefix,按 settings.provider 拼接前缀 - 生成、nanobanana 生成与编辑三处不再硬编码 vector-engine 前缀,Tiantoken 任务的持久化与审计不再带错误前缀 --- .../src/image_provider/runtime/executor.rs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index fa86a2753..5b4926924 100644 --- a/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -216,6 +216,7 @@ pub async fn create_image_generation_with_model( "ImageProvider 图片生成 HTTP 返回" ); let response_text = response.body; + let task_id_prefix = image_task_id_prefix(settings.provider, None); match handle_image_response( http_client, request_url.as_str(), @@ -228,7 +229,7 @@ pub async fn create_image_generation_with_model( Some(prompt.chars().count()), Some(reference_images.len()), candidate_count, - "vector-engine", + task_id_prefix.as_str(), settings.request_deadline, ) .await @@ -406,6 +407,7 @@ pub async fn create_nanobanana_generate_content( "ImageProvider nanobanana2 图片生成 HTTP 返回" ); let response_text = response.body; + let task_id_prefix = image_task_id_prefix(settings.provider, Some("nanobanana")); handle_image_response( http_client, request_url.as_str(), @@ -418,7 +420,7 @@ pub async fn create_nanobanana_generate_content( Some(prompt.chars().count()), Some(reference_image_count), 1, - "vector-engine-nanobanana", + task_id_prefix.as_str(), settings.request_deadline, ) .await @@ -657,6 +659,7 @@ pub async fn create_image_edit_with_references_and_model( "ImageProvider 图片编辑 HTTP 返回" ); let response_text = response.body; + let task_id_prefix = image_task_id_prefix(settings.provider, Some("edit")); match handle_image_response( http_client, request_url.as_str(), @@ -669,7 +672,7 @@ pub async fn create_image_edit_with_references_and_model( Some(prompt.chars().count()), Some(reference_image_count), candidate_count, - "vector-engine-edit", + task_id_prefix.as_str(), settings.request_deadline, ) .await @@ -695,7 +698,7 @@ pub async fn create_image_edit_with_references_and_model( /// executor 用 `settings.base_url`/`settings.api_key` 发请求,如果调用方传入了属于 /// 另一个 provider 的模型,就会把错误的密钥/网关当成目标端点。这里显式 fail fast, /// 避免静默地把请求发到不匹配的 provider。 -fn ensure_provider_matches_model( +pub(crate) fn ensure_provider_matches_model( settings: &ImageProviderSettings, model: &str, failure_context: &str, @@ -718,6 +721,15 @@ fn ensure_provider_matches_model( Ok(()) } +/// 兜底 task id 前缀:必须跟随实际 provider,否则 Tiantoken 任务的 +/// `task_id` 会带着 `vector-engine` 前缀进入持久化与审计。 +fn image_task_id_prefix(provider: ImageProvider, task_kind: Option<&str>) -> String { + match task_kind { + Some(kind) => format!("{}-{kind}", provider.as_str()), + None => provider.as_str().to_string(), + } +} + fn preferred_image_upstream_model(requested_model: &str) -> &str { // Provider routing is selected by api-server at the task boundary. Keep the // concrete value intact here so retries stay on the same model and legacy -- 2.52.0 From 14c2255d545b2da90d0ee423d39eac48dc4900f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:00:58 +0800 Subject: [PATCH 59/80] =?UTF-8?q?=E8=A1=A5=20provider=20=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E7=9A=84=E5=8E=86=E5=8F=B2=E5=80=BC=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolve_image_provider 单测增加 gpt-image-2 → Tiantoken 断言,钉死 ADR 记录的历史可读值意图 --- server-rs/crates/platform-image/tests/image_provider.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server-rs/crates/platform-image/tests/image_provider.rs b/server-rs/crates/platform-image/tests/image_provider.rs index 9d7cb256c..4ceea0692 100644 --- a/server-rs/crates/platform-image/tests/image_provider.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -232,6 +232,12 @@ fn resolve_image_provider_rejects_business_model_and_accepts_concrete_models() { resolve_image_provider(GPT_IMAGE_2_5_EDIT_MODEL), Ok(ImageProvider::Tiantoken) ); + // 中文注释:`gpt-image-2` 是历史持久化值,仍按 Tiantoken 路由;它不得被当成 + // 未知 model 拒绝,也不得被静默改写成当前业务模型后再发往上游。 + assert_eq!( + resolve_image_provider(GPT_IMAGE_2_MODEL), + Ok(ImageProvider::Tiantoken) + ); assert_eq!( resolve_image_provider("gemini-3.1-flash-image-preview"), Ok(ImageProvider::VectorEngine) -- 2.52.0 From 5c5883054594e34c286bba578c7488056f19e032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:01:21 +0800 Subject: [PATCH 60/80] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BB=85=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=BD=BF=E7=94=A8=E7=9A=84=E5=9B=BE=E7=89=87=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 create_openai_image_edit 与 create_openai_image_edit_with_references 两个 dead_code 封装 - 空参考图本地校验用例改走 create_openai_image_edit_with_references_and_model,保留原覆盖 --- .../api-server/src/openai_image_generation.rs | 102 +----------------- 1 file changed, 3 insertions(+), 99 deletions(-) diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index a297eb560..03adbc863 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -2,8 +2,7 @@ use axum::http::StatusCode; use platform_image::{ DownloadedImage, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings, NANOBANANA_2_MODEL, PlatformImageError, PlatformImageStatusHint, ReferenceImage, - VECTOR_ENGINE_PROVIDER, build_image_http_client, create_image_edit, - create_image_edit_with_references, create_image_edit_with_references_and_model, + VECTOR_ENGINE_PROVIDER, build_image_http_client, create_image_edit_with_references_and_model, create_image_generation_with_model, create_nanobanana_generate_content, missing_reference_images_error, resolve_image_provider, }; @@ -221,102 +220,6 @@ pub(crate) async fn create_openai_nanobanana_generate_content( .await } -pub(crate) async fn create_openai_image_edit( - _http_client: &reqwest::Client, - settings: &OpenAiImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - reference_image: &OpenAiReferenceImage, - failure_context: &str, -) -> Result { - let started_at_micros = current_utc_micros(); - let request_payload = json!({ - "size": size, - "promptChars": prompt.chars().count(), - "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), - "referenceImageCount": 1, - }); - let provider_client = settings.client_for_model(GPT_IMAGE_2_5_EDIT_MODEL); - let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_EDIT_MODEL); - let result = create_image_edit( - provider_client.http_client(), - &provider_settings, - prompt, - negative_prompt, - size, - reference_image, - failure_context, - ) - .await; - map_platform_image_result( - settings, - result, - "image_edit", - failure_context, - request_payload, - started_at_micros, - ) - .await -} - -pub(crate) async fn create_openai_image_edit_with_references( - _http_client: &reqwest::Client, - settings: &OpenAiImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - candidate_count: u32, - reference_images: &[OpenAiReferenceImage], - failure_context: &str, -) -> Result { - let started_at_micros = current_utc_micros(); - let request_payload = json!({ - "size": size, - "candidateCount": candidate_count, - "promptChars": prompt.chars().count(), - "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), - "referenceImageCount": reference_images.len(), - }); - // 中文注释:先做本地参考图校验,避免 provider 客户端缺失时在校验前 panic。 - if reference_images.is_empty() { - return map_platform_image_result( - settings, - Err(missing_reference_images_error( - ImageProvider::Tiantoken, - failure_context, - )), - "image_edit_with_references", - failure_context, - request_payload, - started_at_micros, - ) - .await; - } - let provider_client = settings.client_for_model(GPT_IMAGE_2_5_EDIT_MODEL); - let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_EDIT_MODEL); - let result = create_image_edit_with_references( - provider_client.http_client(), - &provider_settings, - prompt, - negative_prompt, - size, - candidate_count, - reference_images, - failure_context, - ) - .await; - map_platform_image_result( - settings, - result, - "image_edit_with_references", - failure_context, - request_payload, - started_at_micros, - ) - .await -} - #[allow(clippy::too_many_arguments)] pub(crate) async fn create_openai_image_edit_with_references_and_model( _http_client: &reqwest::Client, @@ -758,9 +661,10 @@ mod tests { }; let http_client = reqwest::Client::new(); - let result = create_openai_image_edit_with_references( + let result = create_openai_image_edit_with_references_and_model( &http_client, &settings, + GPT_IMAGE_2_5_EDIT_MODEL, "提示词", None, "1:1", -- 2.52.0 From 166977baf0b11261c8c648eec73938c46918687f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:01:44 +0800 Subject: [PATCH 61/80] =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E6=94=B9=E7=94=A8=20concrete=20model=20?= =?UTF-8?q?=E4=B8=8E=20Tiantoken=20=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate-template-samples 与 generate-anthro-cat-illustrations 的 preferredImageModel 改为 gpt-image-2.5-flare-c - 两个脚本的 base URL / API key / 超时改为只读 TIANTOKEN_BASE_URL、TIANTOKEN_API_KEY、TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS,不再回退 VectorEngine - 脚本内 URL 构造函数、错误文案与错误字段名从 VectorEngine 改为 provider 中性的 Tiantoken 口径 - SKILL.md 请求契约、环境变量与模型说明同步到 concrete model + Tiantoken,并说明带参考图的编辑走 gpt-image-2.5-sunburst-c --- .codex/skills/gpt-image-2-apimart/SKILL.md | 34 ++++++++++------- .../generate-anthro-cat-illustrations.mjs | 38 ++++++++++--------- .../scripts/generate-template-samples.mjs | 38 ++++++++++--------- 3 files changed, 61 insertions(+), 49 deletions(-) diff --git a/.codex/skills/gpt-image-2-apimart/SKILL.md b/.codex/skills/gpt-image-2-apimart/SKILL.md index 38baf072c..651193668 100644 --- a/.codex/skills/gpt-image-2-apimart/SKILL.md +++ b/.codex/skills/gpt-image-2-apimart/SKILL.md @@ -1,11 +1,17 @@ --- name: gpt-image-2-apimart -description: Generate or inspect project image assets through this repository's image workflow using the GPT Image 2.5 business model. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical. +description: Generate or inspect project image assets through this repository's Tiantoken GPT Image 2.5 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY image-generation configuration without exposing secrets. The directory name is historical. --- # GPT Image 2.5 project image workflow -Use this skill for project-local image asset generation that must match the repository's image request contract. Use the business model identifier `gpt-image-2.5`; provider concrete model routing is owned by `server-rs`, and this client must not perform a cross-model fallback. The folder still contains `apimart` in its name for compatibility with existing local plugin references. +Use this skill for project-local image asset generation that must match the repository's image request contract. Provider routing is owned by `server-rs`; when this skill talks to the provider directly it must send the concrete provider model and the matching provider credentials, because the provider side only accepts concrete models: + +- generation (no reference images): `gpt-image-2.5-flare-c` through Tiantoken +- edits (any reference image): `gpt-image-2.5-sunburst-c` through Tiantoken +- nanobanana (`gemini-3.1-flash-image-preview`) stays on VectorEngine + +The business model name `gpt-image-2.5` is resolved to a concrete key by `server-rs` at the task boundary and is never sent to a provider directly. This client must not perform a cross-model fallback. The folder still contains `apimart` in its name for compatibility with existing local plugin references. ## Workflow @@ -24,15 +30,15 @@ Use this skill for project-local image asset generation that must match the repo ``` 5. Save final project assets under `public/` or another explicitly requested workspace path. -6. Never print `VECTOR_ENGINE_API_KEY`. Report only whether configuration exists. +6. Never print `TIANTOKEN_API_KEY`. Report only whether configuration exists. ## Request Contract The repository image path uses: ```text -POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations -Authorization: Bearer {VECTOR_ENGINE_API_KEY} +POST {TIANTOKEN_BASE_URL}/v1/images/generations +Authorization: Bearer {TIANTOKEN_API_KEY} Content-Type: application/json ``` @@ -40,7 +46,7 @@ Default body: ```json { - "model": "gpt-image-2.5", + "model": "gpt-image-2.5-flare-c", "prompt": "", "n": 1, "size": "1024x1024" @@ -50,22 +56,22 @@ Default body: For visual references, use the edit endpoint instead of the create endpoint: ```text -POST {VECTOR_ENGINE_BASE_URL}/v1/images/edits -Authorization: Bearer {VECTOR_ENGINE_API_KEY} +POST {TIANTOKEN_BASE_URL}/v1/images/edits +Authorization: Bearer {TIANTOKEN_API_KEY} Content-Type: multipart/form-data ``` Multipart fields: ```text -model=gpt-image-2.5 +model=gpt-image-2.5-sunburst-c prompt= n=1 size=1024x1024 image=@reference.png ``` -In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths send the business model `gpt-image-2.5`; provider routing and retry policy remain server-owned. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part. +In this repository, calls with no reference images use `POST /v1/images/generations` with `gpt-image-2.5-flare-c`; calls with any reference image use `POST /v1/images/edits` with `gpt-image-2.5-sunburst-c` and pass references as one or more `image` form parts. Server-side calls still submit the business model `gpt-image-2.5` and let `server-rs` resolve the concrete key; provider routing and retry policy remain server-owned, and pricing follows the same reference-image rule (generation with a reference image is charged at the edit tier). Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part. Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints. @@ -75,12 +81,14 @@ Load environment values from process env first, then `.env.secrets.local`, `.env Required for live generation: -- `VECTOR_ENGINE_BASE_URL` -- `VECTOR_ENGINE_API_KEY` +- `TIANTOKEN_BASE_URL` +- `TIANTOKEN_API_KEY` Optional: -- `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` +- `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` + +`VECTOR_ENGINE_*` values no longer serve GPT Image 2.5: those credentials belong to VectorEngine, which only serves nanobanana. Do not fall back from `TIANTOKEN_*` to `VECTOR_ENGINE_*`. If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat. diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs index 0ab4af282..c38a99436 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs @@ -9,7 +9,9 @@ const skillRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(skillRoot, '..', '..', '..'); const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations'); const defaultTimeoutMs = 1000000; -const preferredImageModel = 'gpt-image-2.5'; +// GPT Image 2.5 生成任务只接受 concrete provider model;业务模型名 `gpt-image-2.5` +// 由 server-rs 在任务边界解析,脚本直连 provider 时必须自己给出 concrete key。 +const preferredImageModel = 'gpt-image-2.5-flare-c'; const prompts = [ { @@ -100,18 +102,18 @@ function resolveEnv() { ...process.env, }; return { - baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') + baseUrl: String(loaded.TIANTOKEN_BASE_URL || '') .trim() .replace(/\/+$/u, ''), - apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), + apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(), timeoutMs: Number.parseInt( - String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), 10, ), }; } -function buildVectorEngineImagesGenerationUrl(baseUrl) { +function buildTiantokenImagesGenerationUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`; @@ -232,22 +234,22 @@ async function fetchJson(url, options, timeoutMs) { const text = await response.text(); if (!response.ok) { const error = new Error( - `VectorEngine ${response.status}: ${text.slice(0, 600)}`, + `Tiantoken ${response.status}: ${text.slice(0, 600)}`, ); - error.vectorEngineStatus = response.status; - error.vectorEngineBody = text; + error.providerStatus = response.status; + error.providerBody = text; throw error; } try { return JSON.parse(text); } catch (error) { - error.vectorEngineResponseParse = true; - error.vectorEngineBody = text; + error.providerResponseParse = true; + error.providerBody = text; throw error; } } catch (error) { if (error?.name === 'AbortError') { - throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`); } throw error; } finally { @@ -264,7 +266,7 @@ async function requestImagePayload(env, entry) { size: '1024x1024', }; const payload = await fetchJson( - buildVectorEngineImagesGenerationUrl(env.baseUrl), + buildTiantokenImagesGenerationUrl(env.baseUrl), { method: 'POST', headers: { @@ -280,9 +282,9 @@ async function requestImagePayload(env, entry) { if (extractImageUrls(payload)[0] || base64Image) { return payload; } - const error = new Error(`VectorEngine returned no image for ${entry.id}`); - error.vectorEngineResponseParse = true; - error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); + const error = new Error(`Tiantoken returned no image for ${entry.id}`); + error.providerResponseParse = true; + error.providerBody = JSON.stringify(payload).slice(0, 600); throw error; } @@ -326,7 +328,7 @@ async function generateOne(env, entry, outDir) { const bytes = decodeStrictBase64Image(b64Images[0]); if (!bytes) { throw new Error( - `VectorEngine returned invalid base64 image for ${entry.id}`, + `Tiantoken returned invalid base64 image for ${entry.id}`, ); } image = { @@ -334,7 +336,7 @@ async function generateOne(env, entry, outDir) { extension: inferExtensionFromBytes(bytes), }; } else { - throw new Error(`VectorEngine returned no image for ${entry.id}`); + throw new Error(`Tiantoken returned no image for ${entry.id}`); } mkdirSync(outDir, { recursive: true }); @@ -381,7 +383,7 @@ if (!env.baseUrl || !env.apiKey) { console.error( JSON.stringify({ ok: false, - error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', + error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey), }), diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs index 796745998..28363fcba 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs @@ -18,7 +18,9 @@ const defaultOutDir = path.join( 'puzzle-creation-templates', ); const defaultTimeoutMs = 1000000; -const preferredImageModel = 'gpt-image-2.5'; +// GPT Image 2.5 生成任务只接受 concrete provider model;业务模型名 `gpt-image-2.5` +// 由 server-rs 在任务边界解析,脚本直连 provider 时必须自己给出 concrete key。 +const preferredImageModel = 'gpt-image-2.5-flare-c'; const args = new Map(); for (let index = 2; index < process.argv.length; index += 1) { @@ -70,18 +72,18 @@ function resolveEnv() { ...process.env, }; return { - baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') + baseUrl: String(loaded.TIANTOKEN_BASE_URL || '') .trim() .replace(/\/+$/u, ''), - apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), + apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(), timeoutMs: Number.parseInt( - String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), 10, ), }; } -function buildVectorEngineImagesGenerationUrl(baseUrl) { +function buildTiantokenImagesGenerationUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`; @@ -202,22 +204,22 @@ async function fetchJson(url, options, timeoutMs) { const text = await response.text(); if (!response.ok) { const error = new Error( - `VectorEngine ${response.status}: ${text.slice(0, 600)}`, + `Tiantoken ${response.status}: ${text.slice(0, 600)}`, ); - error.vectorEngineStatus = response.status; - error.vectorEngineBody = text; + error.providerStatus = response.status; + error.providerBody = text; throw error; } try { return JSON.parse(text); } catch (error) { - error.vectorEngineResponseParse = true; - error.vectorEngineBody = text; + error.providerResponseParse = true; + error.providerBody = text; throw error; } } catch (error) { if (error?.name === 'AbortError') { - throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`); } throw error; } finally { @@ -234,7 +236,7 @@ async function requestImagePayload(env, template) { size: '1024x1024', }; const payload = await fetchJson( - buildVectorEngineImagesGenerationUrl(env.baseUrl), + buildTiantokenImagesGenerationUrl(env.baseUrl), { method: 'POST', headers: { @@ -250,9 +252,9 @@ async function requestImagePayload(env, template) { if (extractImageUrls(payload)[0] || base64Image) { return payload; } - const error = new Error(`VectorEngine returned no image for ${template.id}`); - error.vectorEngineResponseParse = true; - error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); + const error = new Error(`Tiantoken returned no image for ${template.id}`); + error.providerResponseParse = true; + error.providerBody = JSON.stringify(payload).slice(0, 600); throw error; } @@ -296,7 +298,7 @@ async function generateOne(env, template, outDir) { const bytes = decodeStrictBase64Image(b64Images[0]); if (!bytes) { throw new Error( - `VectorEngine returned invalid base64 image for ${template.id}`, + `Tiantoken returned invalid base64 image for ${template.id}`, ); } image = { @@ -304,7 +306,7 @@ async function generateOne(env, template, outDir) { extension: inferExtensionFromBytes(bytes), }; } else { - throw new Error(`VectorEngine returned no image for ${template.id}`); + throw new Error(`Tiantoken returned no image for ${template.id}`); } mkdirSync(outDir, { recursive: true }); @@ -355,7 +357,7 @@ if (!env.baseUrl || !env.apiKey) { console.error( JSON.stringify({ ok: false, - error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', + error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey), }), -- 2.52.0 From 69af571242c455b1fa642d2e065c3979404ee02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:02:05 +0800 Subject: [PATCH 62/80] =?UTF-8?q?=E4=BF=AE=E8=AE=A2=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=B8=A6=E5=8F=82=E8=80=83=E5=9B=BE=E7=9A=84=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E4=B8=8E=E8=AE=A1=E4=BB=B7=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR 改为「带参考图的生成与编辑都按编辑 concrete model 执行、审计与计价」,并补定价档位跟随 concrete model 的后果 - 里程碑文档路由表拆分 flare-c(仅无参考图生成)与 sunburst-c(编辑 + 带参考图的生成),必须成立的行为补第 7 条 - decision-log 修正 2026-09-18 条目,并新增本次「生成带参考图统一按编辑 concrete model 执行与计价」决策记录 - pitfalls、后端架构与开发运维文档同步 provider 具体模型与计价档位口径 --- ...R】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md | 5 +++-- ...Œ程碑】GPT Image 2.5 provider边界重构-2026-09-18.md | 5 +++-- docs/project-memory/shared-memory/decision-log.md | 12 +++++++++++- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...«¯架构】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 ++-- .../【开发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md index 7bacf73b5..b38217e25 100644 --- a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md +++ b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md @@ -2,7 +2,7 @@ 状态:accepted -新任务使用业务模型值 `gpt-image-2.5`,api-server 按任务显式选择具体 model:生成使用 `gpt-image-2.5-flare-c`,编辑使用 `gpt-image-2.5-sunburst-c`。这两个 GPT Image 2.5 model 必须通过启动时构造的 Tiantoken client 发送;Tiantoken client 只读取显式配置的 `TIANTOKEN_BASE_URL`(部署值由环境设置为 `https://api.tiantoken.com`)和独立 `TIANTOKEN_API_KEY`,缺失即阻止 api-server 启动,不得回退到 VectorEngine 或其 API key。 +新任务使用业务模型值 `gpt-image-2.5`,api-server 按任务显式选择具体 model:无参考图的生成使用 `gpt-image-2.5-flare-c`,编辑以及带参考图的生成使用 `gpt-image-2.5-sunburst-c`(参考图会让执行端必须走 edits multipart,因此执行与计价都按编辑 concrete model)。这两个 GPT Image 2.5 model 必须通过启动时构造的 Tiantoken client 发送;Tiantoken client 只读取显式配置的 `TIANTOKEN_BASE_URL`(部署值由环境设置为 `https://api.tiantoken.com`)和独立 `TIANTOKEN_API_KEY`,缺失即阻止 api-server 启动,不得回退到 VectorEngine 或其 API key。 图片协议执行逻辑保持 provider-neutral:请求 body、multipart、尺寸约束、重试、响应解码和审计由共享 image executor 承担;VectorEngine 与 Tiantoken client 只提供相同协议所需的 base URL、API key 和 provider identity。platform-image 根据 concrete model 做严格白名单路由:`gpt-image-2.5-flare-c` 与 `gpt-image-2.5-sunburst-c` 走 Tiantoken,`gemini-3.1-flash-image-preview`(nanobanana)走 VectorEngine;未知 model 直接拒绝。已持久化的 `gpt-image-2` 只在新任务提交边界按兼容规则解析为当前 GPT Image 2.5 任务,不改写历史资源,也不进入旧 VectorEngine 图片路由。 @@ -10,13 +10,14 @@ provider 白名单同时拒绝非 provider model:业务模型名 `gpt-image-2. `gpt-image-2-c` 已整体删除:它自兜底移除后只剩历史审计字符串,从未进入业务模型、持久化模型字段或前端契约,因此不再保留任何常量、解析分支、尺寸语义或 provider 路由。数据库里既有的审计字符串保持原样,不参与新任务。 -普通主站前端只接触业务模型和新生成展示名 `GPT Image 2.5`,admin Web/API 可以查看和编辑两个具体定价 key;普通生成即使因参考图使用 edits multipart,仍按生成 concrete model。新代码不再跨模型或跨 provider fallback,已退役的 `gpt-image-2-c` 审计记录原样保留在数据库,代码不再引用该值。 +普通主站前端只接触业务模型和新生成展示名 `GPT Image 2.5`,admin Web/API 可以查看和编辑两个具体定价 key;生成类任务带参考图时后端必须走 edits multipart,因此 dispatch、审计与计价统一按编辑 concrete model `gpt-image-2.5-sunburst-c`(包含图标素材图集的固定参考图与 UI 素材提取的来源图),不存在「生成档计价 + 编辑档执行」的分裂。新代码不再跨模型或跨 provider fallback,已退役的 `gpt-image-2-c` 审计记录原样保留在数据库,代码不再引用该值。 主站前端只把原本明确使用 GPT Image 2 的专用新任务改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为保持不变。画布参数或编辑布局读到 `gpt-image-2` 时,在使用端解析为 `gpt-image-2.5` 并触发参数迁移警告,原始资源、审计、metadata 和历史 fixture 不回写。AGC 现有资源生成界面与请求默认保持不变,不因本决策新增模型字段、选择器或尺寸行为。 ## Consequences - 定价配置的活动 key 是两个具体 provider model;旧单 key 配置只允许受控 backfill,并留下兼容 TODO。 +- 定价档位跟随任务最终 concrete model:带参考图的生成任务与编辑任务同价,admin 单独调整 `gpt-image-2.5-sunburst-c` 时生成带参考图的任务必须同步生效。 - 新任务的同模型重试固定使用 api-server dispatch 的具体 model,不切换到另一个 model。 - 两套 provider client 在 api-server 启动阶段同时构造;任一 required provider 配置缺失,启动失败而不是延迟到首次图片请求。 - 两套 provider client 的缺失配置报错点名对应环境变量(`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`、`TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`),构造成功后用启动日志记录已启用的 provider。 diff --git a/docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md b/docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md index 0e87c4e62..4e5677743 100644 --- a/docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md @@ -21,8 +21,8 @@ | Concrete model | Provider client | 业务用途 | | --- | --- | --- | -| `gpt-image-2.5-flare-c` | Tiantoken | Generate,包括带参考图的普通生成 | -| `gpt-image-2.5-sunburst-c` | Tiantoken | Edit,包括快速编辑、原位修改、raw edit | +| `gpt-image-2.5-flare-c` | Tiantoken | Generate,仅限不带参考图的生成任务 | +| `gpt-image-2.5-sunburst-c` | Tiantoken | Edit,包括快速编辑、原位修改、raw edit,以及带参考图的生成任务(图标素材图集的固定参考图、UI 素材提取的来源图同在内) | | `gemini-3.1-flash-image-preview` | VectorEngine | nanobanana 生成/编辑能力 | `gpt-image-2` 与 `gpt-image-2-c` 只保留为历史持久化/审计字符串;新任务不得 dispatch 到旧 GPT Image 2 路由。未知 model 直接拒绝。 @@ -35,6 +35,7 @@ 4. 同 concrete model 可以 retry,但永不跨 concrete model 或跨 provider fallback。 5. 历史值读取不改写;新任务提交边界将旧值兼容为 GPT Image 2.5 业务任务。 6. 普通前端不接收 concrete provider model;admin 定价界面可查看和编辑两个具体 pricing key。 +7. 生成任务的 concrete model 由参考图决定:同一判据同时用于 dispatch、审计与计价,带参考图的生成任务按编辑档计价。 ## 非目标 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a5f3c1561..3be5f125f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,15 @@ # 决策记录 +## 2026-09-21 生成带参考图统一按编辑 concrete model 执行与计价 + +- 背景:GPT Image 2.5 迁移后,生成端在带参考图时会改走 `/v1/images/edits` 并提交 `gpt-image-2.5-sunburst-c`,但计价仍按 `gpt-image-2.5-flare-c` 生成档,dispatch 判断也分散在三个入口各自的 `if 带参考图` 表达式里;结果是「生成档扣费 + 编辑档执行/审计」分裂,admin 单独调价任一档位都会错价,ADR 里「普通生成即使因参考图使用 edits multipart,仍按生成 concrete model」与代码事实不符。 +- 决策:保留「按是否带参考图决定 concrete model」这一现有行为,把三处文档改到与代码一致;同时新增 `editor_image_generation_concrete_model(model, has_reference_images)` 作为生成类任务选择 concrete model 的唯一入口,dispatch 与计价共用同一判据。`image_generation_mud_points` 增加 `has_reference_images`,命中编辑 concrete model 时改走编辑档 `image_edit_model_mud_points`;图标素材图集(固定参考图)、UI 素材提取(来源图)、图标规范带 `referenceId`、Agent 生成类工具带 `referenceImageIds` 全部同规则。`EditImageTool` 计价从生成档改为编辑档,与直连快速编辑、raw edit 对齐。 +- 边界:nanobanana 生成与编辑共用一个 concrete model,参考图不改变档位;不引入跨模型 fallback;历史持久化值与审计字符串不改写。 +- 同批修复:`raw_image_edit` 发送前复用共享 `ensure_provider_matches_model` 白名单校验;`platform-image` 兜底 task id 前缀改为跟随实际 provider(`tiantoken-edit` / `tiantoken-nanobanana` 不再带 `vector-engine` 前缀);`resolve_image_provider` 增加 `gpt-image-2 → Tiantoken` 断言把历史值意图钉死;删除 `openai_image_generation.rs` 中仅测试使用的 `create_openai_image_edit` / `create_openai_image_edit_with_references`(测试改用 `..._and_model` 变体,保留空参考图本地校验覆盖);`normalize_editor_generation_options` 的具体 provider key 改用常量;External v1 OpenAPI 的 `model` 描述改为与实现一致(`kind=ui-design` / `publication-material` 固定 `gpt-image-2.5`,UI 素材提取未传默认 nanobanana)。 +- 影响范围:`api-server`(`editor_generation_config`、`editor_project`、`editor_project_icon`、`character_visual_assets`、`editor_agent/tool`、`openai_image_generation`)、`platform-image`(`raw_image_edit`、`runtime/executor`、集成测试)、ADR、里程碑文档、External v1 OpenAPI 描述、`.codex/skills/gpt-image-2-apimart` 脚本与 SKILL.md(改用 `TIANTOKEN_*` 凭据 + concrete model)。 +- 验证:`cargo test -p api-server editor_generation_config`、`cargo test -p api-server editor_agent::tool`、`cargo test -p api-server`(1123 passed / 1 failed,失败项为 mock LLM 连接超时,单测隔离重跑通过)。 +- 关联文档:[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`](../plans/【里程碑】GPT%20Image%202.5%20provider边界重构-2026-09-18.md)。 + ## 2026-09-21 图片 provider 白名单收紧,并整体删除 gpt-image-2-c - 背景:GPT Image 2.5 迁移后,`resolve_image_provider` 仍把业务模型名 `gpt-image-2.5` 与 `gpt-image-2-c` 一起判为 Tiantoken,等于把「非 provider model」留在 provider 边界白名单里。`gpt-image-2-c` 自 2026-07-21 起只是首选模型失败时的兜底 provider model(兜底移除后仅剩历史审计字符串),从未进入业务模型、持久化 `model` 字段或前端契约——api-server 持久化的模型一律取自 `generation_options.model`(业务值),前端在 2.5 迁移前只写 `gpt-image-2`。 @@ -9161,7 +9171,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-18 GPT Image 2.5 业务模型与具体 provider 定价路由 -- **决策**:新任务使用业务模型值 `gpt-image-2.5`;api-server 按任务显式 dispatch 具体模型 `gpt-image-2.5-flare-c`(生成)或 `gpt-image-2.5-sunburst-c`(编辑),并把同一具体 key 交给 `platform-image` 与后台定价解析。普通生成即使因参考图使用 edits multipart,仍按生成 route;同模型重试不跨模型 fallback。 +- **决策**:新任务使用业务模型值 `gpt-image-2.5`;api-server 按任务显式 dispatch 具体模型 `gpt-image-2.5-flare-c`(无参考图的生成)或 `gpt-image-2.5-sunburst-c`(编辑,以及带参考图的生成),并把同一具体 key 交给 `platform-image` 与后台定价解析。带参考图的生成任务因为必须走 edits multipart,dispatch、审计与计价统一按编辑 concrete model;同模型重试不跨模型 fallback。 - **历史兼容**:已持久化 `gpt-image-2` 读回原值不改写;基于旧资源发起新任务时,在提交边界解析为 `gpt-image-2.5`,新任务/新产物按新业务值和当前 task price 处理。旧 `gpt-image-2-c` 仅保留历史审计,不再作为 fallback 或业务模型。 - **可见性**:普通主站前端和公开定价 API 不接收具体 provider key;新生成 UI label 为 `GPT Image 2.5`,历史资源/旧编辑上下文不扩散该 label。admin Web/API 是明确例外,可查看和编辑两个具体定价 key。旧单 key 定价配置允许受控 backfill,并加 compatibility TODO。 - **关联 ADR**:[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 45b343a80..00a6556df 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2503,7 +2503,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。 - 原因:2026-05-21 后 GPT-image-2 图片生成按 VectorEngine 创建/编辑接口分流;2026-07-05 后创意 Agent 文本链路也改为 VectorEngine Chat Completions `gpt-5.4-mini`,APIMart 不再作为当前创意 Agent 来源。 -- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,新任务业务模型为 `gpt-image-2.5`,provider 具体模型为 `gpt-image-2.5-flare-c`(生成)/ `gpt-image-2.5-sunburst-c`(编辑),只在同一具体模型内重试。历史兜底模型 `gpt-image-2-c` 已从代码整体删除,不再存在任何 fallback 路径,请求体里出现该值即视为异常。 +- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,新任务业务模型为 `gpt-image-2.5`,provider 具体模型为 `gpt-image-2.5-flare-c`(不带参考图的生成)/ `gpt-image-2.5-sunburst-c`(编辑与带参考图的生成),带参考图的生成按编辑档计价,只在同一具体模型内重试。历史兜底模型 `gpt-image-2-c` 已从代码整体删除,不再存在任何 fallback 路径,请求体里出现该值即视为异常。 - 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。 - 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 317869fa3..72132b167 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -184,7 +184,7 @@ npm run check:server-rs-ddd 2. Adapter 输入应显式包含 provider、prompt、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source job、metadata 和可选透明背景后处理。 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 -5. 图片 provider 协议不再放在玩法模块里实现。产品、计费、DTO、持久化和 GPT Image 2.5 创建 / 编辑请求统一使用业务模型 `gpt-image-2.5`,api-server 在提交边界分派 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c`;已持久化的 `gpt-image-2` 只按兼容规则读取,不改写历史值;已退役的 `gpt-image-2-c` 已从代码整体删除,传入即按不支持的值处理。URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/image_provider/`;其中 `runtime/executor.rs` 负责共享 provider-neutral 执行编排,`transport/` 负责 HTTP client 与 curl 传输,`protocol/` 负责请求体、路径和响应 JSON 字段。raw image edit 的 multipart 与严格尺寸校验位于独立的 `server-rs/crates/platform-image/src/raw_image_edit/`。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 +5. 图片 provider 协议不再放在玩法模块里实现。产品、计费、DTO、持久化和 GPT Image 2.5 创建 / 编辑请求统一使用业务模型 `gpt-image-2.5`,api-server 在提交边界按是否带参考图分派 `gpt-image-2.5-flare-c`(无参考图的生成)/ `gpt-image-2.5-sunburst-c`(编辑与带参考图的生成),dispatch、审计与计价共用同一 concrete model;已持久化的 `gpt-image-2` 只按兼容规则读取,不改写历史值;已退役的 `gpt-image-2-c` 已从代码整体删除,传入即按不支持的值处理。URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/image_provider/`;其中 `runtime/executor.rs` 负责共享 provider-neutral 执行编排,`transport/` 负责 HTTP client 与 curl 传输,`protocol/` 负责请求体、路径和响应 JSON 字段。raw image edit 的 multipart 与严格尺寸校验位于独立的 `server-rs/crates/platform-image/src/raw_image_edit/`,发送前复用共享 `ensure_provider_matches_model` 白名单校验,避免把 Tiantoken 的模型发到 VectorEngine 端点。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。generated 私有对象上传时必须由 OSS 对象头承载浏览器 / CDN 缓存策略,默认写入 `Cache-Control: public, max-age=31536000, immutable`,不得改成 api-server 本地磁盘静态资源兜底。 7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 @@ -318,7 +318,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 错误边界固定如下:`StreamUnavailable` 只表示流式响应已给出 `tool_use` / `tool_calls` 完成原因但没有聚合出任何工具 slot,供调用方回退非流式,它不承担截断语义;`EmptyResponse` 表示最终文本和工具调用都为空,纯工具响应合法;`Deserialize` 覆盖 JSON / SSE / UTF-8 解析失败、缺少 `choices[0]`、流式工具身份缺失、流式工具槽位身份冲突、流式参数不完整,以及上述工具流未收尾截断。Anthropic 仍不支持 `web_search`、图片内容和纯 system 消息,必须至少有一条非 system 文本消息。 -- 图片生成:VectorEngine 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;逻辑 SKU 固定为业务模型 `gpt-image-2.5`,provider 具体模型固定为 `gpt-image-2.5-flare-c`(生成)/ `gpt-image-2.5-sunburst-c`(编辑);历史兜底模型 `gpt-image-2-c` 已从代码整体删除,只在同一具体模型内重试,不做跨模型 fallback。401 / 403、普通参数或安全拒绝、本地配置 / 参考图错误、无法确认上游是否已受理的发送错误、request budget 耗尽和生成成功后的图片下载失败不得切模型。一次业务请求总发送上限仍为 5 次;切换兜底模型会消耗后续 attempt,不允许两个模型各重试 5 次。`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id、结果摘要和 recovered failure 数量;失败 attempt 仍落 `external_api_call_failure`,后续 attempt 成功时在运行摘要里计入 recovered failure。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 +- 图片生成:VectorEngine 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;逻辑 SKU 固定为业务模型 `gpt-image-2.5`,provider 具体模型为 `gpt-image-2.5-flare-c`(无参考图的生成)/ `gpt-image-2.5-sunburst-c`(编辑与带参考图的生成),计价档位与执行档位必须一致;历史兜底模型 `gpt-image-2-c` 已从代码整体删除,只在同一具体模型内重试,不做跨模型 fallback。401 / 403、普通参数或安全拒绝、本地配置 / 参考图错误、无法确认上游是否已受理的发送错误、request budget 耗尽和生成成功后的图片下载失败不得切模型。一次业务请求总发送上限仍为 5 次;切换兜底模型会消耗后续 attempt,不允许两个模型各重试 5 次。`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id、结果摘要和 recovered failure 数量;失败 attempt 仍落 `external_api_call_failure`,后续 attempt 成功时在运行摘要里计入 recovered failure。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 - 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index daa996a20..1d62fffd9 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -238,7 +238,7 @@ spacetime sql "SELECT * FROM runtime_setting LIMIT 1" --server http:/ 本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.8.3`,CLI / standalone commit 固定核对为 `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。若版本或 commit 错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;其它版本可执行 `spacetime version install && spacetime version use `,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.8.3 + 8e410d28...` 同时匹配;旧版本或旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。 -本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git;同时确认 VectorEngine 的 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 已配置,因为两个图片 client 都在 api-server 启动时构造,任一缺失都会阻止启动。VectorEngine 配置仍保留给 nanobanana 与 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。新生成任务使用业务模型 `gpt-image-2.5`,api-server 显式分派 `gpt-image-2.5-flare-c`(generate)或 `gpt-image-2.5-sunburst-c`(edit);已持久化的 `gpt-image-2` 只在提交边界按兼容规则解析,不改写历史值,也不回退到旧 GPT Image 2 路由;已退役的 `gpt-image-2-c` 已从代码整体删除,传入即按不支持的值处理。图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 +本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git;同时确认 VectorEngine 的 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 已配置,因为两个图片 client 都在 api-server 启动时构造,任一缺失都会阻止启动。VectorEngine 配置仍保留给 nanobanana 与 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。新生成任务使用业务模型 `gpt-image-2.5`,api-server 显式分派 `gpt-image-2.5-flare-c`(不带参考图的 generate)或 `gpt-image-2.5-sunburst-c`(edit 与带参考图的 generate,计价同步走编辑档);已持久化的 `gpt-image-2` 只在提交边界按兼容规则解析,不改写历史值,也不回退到旧 GPT Image 2 路由;已退役的 `gpt-image-2-c` 已从代码整体删除,传入即按不支持的值处理。图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 编辑器 ElevenLabs 音效生成只从服务端读取 `ELEVENLABS_BASE_URL`、`ELEVENLABS_API_KEY` 和 `ELEVENLABS_REQUEST_TIMEOUT_MS`,timeout 默认 `180000ms`;base URL 或 Key 缺失时失败关闭,不回退 Vidu。生产 API 与 external-generation worker 通过共享 API env 取得同一配置,模板见 `deploy/env/api-server.env.example`;Key 不得进入 Web/Vite 环境、命令参数、日志、fixture 或仓库。普通测试只使用 loopback mock,禁止把真实付费请求作为 T3 自动验收。 -- 2.52.0 From e3ba9865967cf710aa02e55b26a214129bb96a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:02:44 +0800 Subject: [PATCH 63/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20OpenAPI=20=E4=B8=AD?= =?UTF-8?q?=20model=20=E5=AD=97=E6=AE=B5=E4=B8=8E=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=E7=9A=84=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditorImageGenerationRequest.model 说明 kind=ui-design 与 publication-material 固定使用 gpt-image-2.5 并忽略传入 model,其余 kind 未传时默认 gpt-image-2.5 - EditorUiDesignAssetExtractionRequest.model 说明未传时默认 nanobanana,传 gpt-image-2.5 时按 GPT Image 2.5 编辑任务执行 --- docs/openapi/genarrative-external-v1.openapi.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index dece62ebb..3da686869 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3017,7 +3017,7 @@ }, "model": { "type": "string", - "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 仅作为历史值在新任务提交边界兼容解析;已退役的 gpt-image-2-c 不再接受,命中按不支持的值处理。未传时沿用编辑器默认。" + "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 仅作为历史值在新任务提交边界兼容解析;已退役的 gpt-image-2-c 不再接受,命中按不支持的值处理。kind=ui-design 与 kind=publication-material 固定使用 gpt-image-2.5,传入的 model 会被忽略;其余 kind 未传时默认 gpt-image-2.5。" }, "aspectRatio": { "type": "string", @@ -3488,7 +3488,7 @@ "model": { "type": "string", "default": "gemini-3.1-flash-image-preview", - "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 仅作为历史值兼容解析;已退役的 gpt-image-2-c 不再接受,命中按不支持的值处理。未传时默认使用 nanobanana。" + "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 仅作为历史值兼容解析;已退役的 gpt-image-2-c 不再接受,命中按不支持的值处理。未传时默认使用 nanobanana(gemini-3.1-flash-image-preview);传 gpt-image-2.5 时按 GPT Image 2.5 编辑任务执行。" }, "referenceImageSrcs": { "type": "array", -- 2.52.0 From 97b0a4b9b8b7699f6fe13e6db9335b86a7d37e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:09:17 +0800 Subject: [PATCH 64/80] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E4=BB=A5=E5=8F=8D=E6=98=A0=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E4=BB=8E=20"gpt-image-2"=20=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20"GPT=20Image=202.5"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/resourceCanvasFloatingDismiss.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index ce3da42c4..e7c37bf18 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -703,11 +703,11 @@ describe('快速编辑浮层的下拉弹层', () => { fireEvent.click( screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), ); - expect(screen.getByRole('button', { name: 'gpt-image-2' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'GPT Image 2.5' })).toBeTruthy(); // 点面板内、弹层外的提示词区域:只收弹层,不动面板本身。 fireEvent.click(screen.getByLabelText('快速编辑提示词')); - expect(screen.queryByRole('button', { name: 'gpt-image-2' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'GPT Image 2.5' })).toBeNull(); expect( screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), ).toBeTruthy(); @@ -739,10 +739,10 @@ describe('快速编辑浮层的下拉弹层', () => { fireEvent.click( screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), ); - expect(screen.getByRole('button', { name: 'gpt-image-2' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'GPT Image 2.5' })).toBeTruthy(); fireEvent.keyDown(document, { key: 'Escape' }); - expect(screen.queryByRole('button', { name: 'gpt-image-2' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'GPT Image 2.5' })).toBeNull(); }); }); -- 2.52.0 From 71a17d609668c2efeae90dc80fd23b15c7edd3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 11:46:43 +0800 Subject: [PATCH 65/80] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20platform-image=20moc?= =?UTF-8?q?k=20=E8=AF=B7=E6=B1=82=E4=BD=93=E8=AF=BB=E5=8F=96=E7=AB=9E?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nanobanana 生成用例的 mock server 改为复用同文件 read_http_request,按 Content-Length 读满请求体再断言,消除「只读到 \r\n\r\n 就断言头后 body」的竞态 - pitfalls 新增「测试内 mock HTTP server 必须按 Content-Length 读满请求体再断言」记录现象、原因与变异验证方式 --- docs/project-memory/shared-memory/pitfalls.md | 8 ++++++++ .../crates/platform-image/tests/image_provider.rs | 15 +-------------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 00a6556df..21be86be3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -7,6 +7,14 @@ - 处理:客户端画布图标生成只 trim 描述并以单项 `iconDescriptions` 原样提交,保留内部换行;面板与原生入口按 `200` 个 Unicode 码点校验并拒绝空白或超限输入,不静默截断、不机械拆条。服务端共用的提示词流程负责规范图、背景与排布要求;其它图片生成的 `32000` 字符上限保持不变。 - 验证:检查实际请求体与 trim 后的原文一致,并覆盖 `200/201` 码点、补充平面字符、换行和空白输入;不能只断言“请求长度未超限”。完整合同见 [画板图标素材生成入口设计](../../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。 +## 测试内 mock HTTP server 必须按 Content-Length 读满请求体再断言 + +- 现象:`platform-image` 的 `nanobanana_generate_content_posts_native_body_and_reads_inline_data` 在本机(Windows)稳定红、在 CI 上存在偶发红,断言 `request_text.contains("\"imageSize\":\"512\"")` 失败,看起来像实现没有发送请求体。 +- 原因:该 mock 用例自带一版「读到第一个 `\r\n\r\n` 就 break」的读循环,而 `\r\n\r\n` 只代表请求头结束;reqwest 写完头再写 body,TCP 分片下 body 常常还没到。断言实际只看到头和半截 body,是测试脚手架的竞态,与平台 / 网络栈无关,被测代码本身没有问题。 +- 处理:mock server 一律复用同文件已有的 `read_http_request`,先解析 `Content-Length`,读满 `header_end + content_length` 再断言;同文件的 `image_edit_retries_send_timeout_once_and_succeeds` 与 `image_provider_deadline_clips_stalled_attempt_and_prevents_retry` 早已使用该 helper。 +- 验证:把断言期望值临时改成错误值,用例必须失败(证明读到了真实 body);改回后期望连续重跑 8 次全绿,并跑 `cargo test -p platform-image` 全量(22 + 64 + 14 passed)。 +- 关联:`server-rs/crates/platform-image/tests/image_provider.rs`。 + ## 2026-09-21 不同渠道的包体在同一台设备安装会互相顶掉 - **现象**:在一台已经装了某个渠道 AGC 客户端的设备上安装另一个渠道的安装包,装完后旧客户端直接消失(安装目录被覆盖、卸载项被接管),更新端点、平台服务器与本地登录态一起换成新渠道的;两个渠道的客户端无法共存。 diff --git a/server-rs/crates/platform-image/tests/image_provider.rs b/server-rs/crates/platform-image/tests/image_provider.rs index 4ceea0692..be1401272 100644 --- a/server-rs/crates/platform-image/tests/image_provider.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -443,20 +443,7 @@ async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() { let Ok((mut stream, _)) = listener.accept().await else { return; }; - let mut request = Vec::new(); - let mut buffer = [0_u8; 4096]; - loop { - let Ok(read) = stream.read(&mut buffer).await else { - return; - }; - if read == 0 { - return; - } - request.extend_from_slice(&buffer[..read]); - if request.windows(4).any(|window| window == b"\r\n\r\n") { - break; - } - } + let request = read_http_request(&mut stream).await; let request_text = String::from_utf8_lossy(request.as_slice()); assert!( request_text.contains("/v1beta/models/gemini-3.1-flash-image-preview:generateContent") -- 2.52.0 From 46c8573b0e4ca5ccbb0efa34ee74ab3d5f765f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 11:47:15 +0800 Subject: [PATCH 66/80] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=A8=A1=E5=9E=8B=E6=B5=8B=E8=AF=95=E5=88=B0?= =?UTF-8?q?=20GPT=20Image=202.5=20=E9=87=8D=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 6 个 image-editor 测试文件的旧 gpt-image-2 断言改为 gpt-image-2.5 / GPT Image 2.5,覆盖图集导出、元数据弹窗、UI 素材提取模型菜单与生成/快速编辑 dialog 参数快照 - 工作流测试夹具与提取模型按钮改用 IMAGE_MODEL_GPT_IMAGE_2_5 常量,legacy 夹具保留并断言提交边界归一为当前业务模型 - decision-log 2026-09-21 条目补 platform-image 与前端全量测试验证结论 --- .../shared-memory/decision-log.md | 2 +- ...CanvasEditorGenerationIntegration.test.tsx | 8 +++--- .../ImageCanvasEditorView.test.tsx | 2 +- .../ImageCanvasExportModel.test.ts | 4 +-- .../ImageCanvasMetadataModalView.test.tsx | 4 +-- ...anvasUiAssetExtractionOverlayView.test.tsx | 10 ++++---- .../useImageCanvasGenerationWorkflow.test.tsx | 25 ++++++++++--------- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3be5f125f..1b4d7b8a9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7,7 +7,7 @@ - 边界:nanobanana 生成与编辑共用一个 concrete model,参考图不改变档位;不引入跨模型 fallback;历史持久化值与审计字符串不改写。 - 同批修复:`raw_image_edit` 发送前复用共享 `ensure_provider_matches_model` 白名单校验;`platform-image` 兜底 task id 前缀改为跟随实际 provider(`tiantoken-edit` / `tiantoken-nanobanana` 不再带 `vector-engine` 前缀);`resolve_image_provider` 增加 `gpt-image-2 → Tiantoken` 断言把历史值意图钉死;删除 `openai_image_generation.rs` 中仅测试使用的 `create_openai_image_edit` / `create_openai_image_edit_with_references`(测试改用 `..._and_model` 变体,保留空参考图本地校验覆盖);`normalize_editor_generation_options` 的具体 provider key 改用常量;External v1 OpenAPI 的 `model` 描述改为与实现一致(`kind=ui-design` / `publication-material` 固定 `gpt-image-2.5`,UI 素材提取未传默认 nanobanana)。 - 影响范围:`api-server`(`editor_generation_config`、`editor_project`、`editor_project_icon`、`character_visual_assets`、`editor_agent/tool`、`openai_image_generation`)、`platform-image`(`raw_image_edit`、`runtime/executor`、集成测试)、ADR、里程碑文档、External v1 OpenAPI 描述、`.codex/skills/gpt-image-2-apimart` 脚本与 SKILL.md(改用 `TIANTOKEN_*` 凭据 + concrete model)。 -- 验证:`cargo test -p api-server editor_generation_config`、`cargo test -p api-server editor_agent::tool`、`cargo test -p api-server`(1123 passed / 1 failed,失败项为 mock LLM 连接超时,单测隔离重跑通过)。 +- 验证:`cargo test -p api-server editor_generation_config`、`cargo test -p api-server editor_agent::tool`、`cargo test -p api-server`(1123 passed / 1 failed,失败项为 mock LLM 连接超时,单测隔离重跑通过);`cargo test -p platform-image`(22 + 64 + 14 passed);`npx vitest run` 全量 382 files / 4619 passed(6 个测试文件的旧 `gpt-image-2` 断言与 mock 请求体读取竞态同步修正);`npm run typecheck`、`npm run check:encoding`、`git diff --check` 通过。 - 关联文档:[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`](../plans/【里程碑】GPT%20Image%202.5%20provider边界重构-2026-09-18.md)。 ## 2026-09-21 图片 provider 白名单收紧,并整体删除 gpt-image-2-c diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 8f5033b45..7f9aee873 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -1527,7 +1527,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'spec', - model: 'gpt-image-2', + model: 'gpt-image-2.5', size: '2048x1152', aspectRatio: '16:9', imageSize: '2K', @@ -1872,7 +1872,7 @@ describe('ImageCanvasEditorView generation integration', () => { await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ - model: 'gpt-image-2', + model: 'gpt-image-2.5', aspectRatio: '2:3', imageSize: '2K', }), @@ -2315,7 +2315,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'spec', - model: 'gpt-image-2', + model: 'gpt-image-2.5', size: '2048x1152', aspectRatio: '16:9', imageSize: '2K', @@ -3894,7 +3894,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy(); expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy(); expect(within(metadataDialog).getByText('Model')).toBeTruthy(); - expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy(); + expect(within(metadataDialog).getByText('GPT Image 2.5')).toBeTruthy(); expect(within(metadataDialog).queryByText('Size')).toBeNull(); expect(within(metadataDialog).getByText('Resolution')).toBeTruthy(); expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 92b947c88..8665e391d 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1274,7 +1274,7 @@ describe('ImageCanvasEditorView', () => { fields: [{ title: '生成提示词', value: '明亮主视觉' }], references: [], }, - model: 'gpt-image-2', + model: 'GPT Image 2.5', task: '1', object: 'asset-object-visible', resolution: '1024 x 1024 px', diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 9a1e95e04..6c77f038a 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -187,7 +187,7 @@ describe('ImageCanvasExportModel', () => { }, ], }, - model: 'gpt-image-2', + model: 'GPT Image 2.5', task: '1', object: 'generated/layer.png', resolution: '1024 x 1024 px', @@ -268,7 +268,7 @@ describe('ImageCanvasExportModel', () => { ], references: [], }); - expect(metadata.visible.model).toBe('gpt-image-2'); + expect(metadata.visible.model).toBe('GPT Image 2.5'); }); it('builds a Spine JSON attachment timeline from image sequence frames', () => { diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index c108b87d8..95cba2b9d 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -81,7 +81,7 @@ describe('ImageCanvasMetadataModalView', () => { within(dialog).getByText('项目资源 · resource-reference'), ).toBeTruthy(); expect(within(dialog).getByText('Model')).toBeTruthy(); - expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); + expect(within(dialog).getByText('GPT Image 2.5')).toBeTruthy(); expect(within(dialog).getByText('1024 x 768 px')).toBeTruthy(); expect(within(dialog).queryByText('Provider')).toBeNull(); expect(within(dialog).queryByText(/VectorEngine/u)).toBeNull(); @@ -129,7 +129,7 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('角色抠图')).toBeTruthy(); expect(within(dialog).queryByText('处理模型')).toBeNull(); expect(within(dialog).getByText('Model')).toBeTruthy(); - expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); + expect(within(dialog).getByText('GPT Image 2.5')).toBeTruthy(); expect(within(dialog).queryByText('birefnet')).toBeNull(); expect(within(dialog).queryByText('Aliyun Matting')).toBeNull(); expect(within(dialog).queryByText('segment-common-image')).toBeNull(); diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx index 6220aacaa..bdc8f89f4 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel'; @@ -229,11 +229,11 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { fireEvent.click( within(screen.getByRole('menu', { name: '提取素材模型选项' })).getByRole( 'button', - { name: 'gpt-image-2' }, + { name: 'GPT Image 2.5' }, ), ); - expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); + expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2_5); }); it('disables extraction submission while reference uploads are pending', () => { @@ -298,12 +298,12 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { fireEvent.click( within(screen.getByRole('menu', { name: '提取素材模型选项' })).getByRole( 'button', - { name: 'gpt-image-2' }, + { name: 'GPT Image 2.5' }, ), ); expect(alertMock).toHaveBeenCalledWith( - '当前已有 5 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换', + '当前已有 5 张参考图,GPT Image 2.5 最多允许 4 张,请先删除多余参考图后再切换', ); expect(onModelChange).not.toHaveBeenCalled(); expect(screen.getByRole('menu', { name: '提取素材模型选项' })).toBeTruthy(); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 0bbdb6dcb..798c96edc 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -31,6 +31,7 @@ import { CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, DEFAULT_IMAGE_MODEL, IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, } from './ImageCanvasGenerationModel'; import { readPerfectPixelOperations, @@ -622,7 +623,7 @@ function GenerationWorkflowHarness({ status: 'idle', composerOpen: true, sourceLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', }) @@ -780,7 +781,7 @@ function GenerationWorkflowHarness({ status: 'idle', composerOpen: true, generatedLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', }) @@ -797,7 +798,7 @@ function GenerationWorkflowHarness({ status: 'generating', composerOpen: false, sourceLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '1:1', imageSize: '1K', }) @@ -1241,7 +1242,7 @@ function GenerationWorkflowHarness({