Compare commits

...

5 Commits

Author SHA1 Message Date
k88936 1574f206a2 整理图片 provider 模块层次并拆出 raw edit
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m26s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m18s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m39s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m37s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m28s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m26s
Project CI / Frontend tests (pull_request) Failing after 5m49s
Project CI / Native shell tests (pull_request) Successful in 10m45s
将 raw image edit 提取到独立 raw_image_edit 模块

按 protocol、runtime、transport 目录收拢 image_provider 实现

同步后端架构与 raw edit 技术文档路径
2026-09-18 17:45:09 +08:00
k88936 1367e9c04e 兼容历史 GPT Image 2-c 提交模型
读取历史 gpt-image-2-c 时按当前 GPT Image 2.5 业务任务处理

保留历史字符串不改写并沿用新的生成与编辑定价路由
2026-09-18 16:50:10 +08:00
k88936 b294cbc3db 接入 GPT Image 2.5 双 provider 启动路由
启动时分别构造 VectorEngine 与 Tiantoken 图片 client

按生成、编辑和 nanobanana 模型选择具体 provider

迁移 api-server、编辑器 Agent 与 raw edit 调用方

拆分 GPT Image 2.5 生成与编辑定价并隐藏 provider 具体值
2026-09-18 16:28:45 +08:00
k88936 c34d24c2c8 重构图片 provider 中立执行层
将 platform-image 图片协议从 vector_engine 目录迁移到 image_provider

引入 ImageProviderClient 与 VectorEngine/Tiantoken provider 类型

按 concrete model 白名单拒绝未知模型并移除跨模型回退
2026-09-18 16:03:29 +08:00
k88936 0dbc34d13c 明确 GPT Image 2.5 provider 边界与路由计划
更新业务模型、具体模型与 provider client 领域术语

记录 Tiantoken/VectorEngine 双 client 启动约束

同步开发运维文档中的图片路由与环境变量要求
2026-09-18 16:01:32 +08:00
49 changed files with 1193 additions and 780 deletions
+29
View File
@@ -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 keyVectorEngine 与 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
+1
View File
@@ -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)
## 后端、运维与测试
@@ -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 定价管理是明确例外。
@@ -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 → Tiantokennanobanana → VectorEnginelegacy/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、调用方迁移四个局部提交边界回滚;不执行数据库历史迁移。
@@ -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 通过 Tiantokennanobanana 通过 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 modeladmin 定价界面可查看和编辑两个具体 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 门禁通过。
@@ -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=<n> 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)。
@@ -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<u8>` 契约,不因 raw-edit 引入全局字节类型迁移。
@@ -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 读取。
@@ -226,7 +226,7 @@ spacetime sql <database> "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 <version> && spacetime version use <version>`,升级后重启 `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 自动验收。
@@ -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": {
@@ -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-proRPG 主图当前统一归一到 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<GeneratedCharacterVisuals, AppError> {
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]
+6 -15
View File
@@ -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<String> {
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<String> {
@@ -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]
@@ -17,6 +17,10 @@ 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";
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 +98,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 +137,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 +264,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 +375,7 @@ fn load_editor_generation_pricing_from_candidates(
serde_json::from_str::<EditorGenerationPricingConfig>(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 +388,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 +524,13 @@ 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_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,
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 +538,19 @@ 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_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,
_ => normalize_editor_image_model(model),
}
}
fn normalize_editor_generation_image_price_size(
model: &str,
image_size: Option<&str>,
@@ -112,9 +112,10 @@ use crate::{
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, OpenAiImageSettings,
OpenAiReferenceImage, build_openai_image_http_client,
create_openai_image_edit_with_references_and_model,
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,
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,16 @@ 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_C_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 +4004,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 +6069,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 +8514,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 +17838,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 +17868,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 +17883,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 +20010,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(
@@ -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";
@@ -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<String>,
pub external_api_audit_profile_id: Option<String>,
pub external_api_audit_request_id: Option<String>,
pub tiantoken_client: Option<ImageProviderClient>,
pub vector_engine_client: Option<ImageProviderClient>,
}
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<reqwest::Client, AppError> {
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<String>,
@@ -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);
}
}
+4 -4
View File
@@ -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!({
+71
View File
@@ -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<String>,
vector_engine_image_client: ImageProviderClient,
tiantoken_image_client: ImageProviderClient,
matting_client: Option<MattingClient>,
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<String>,
request_timeout_ms: u64,
) -> Result<ImageProviderClient, AppStateInitError> {
#[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<Option<OssClient>, AppStateInitError> {
let oss_fields = [
("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()),
@@ -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<EditImageError> {
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,
@@ -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}")
@@ -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,
@@ -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());
}
}
@@ -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-previewuser may call it nanobanana2);也可选择 gpt-image-2。"
"description": "生图模型。默认 gemini-3.1-flash-image-previewuser 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、2Kgpt-image-2 仅支持 1K、2K;默认 1K。"
"description": "图片尺寸档位。nanobanana2 支持 0.5K、1K、2Kgpt-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!(
@@ -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"],
@@ -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;

Some files were not shown because too many files have changed in this diff Show More