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 生成资源,但仍按生成资源元数据形状保存。 由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。
_Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果 _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 持久化和局部重生成的通用流水线。 一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
+1
View File
@@ -70,6 +70,7 @@
- [画板音乐生成入口](./【编辑器】画板音乐生成入口设计-2026-06-18.md):BGM/SFX 共享视图、独立业务规则和当前发布门禁。 - [画板音乐生成入口](./【编辑器】画板音乐生成入口设计-2026-06-18.md):BGM/SFX 共享视图、独立业务规则和当前发布门禁。
- [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md) - [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md)
- [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-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) - [编辑器模型定价配置](./【编辑器】模型定价配置管理方案-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-<时间戳>`)。 - 边界: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%。 - 验证:限速后 `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-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/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 引入全局字节类型迁移。 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 和可选透明背景后处理。 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。 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 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 本地磁盘静态资源兜底。 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 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 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 读取。 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` 鉴权时必须同时确认宿主已升级。 本地 `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 自动验收。 编辑器 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 "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": { "seedance2.0-fast": {
"unit": "perSecond", "unit": "perSecond",
"prices": { "prices": {
@@ -36,16 +36,16 @@ use crate::{
}, },
http_error::AppError, http_error::AppError,
openai_image_generation::{ openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiImageSettings, DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_GENERATION_MODEL,
build_openai_image_http_client, create_openai_image_generation, OpenAiImageSettings, build_openai_image_http_client,
require_openai_image_settings, create_openai_image_generation_with_model, require_openai_image_settings,
}, },
platform_errors::map_oss_error, platform_errors::map_oss_error,
request_context::RequestContext, request_context::RequestContext,
state::AppState, 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_ASSET_KIND: &str = "character_visual";
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character"; const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
const CHARACTER_VISUAL_SLOT: &str = "primary_visual"; 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 { fn resolve_character_visual_model(value: &str) -> String {
// 中文注释:旧前端和历史草稿可能仍传 wan2.7-image-proRPG 主图当前统一归一到 gpt-image-2 // 中文注释:旧前端和历史草稿可能仍传旧模型;只在新任务提交边界归一到当前业务模型
let trimmed = value.trim(); let trimmed = value.trim();
if !trimmed.is_empty() && trimmed != CHARACTER_VISUAL_MODEL { if !trimmed.is_empty() && trimmed != CHARACTER_VISUAL_MODEL {
tracing::warn!( tracing::warn!(
requested_model = trimmed, requested_model = trimmed,
effective_model = CHARACTER_VISUAL_MODEL, effective_model = CHARACTER_VISUAL_MODEL,
"角色主形象图片模型已归一到 gpt-image-2" "角色主形象图片模型已归一到当前业务模型"
); );
} }
CHARACTER_VISUAL_MODEL.to_string() CHARACTER_VISUAL_MODEL.to_string()
@@ -946,9 +946,10 @@ async fn create_character_visual_generation_once(
candidate_count: u32, candidate_count: u32,
reference_images: &[String], reference_images: &[String],
) -> Result<GeneratedCharacterVisuals, AppError> { ) -> Result<GeneratedCharacterVisuals, AppError> {
let generated = create_openai_image_generation( let generated = create_openai_image_generation_with_model(
http_client, http_client,
settings, settings,
GPT_IMAGE_2_5_GENERATION_MODEL,
prompt, prompt,
Some(build_character_visual_negative_prompt().as_str()), Some(build_character_visual_negative_prompt().as_str()),
size, size,
@@ -1921,12 +1922,12 @@ mod tests {
} }
#[test] #[test]
fn legacy_character_visual_model_normalizes_to_gpt_image_2() { fn legacy_character_visual_model_normalizes_to_gpt_image_2_5() {
assert_eq!( assert_eq!(
resolve_character_visual_model("wan2.7-image-pro"), 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] #[test]
+6 -15
View File
@@ -1428,18 +1428,15 @@ impl AppConfig {
} }
} }
/// Tiantoken 是图片、文本和旧版非 Suno 音频生成的新 provider /// Tiantoken 是 GPT Image 2.5 图片任务的独立 provider;凭证不得回退到 VectorEngine
///
/// 这里保留对 `AppConfig.vector_engine_*` 的回退,方便测试构造的旧配置继续工作;
/// 生产环境一旦设置了新的 `TIANTOKEN_*` 变量,就不会再把非 Suno 请求发往 VectorEngine。
pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String { pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String {
read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]) let _ = config;
.unwrap_or_else(|| config.vector_engine_base_url.clone()) read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]).unwrap_or_default()
} }
pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option<String> { pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option<String> {
let _ = config;
read_first_non_empty_env(&["TIANTOKEN_API_KEY"]) 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> { 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_BASE_URL");
std::env::remove_var("TIANTOKEN_API_KEY"); std::env::remove_var("TIANTOKEN_API_KEY");
} }
assert_eq!( assert_eq!(tiantoken_base_url(&config), "");
tiantoken_base_url(&config), assert_eq!(tiantoken_api_key(&config), None);
"https://vector.example.invalid"
);
assert_eq!(
tiantoken_api_key(&config).as_deref(),
Some("legacy-vector-key")
);
} }
#[test] #[test]
@@ -17,6 +17,10 @@ pub(crate) const EDITOR_GENERATION_PRICING_DEFAULT_JSON: &str =
include_str!("../config/editor-generation-pricing.default.json"); 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: &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: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2"; const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana"; const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
@@ -94,6 +98,25 @@ pub(crate) enum EditorGenerationPricingError {
} }
impl EditorGenerationPricingConfig { 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( pub(crate) fn image_model_mud_points(
&self, &self,
model: Option<&str>, 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 { 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() { if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return 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( read_tier_price(
&self.models, &self.models,
normalized_model, normalized_model,
EDITOR_IMAGE_MODEL_GPT_IMAGE_2, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION,
SPEC_IMAGE_PRICE_SIZE, SPEC_IMAGE_PRICE_SIZE,
SPEC_IMAGE_PRICE_SIZE, SPEC_IMAGE_PRICE_SIZE,
) )
@@ -221,7 +264,13 @@ impl EditorGenerationPricingConfig {
)?; )?;
validate_required_tier_prices( validate_required_tier_prices(
&self.models, &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, EditorGenerationPricingUnit::PerGeneration,
REQUIRED_GPT_IMAGE_SIZES, REQUIRED_GPT_IMAGE_SIZES,
)?; )?;
@@ -326,6 +375,7 @@ fn load_editor_generation_pricing_from_candidates(
serde_json::from_str::<EditorGenerationPricingConfig>(override_json.as_str()) serde_json::from_str::<EditorGenerationPricingConfig>(override_json.as_str())
.map_err(EditorGenerationPricingError::Json)?; .map_err(EditorGenerationPricingError::Json)?;
backfill_legacy_sfx_pricing(&mut override_config, &config, source.as_ref())?; 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 { override_config.validate().map_err(|error| match error {
EditorGenerationPricingError::Invalid(message) => { EditorGenerationPricingError::Invalid(message) => {
EditorGenerationPricingError::Invalid(format!("{source}: {message}")) EditorGenerationPricingError::Invalid(format!("{source}: {message}"))
@@ -338,6 +388,43 @@ fn load_editor_generation_pricing_from_candidates(
Ok(config) 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( fn backfill_legacy_sfx_pricing(
config: &mut EditorGenerationPricingConfig, config: &mut EditorGenerationPricingConfig,
fallback: &EditorGenerationPricingConfig, fallback: &EditorGenerationPricingConfig,
@@ -437,7 +524,13 @@ fn default_runtime_pricing() -> EditorGenerationPricingConfig {
fn normalize_editor_image_model(model: Option<&str>) -> &'static str { fn normalize_editor_image_model(model: Option<&str>) -> &'static str {
match model.map(str::trim).filter(|value| !value.is_empty()) { 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)
| Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS) | Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS)
| Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => EDITOR_IMAGE_MODEL_NANOBANANA2, | 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( fn normalize_editor_generation_image_price_size(
model: &str, model: &str,
image_size: Option<&str>, image_size: Option<&str>,
@@ -112,9 +112,10 @@ use crate::{
}, },
http_error::AppError, http_error::AppError,
openai_image_generation::{ openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, OpenAiImageSettings, DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL,
OpenAiReferenceImage, build_openai_image_http_client, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL,
create_openai_image_edit_with_references_and_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, create_openai_image_generation_with_model, create_openai_nanobanana_generate_content,
require_openai_image_settings, require_openai_image_settings,
}, },
@@ -374,6 +375,9 @@ pub(crate) struct EditorImagePromptBuildOutput {
pub(crate) struct EditorImageProviderRequest<'a> { pub(crate) struct EditorImageProviderRequest<'a> {
pub(crate) model: &'a str, 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) prompt: &'a str,
pub(crate) negative_prompt: Option<&'a str>, pub(crate) negative_prompt: Option<&'a str>,
pub(crate) size: &'a str, pub(crate) size: &'a str,
@@ -392,7 +396,7 @@ pub(crate) async fn request_editor_generated_images(
create_openai_nanobanana_generate_content( create_openai_nanobanana_generate_content(
http_client, http_client,
settings, settings,
request.model, request.provider_model,
request.prompt, request.prompt,
request.negative_prompt, request.negative_prompt,
request.aspect_ratio, request.aspect_ratio,
@@ -405,7 +409,7 @@ pub(crate) async fn request_editor_generated_images(
create_openai_image_generation_with_model( create_openai_image_generation_with_model(
http_client, http_client,
settings, settings,
request.model, request.provider_model,
request.prompt, request.prompt,
request.negative_prompt, request.negative_prompt,
request.size, request.size,
@@ -418,7 +422,7 @@ pub(crate) async fn request_editor_generated_images(
create_openai_image_edit_with_references_and_model( create_openai_image_edit_with_references_and_model(
http_client, http_client,
settings, settings,
request.model, request.provider_model,
request.prompt, request.prompt,
request.negative_prompt, request.negative_prompt,
request.size, request.size,
@@ -1926,7 +1930,10 @@ pub async fn get_editor_generation_pricing(
"message": error.to_string(), "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( 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")); matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options( let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation { if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL) Some(GPT_IMAGE_2_5_BUSINESS_NAME)
} else { } else {
payload.model.as_deref() 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")); matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options( let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation { if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL) Some(GPT_IMAGE_2_5_BUSINESS_NAME)
} else { } else {
payload.model.as_deref() payload.model.as_deref()
}, },
@@ -2987,7 +2994,7 @@ where
matches!(normalized_kind, Some("publication-material")); matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options( let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation { if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL) Some(GPT_IMAGE_2_5_BUSINESS_NAME)
} else { } else {
payload.model.as_deref() payload.model.as_deref()
}, },
@@ -3181,6 +3188,11 @@ where
&settings, &settings,
EditorImageProviderRequest { EditorImageProviderRequest {
model: generation_options.model, 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(), prompt: submitted_prompt.as_str(),
negative_prompt, negative_prompt,
size: provider_request_size.as_ref(), size: provider_request_size.as_ref(),
@@ -3832,7 +3844,7 @@ async fn resolve_editor_image_edit_price(
"message": error.to_string(), "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) Ok(expected_price_mud_points)
} }
@@ -3975,7 +3987,16 @@ pub(crate) fn normalize_editor_generation_options(
image_size: Option<&str>, image_size: Option<&str>,
) -> EditorGenerationOptions { ) -> EditorGenerationOptions {
let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) { 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( Some(
EDITOR_IMAGE_MODEL_NANOBANANA2 EDITOR_IMAGE_MODEL_NANOBANANA2
| EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS | EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS
@@ -3983,7 +4004,7 @@ pub(crate) fn normalize_editor_generation_options(
) => EDITOR_IMAGE_MODEL_NANOBANANA2, ) => EDITOR_IMAGE_MODEL_NANOBANANA2,
// 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2 // 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2
// 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。 // 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。
None => GPT_IMAGE_2_MODEL, None => GPT_IMAGE_2_5_BUSINESS_NAME,
_ => EDITOR_IMAGE_MODEL_NANOBANANA2, _ => EDITOR_IMAGE_MODEL_NANOBANANA2,
}; };
let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio); 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( create_openai_image_edit_with_references_and_model(
&http_client, &http_client,
&settings, &settings,
generation_options.model, GPT_IMAGE_2_5_EDIT_MODEL,
prompt.as_str(), prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、变形主体"), Some("文字、水印、边框、按钮、UI 控件、变形主体"),
provider_size.as_str(), 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( create_openai_image_edit_with_references_and_model(
&http_client, &http_client,
&settings, &settings,
generation_options.model, GPT_IMAGE_2_5_GENERATION_MODEL,
prompt.as_str(), prompt.as_str(),
None, None,
generation_options.provider_size.as_str(), generation_options.provider_size.as_str(),
@@ -17817,7 +17838,7 @@ mod tests {
#[test] #[test]
fn editor_generation_dimensions_follow_model_options() { fn editor_generation_dimensions_follow_model_options() {
let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K")); 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"); assert_eq!(default_generation.size, "1024x1024");
let nanobanana = normalize_editor_generation_options( let nanobanana = normalize_editor_generation_options(
@@ -17847,7 +17868,7 @@ mod tests {
assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9"); assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9");
let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K")); 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.size, "683x1024");
assert_eq!(gpt.provider_size, "688x1024"); assert_eq!(gpt.provider_size, "688x1024");
assert_eq!(gpt.aspect_ratio, "2:3"); assert_eq!(gpt.aspect_ratio, "2:3");
@@ -17862,21 +17883,21 @@ mod tests {
let gpt_cover = let gpt_cover =
normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K")); 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.size, "1024x768");
assert_eq!(gpt_cover.provider_size, "1024x768"); assert_eq!(gpt_cover.provider_size, "1024x768");
assert_eq!(gpt_cover.aspect_ratio, "4:3"); assert_eq!(gpt_cover.aspect_ratio, "4:3");
let gpt_landscape_2k = let gpt_landscape_2k =
normalize_editor_generation_options(Some("gpt-image-2"), Some("16:9"), Some("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.size, "2048x1152");
assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9"); assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9");
assert_eq!(gpt_landscape_2k.image_size, "2K"); assert_eq!(gpt_landscape_2k.image_size, "2K");
let gpt_portrait_2k = let gpt_portrait_2k =
normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("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.size, "1152x2048");
assert_eq!(gpt_portrait_2k.aspect_ratio, "9:16"); assert_eq!(gpt_portrait_2k.aspect_ratio, "9:16");
assert_eq!(gpt_portrait_2k.image_size, "2K"); assert_eq!(gpt_portrait_2k.image_size, "2K");
@@ -19989,7 +20010,7 @@ mod tests {
) )
.expect("UI extraction dimensions should pass"); .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!(generation_options.size, "2048x2048");
assert_eq!( assert_eq!(
resolve_editor_ui_design_asset_extraction_price( resolve_editor_ui_design_asset_extraction_price(
@@ -64,7 +64,7 @@ use crate::{
state::AppState, 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_ASPECT_RATIO: &str = "16:9";
const ICON_SPEC_IMAGE_SIZE: &str = "2K"; const ICON_SPEC_IMAGE_SIZE: &str = "2K";
const ICON_SPEC_SIZE: &str = "2048x1152"; const ICON_SPEC_SIZE: &str = "2048x1152";
@@ -1,17 +1,14 @@
use axum::http::StatusCode; use axum::http::StatusCode;
use platform_image::{ use platform_image::{
DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage, DownloadedImage, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings,
VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, NANOBANANA_2_MODEL, PlatformImageError, PlatformImageStatusHint, ReferenceImage,
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, VECTOR_ENGINE_PROVIDER, build_image_http_client, create_image_edit,
create_vector_engine_image_edit_with_references_and_model, create_image_edit_with_references, create_image_edit_with_references_and_model,
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, create_image_generation, create_image_generation_with_model,
create_vector_engine_nanobanana_generate_content, create_nanobanana_generate_content,
}; };
#[cfg(test)] #[cfg(test)]
use platform_image::{ use platform_image::{build_image_request_body, images_edit_url, images_generation_url};
build_vector_engine_image_request_body, vector_engine_images_edit_url,
vector_engine_images_generation_url,
};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::time::Instant; use std::time::Instant;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -27,9 +24,10 @@ use crate::{
tracking::record_external_generation_run_after_success, tracking::record_external_generation_run_after_success,
}; };
pub(crate) use platform_image::GPT_IMAGE_2_MODEL; pub(crate) use platform_image::{
#[cfg(test)] GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL,
use platform_image::VECTOR_ENGINE_GPT_IMAGE_2_MODEL; GPT_IMAGE_2_MODEL,
};
pub(crate) type OpenAiGeneratedImages = GeneratedImages; pub(crate) type OpenAiGeneratedImages = GeneratedImages;
pub(crate) type DownloadedOpenAiImage = DownloadedImage; 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_user_id: Option<String>,
pub external_api_audit_profile_id: Option<String>, pub external_api_audit_profile_id: Option<String>,
pub external_api_audit_request_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 { impl std::fmt::Debug for OpenAiImageSettings {
@@ -71,6 +71,11 @@ impl std::fmt::Debug for OpenAiImageSettings {
"external_api_audit_request_id", "external_api_audit_request_id",
&self.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() .finish()
} }
} }
@@ -110,18 +115,22 @@ pub(crate) fn require_openai_image_settings(
external_api_audit_user_id: None, external_api_audit_user_id: None,
external_api_audit_profile_id: None, external_api_audit_profile_id: None,
external_api_audit_request_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( pub(crate) fn build_openai_image_http_client(
settings: &OpenAiImageSettings, settings: &OpenAiImageSettings,
) -> Result<reqwest::Client, AppError> { ) -> Result<reqwest::Client, AppError> {
build_vector_engine_image_http_client(&settings.provider_settings()) if let Some(client) = settings.tiantoken_client.as_ref() {
.map_err(map_platform_image_error) 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( pub(crate) async fn create_openai_image_generation(
http_client: &reqwest::Client, _http_client: &reqwest::Client,
settings: &OpenAiImageSettings, settings: &OpenAiImageSettings,
prompt: &str, prompt: &str,
negative_prompt: Option<&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), "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(), "referenceImageCount": reference_images.len(),
}); });
let result = create_vector_engine_image_generation( let provider_client = settings.client_for_model(GPT_IMAGE_2_5_GENERATION_MODEL);
http_client, let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_GENERATION_MODEL);
&settings.provider_settings(), let result = create_image_generation(
provider_client.http_client(),
&provider_settings,
prompt, prompt,
negative_prompt, negative_prompt,
size, size,
@@ -162,7 +173,7 @@ pub(crate) async fn create_openai_image_generation(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) async fn create_openai_image_generation_with_model( pub(crate) async fn create_openai_image_generation_with_model(
http_client: &reqwest::Client, _http_client: &reqwest::Client,
settings: &OpenAiImageSettings, settings: &OpenAiImageSettings,
model: &str, model: &str,
prompt: &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), "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(), "referenceImageCount": reference_images.len(),
}); });
let result = create_vector_engine_image_generation_with_model( let provider_client = settings.client_for_model(model);
http_client, let provider_settings = settings.provider_settings_for_model(model);
&settings.provider_settings(), let result = create_image_generation_with_model(
provider_client.http_client(),
&provider_settings,
model, model,
prompt, prompt,
negative_prompt, negative_prompt,
@@ -206,7 +219,7 @@ pub(crate) async fn create_openai_image_generation_with_model(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) async fn create_openai_nanobanana_generate_content( pub(crate) async fn create_openai_nanobanana_generate_content(
http_client: &reqwest::Client, _http_client: &reqwest::Client,
settings: &OpenAiImageSettings, settings: &OpenAiImageSettings,
model: &str, model: &str,
prompt: &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), "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(), "referenceImageCount": reference_images.len(),
}); });
let result = create_vector_engine_nanobanana_generate_content( let provider_client = settings.client_for_model(model);
http_client, let provider_settings = settings.provider_settings_for_model(model);
&settings.provider_settings(), let result = create_nanobanana_generate_content(
provider_client.http_client(),
&provider_settings,
model, model,
prompt, prompt,
negative_prompt, negative_prompt,
@@ -249,7 +264,7 @@ pub(crate) async fn create_openai_nanobanana_generate_content(
} }
pub(crate) async fn create_openai_image_edit( pub(crate) async fn create_openai_image_edit(
http_client: &reqwest::Client, _http_client: &reqwest::Client,
settings: &OpenAiImageSettings, settings: &OpenAiImageSettings,
prompt: &str, prompt: &str,
negative_prompt: Option<&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), "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": 1, "referenceImageCount": 1,
}); });
let result = create_vector_engine_image_edit( let provider_client = settings.client_for_model(GPT_IMAGE_2_5_EDIT_MODEL);
http_client, let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_EDIT_MODEL);
&settings.provider_settings(), let result = create_image_edit(
provider_client.http_client(),
&provider_settings,
prompt, prompt,
negative_prompt, negative_prompt,
size, size,
@@ -286,7 +303,7 @@ pub(crate) async fn create_openai_image_edit(
} }
pub(crate) async fn create_openai_image_edit_with_references( pub(crate) async fn create_openai_image_edit_with_references(
http_client: &reqwest::Client, _http_client: &reqwest::Client,
settings: &OpenAiImageSettings, settings: &OpenAiImageSettings,
prompt: &str, prompt: &str,
negative_prompt: Option<&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), "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(), "referenceImageCount": reference_images.len(),
}); });
let result = create_vector_engine_image_edit_with_references( let provider_client = settings.client_for_model(GPT_IMAGE_2_5_EDIT_MODEL);
http_client, let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_EDIT_MODEL);
&settings.provider_settings(), let result = create_image_edit_with_references(
provider_client.http_client(),
&provider_settings,
prompt, prompt,
negative_prompt, negative_prompt,
size, size,
@@ -327,7 +346,7 @@ pub(crate) async fn create_openai_image_edit_with_references(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) async fn create_openai_image_edit_with_references_and_model( pub(crate) async fn create_openai_image_edit_with_references_and_model(
http_client: &reqwest::Client, _http_client: &reqwest::Client,
settings: &OpenAiImageSettings, settings: &OpenAiImageSettings,
model: &str, model: &str,
prompt: &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), "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(), "referenceImageCount": reference_images.len(),
}); });
let result = create_vector_engine_image_edit_with_references_and_model( let provider_client = settings.client_for_model(model);
http_client, let provider_settings = settings.provider_settings_for_model(model);
&settings.provider_settings(), let result = create_image_edit_with_references_and_model(
provider_client.http_client(),
&provider_settings,
model, model,
prompt, prompt,
negative_prompt, negative_prompt,
@@ -376,7 +397,7 @@ pub(crate) fn build_openai_image_request_body(
candidate_count: u32, candidate_count: u32,
reference_images: &[String], reference_images: &[String],
) -> Value { ) -> Value {
build_vector_engine_image_request_body( build_image_request_body(
prompt, prompt,
negative_prompt, negative_prompt,
size, size,
@@ -386,6 +407,24 @@ pub(crate) fn build_openai_image_request_body(
} }
impl OpenAiImageSettings { 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( pub(crate) fn with_external_api_audit_actor(
mut self, mut self,
user_id: Option<String>, user_id: Option<String>,
@@ -409,8 +448,9 @@ impl OpenAiImageSettings {
self self
} }
pub(crate) fn provider_settings(&self) -> VectorEngineImageSettings { pub(crate) fn provider_settings(&self) -> ImageProviderSettings {
VectorEngineImageSettings { ImageProviderSettings {
provider: ImageProvider::Tiantoken,
base_url: self.base_url.clone(), base_url: self.base_url.clone(),
api_key: self.api_key.clone(), api_key: self.api_key.clone(),
request_timeout_ms: self.request_timeout_ms.max(1), 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)] #[cfg(test)]
fn vector_engine_images_generation_url_for_test(settings: &OpenAiImageSettings) -> String { fn images_generation_url_for_test(settings: &OpenAiImageSettings) -> String {
vector_engine_images_generation_url(&settings.provider_settings()) images_generation_url(&settings.provider_settings())
} }
#[cfg(test)] #[cfg(test)]
fn vector_engine_images_edit_url_for_test(settings: &OpenAiImageSettings) -> String { fn images_edit_url_for_test(settings: &OpenAiImageSettings) -> String {
vector_engine_images_edit_url(&settings.provider_settings()) images_edit_url(&settings.provider_settings())
} }
#[cfg(test)] #[cfg(test)]
@@ -611,6 +651,8 @@ mod tests {
external_api_audit_user_id: None, external_api_audit_user_id: None,
external_api_audit_profile_id: None, external_api_audit_profile_id: None,
external_api_audit_request_id: None, external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
} }
.with_external_api_audit_context(&request_context, None, None); .with_external_api_audit_context(&request_context, None, None);
@@ -631,7 +673,7 @@ mod tests {
&["data:image/png;base64,abcd".to_string()], &["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["size"], "1536x1024");
assert_eq!(body["n"], 2); assert_eq!(body["n"], 2);
assert!(body.get("official_fallback").is_none()); assert!(body.get("official_fallback").is_none());
@@ -650,6 +692,8 @@ mod tests {
external_api_audit_user_id: None, external_api_audit_user_id: None,
external_api_audit_profile_id: None, external_api_audit_profile_id: None,
external_api_audit_request_id: None, external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
}; };
let v1_settings = OpenAiImageSettings { let v1_settings = OpenAiImageSettings {
base_url: "https://vector.example/v1".to_string(), base_url: "https://vector.example/v1".to_string(),
@@ -660,14 +704,16 @@ mod tests {
external_api_audit_user_id: None, external_api_audit_user_id: None,
external_api_audit_profile_id: None, external_api_audit_profile_id: None,
external_api_audit_request_id: None, external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
}; };
assert_eq!( assert_eq!(
vector_engine_images_generation_url_for_test(&root_settings), images_generation_url_for_test(&root_settings),
"https://vector.example/v1/images/generations" "https://vector.example/v1/images/generations"
); );
assert_eq!( assert_eq!(
vector_engine_images_generation_url_for_test(&v1_settings), images_generation_url_for_test(&v1_settings),
"https://vector.example/v1/images/generations" "https://vector.example/v1/images/generations"
); );
} }
@@ -683,6 +729,8 @@ mod tests {
external_api_audit_user_id: None, external_api_audit_user_id: None,
external_api_audit_profile_id: None, external_api_audit_profile_id: None,
external_api_audit_request_id: None, external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
}; };
let v1_settings = OpenAiImageSettings { let v1_settings = OpenAiImageSettings {
base_url: "https://vector.example/v1".to_string(), base_url: "https://vector.example/v1".to_string(),
@@ -693,14 +741,16 @@ mod tests {
external_api_audit_user_id: None, external_api_audit_user_id: None,
external_api_audit_profile_id: None, external_api_audit_profile_id: None,
external_api_audit_request_id: None, external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
}; };
assert_eq!( assert_eq!(
vector_engine_images_edit_url_for_test(&root_settings), images_edit_url_for_test(&root_settings),
"https://vector.example/v1/images/edits" "https://vector.example/v1/images/edits"
); );
assert_eq!( assert_eq!(
vector_engine_images_edit_url_for_test(&v1_settings), images_edit_url_for_test(&v1_settings),
"https://vector.example/v1/images/edits" "https://vector.example/v1/images/edits"
); );
} }
@@ -716,6 +766,8 @@ mod tests {
external_api_audit_user_id: None, external_api_audit_user_id: None,
external_api_audit_profile_id: None, external_api_audit_profile_id: None,
external_api_audit_request_id: None, external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
}; };
let http_client = reqwest::Client::new(); let http_client = reqwest::Client::new();
@@ -764,7 +816,7 @@ mod tests {
latency_ms: Some(321), latency_ms: Some(321),
prompt_chars: Some(42), prompt_chars: Some(42),
reference_image_count: Some(1), 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( let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(
&build_external_api_failure_draft_from_platform_image_audit(&audit), &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["retryable"], true);
assert_eq!(tracking.metadata["promptChars"], 42); assert_eq!(tracking.metadata["promptChars"], 42);
assert_eq!(tracking.metadata["referenceImageCount"], 1); assert_eq!(tracking.metadata["referenceImageCount"], 1);
assert_eq!( assert_eq!(tracking.metadata["imageModel"], GPT_IMAGE_2_MODEL);
tracking.metadata["imageModel"],
VECTOR_ENGINE_GPT_IMAGE_2_MODEL
);
} }
} }
+4 -4
View File
@@ -6,8 +6,8 @@ use axum::{
use bytes::Bytes; use bytes::Bytes;
use image::{ImageDecoder, ImageFormat, ImageReader}; use image::{ImageDecoder, ImageFormat, ImageReader};
use platform_image::{ use platform_image::{
GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, GPT_IMAGE_2_5_EDIT_MODEL, RAW_IMAGE_MAX_EDGE,
RawImageEditImage, RawImageEditOptions, create_vector_engine_raw_image_edit, RAW_IMAGE_MAX_PIXELS, RawImageEditImage, RawImageEditOptions, create_raw_image_edit,
validate_raw_image_edit_dimensions, validate_raw_image_edit_dimensions,
}; };
use serde::Serialize; 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 started_at_micros = (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64;
let operation = async move { let operation = async move {
let generated = match create_vector_engine_raw_image_edit( let generated = match create_raw_image_edit(
&http_client, &http_client,
&provider_settings, &provider_settings,
prepared.prompt.as_str(), prepared.prompt.as_str(),
@@ -572,7 +572,7 @@ async fn raw_image_edit_price(state: &AppState, width: u32, height: u32) -> Resu
.editor_generation_pricing() .editor_generation_pricing()
.await .await
.map(|pricing| { .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| { .map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ 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, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token, 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_llm::{LlmClient, LlmConfig, LlmError, LlmProvider, OpenAiChatTokenBudgetField};
use platform_matting::{MattingClient, MattingConfig}; use platform_matting::{MattingClient, MattingConfig};
use platform_oss::{OssClient, OssConfig, OssError}; use platform_oss::{OssClient, OssConfig, OssError};
@@ -306,6 +309,8 @@ pub struct AppStateInner {
/// 非 Suno 的文本、图片和旧版音频生成 provider 配置。 /// 非 Suno 的文本、图片和旧版音频生成 provider 配置。
tiantoken_base_url: String, tiantoken_base_url: String,
tiantoken_api_key: Option<String>, tiantoken_api_key: Option<String>,
vector_engine_image_client: ImageProviderClient,
tiantoken_image_client: ImageProviderClient,
matting_client: Option<MattingClient>, matting_client: Option<MattingClient>,
bgfilter_provider_http_client: reqwest::Client, bgfilter_provider_http_client: reqwest::Client,
bgfilter_worker_http_client: reqwest::Client, bgfilter_worker_http_client: reqwest::Client,
@@ -605,6 +610,18 @@ impl AppState {
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
let tiantoken_base_url = crate::config::tiantoken_base_url(&config); let tiantoken_base_url = crate::config::tiantoken_base_url(&config);
let tiantoken_api_key = crate::config::tiantoken_api_key(&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 llm_client = build_llm_client(&config)?;
let vector_engine_llm_client = build_vector_engine_llm_client( let vector_engine_llm_client = build_vector_engine_llm_client(
&config, &config,
@@ -688,6 +705,8 @@ impl AppState {
vector_engine_llm_client, vector_engine_llm_client,
tiantoken_base_url, tiantoken_base_url,
tiantoken_api_key, tiantoken_api_key,
vector_engine_image_client,
tiantoken_image_client,
matting_client, matting_client,
bgfilter_provider_http_client, bgfilter_provider_http_client,
bgfilter_worker_http_client, bgfilter_worker_http_client,
@@ -1598,6 +1617,14 @@ impl AppState {
self.tiantoken_api_key.as_deref() 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> { pub fn matting_client(&self) -> Option<&MattingClient> {
self.matting_client.as_ref() 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> { fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
let oss_fields = [ let oss_fields = [
("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()), ("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::prompt::{PENDING_USER_CONFIRMATION_MESSAGE, edit_image_tool_description};
use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::context::EditorToolContext;
use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; 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::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::error::Error; use std::error::Error;
@@ -33,7 +33,7 @@ impl Display for EditImageError {
EditImageError::InvalidModel(model) => { EditImageError::InvalidModel(model) => {
write!( write!(
f, 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, pub model: String,
} }
fn default_model_name() -> 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)] #[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 // TODO need to introduce size param, but that needs more metadata such as original image size, skip in this version
"model": { "model": {
"type": "string", "type": "string",
"enum": [GPT_IMAGE_2_MODEL], "enum": [GPT_IMAGE_2_5_BUSINESS_NAME],
"default": GPT_IMAGE_2_MODEL, "default": GPT_IMAGE_2_5_BUSINESS_NAME,
"description": format!("图片编辑固定使用{GPT_IMAGE_2_MODEL}") "description": format!("图片编辑固定使用{GPT_IMAGE_2_5_BUSINESS_NAME}")
} }
}, },
"required": ["object_image_id", "prompt"], "required": ["object_image_id", "prompt"],
@@ -155,7 +155,7 @@ pub struct EditorImageEditResult {
impl EditImageTool { impl EditImageTool {
/// Validate the semantic correctness of the arguments. /// Validate the semantic correctness of the arguments.
pub fn validate_args(&self, args: &EditImageToolArgs) -> Option<EditImageError> { 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())); return Some(EditImageError::InvalidModel(args.model.clone()));
} }
if args.prompt.trim().is_empty() { if args.prompt.trim().is_empty() {
@@ -210,7 +210,7 @@ mod tests {
"sourceType": "generated", "sourceType": "generated",
"prompt": "修改图片", "prompt": "修改图片",
"actualPrompt": "修改后的图片", "actualPrompt": "修改后的图片",
"model": "gpt-image-2", "model": "gpt-image-2.5",
"taskId": "task-2", "taskId": "task-2",
"resource": null, "resource": null,
"asset": null, "asset": null,
@@ -10,7 +10,7 @@ use crate::agent::tools::image_generation_options::{
validate_image_generation_options, validate_image_generation_options,
}; };
use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; 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::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::error::Error; use std::error::Error;
@@ -39,7 +39,7 @@ impl Display for GenerateIconSpritesheetError {
match self { match self {
Self::InvalidModel(model) => write!( Self::InvalidModel(model) => write!(
f, 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) => { Self::InvalidAspectRatio(aspect_ratio) => {
write!(f, "invalid aspect ratio: {aspect_ratio}") write!(f, "invalid aspect ratio: {aspect_ratio}")
@@ -8,7 +8,7 @@ use crate::agent::tools::image_generation_options::{
validate_image_generation_options, validate_image_generation_options,
}; };
use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; 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::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::error::Error; use std::error::Error;
@@ -33,11 +33,11 @@ impl Display for GenerateImageError {
match self { match self {
Self::InvalidModel(model) => write!( Self::InvalidModel(model) => write!(
f, 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!( Self::UnsupportedUiDesignModel(model) => write!(
f, 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) => { Self::InvalidAspectRatio(aspect_ratio) => {
write!(f, "invalid aspect ratio: {aspect_ratio}") write!(f, "invalid aspect ratio: {aspect_ratio}")
@@ -222,7 +222,7 @@ mod tests {
"sourceType": "generated", "sourceType": "generated",
"prompt": "生成一张图片", "prompt": "生成一张图片",
"actualPrompt": "生成一张清晰图片", "actualPrompt": "生成一张清晰图片",
"model": "gpt-image-2", "model": "gpt-image-2.5",
"taskId": "task-1", "taskId": "task-1",
"resource": null, "resource": null,
"asset": null, "asset": null,
@@ -12,7 +12,7 @@ use crate::agent::tools::image_generation_options::{
}; };
use crate::framework::tool::ToolFailureKind; use crate::framework::tool::ToolFailureKind;
use crate::framework::tool::{Tool, ToolFailure}; 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::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -34,7 +34,7 @@ pub struct GenerateUiDesignToolArgs {
} }
fn default_ui_design_model() -> String { fn default_ui_design_model() -> String {
GPT_IMAGE_2_MODEL.to_string() GPT_IMAGE_2_5_BUSINESS_NAME.to_string()
} }
impl Tool for GenerateUiDesignTool { impl Tool for GenerateUiDesignTool {
@@ -54,9 +54,9 @@ impl Tool for GenerateUiDesignTool {
"prompt": { "type": "string", "description": "完整 UI 画面、信息层级、视觉风格和构图描述。" }, "prompt": { "type": "string", "description": "完整 UI 画面、信息层级、视觉风格和构图描述。" },
"model": { "model": {
"type": "string", "type": "string",
"enum": [GPT_IMAGE_2_MODEL], "enum": [GPT_IMAGE_2_5_BUSINESS_NAME],
"default": GPT_IMAGE_2_MODEL, "default": GPT_IMAGE_2_5_BUSINESS_NAME,
"description": "UI 设计图固定使用 gpt-image-2。" "description": "UI 设计图固定使用 gpt-image-2.5"
}, },
"reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "image_id(s) for desc UI 风格或布局" }, "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "image_id(s) for desc UI 风格或布局" },
"aspect_ratio": image_aspect_ratio_parameter_schema(), "aspect_ratio": image_aspect_ratio_parameter_schema(),
@@ -95,7 +95,7 @@ impl Tool for GenerateUiDesignTool {
impl GenerateUiDesignTool { impl GenerateUiDesignTool {
pub fn validate_args(&self, args: &GenerateUiDesignToolArgs) -> Result<(), GenerateImageError> { 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( return Err(GenerateImageError::UnsupportedUiDesignModel(
args.model.clone(), args.model.clone(),
)); ));
@@ -165,11 +165,11 @@ mod tests {
assert_eq!( assert_eq!(
parameters["properties"]["model"]["enum"], parameters["properties"]["model"]["enum"],
json!([GPT_IMAGE_2_MODEL]) json!([GPT_IMAGE_2_5_BUSINESS_NAME])
); );
assert_eq!( assert_eq!(
parameters["properties"]["model"]["default"], parameters["properties"]["model"]["default"],
GPT_IMAGE_2_MODEL GPT_IMAGE_2_5_BUSINESS_NAME
); );
assert_eq!( assert_eq!(
parameters["properties"]["image_size"]["enum"], parameters["properties"]["image_size"]["enum"],
@@ -184,7 +184,10 @@ mod tests {
.as_array() .as_array()
.is_some_and(|required| required.contains(&json!("model"))) .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!( assert!(matches!(
tool.validate_args(&args(NANOBANANA_2_MODEL)), tool.validate_args(&args(NANOBANANA_2_MODEL)),
Err(GenerateImageError::UnsupportedUiDesignModel(model)) if model == NANOBANANA_2_MODEL Err(GenerateImageError::UnsupportedUiDesignModel(model)) if model == NANOBANANA_2_MODEL
@@ -198,7 +201,7 @@ mod tests {
})) }))
.expect("旧版 UI 设计参数应能反序列化"); .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()); 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 serde_json::{Value, json};
use std::error::Error; use std::error::Error;
use std::fmt::Display; use std::fmt::Display;
@@ -21,7 +21,7 @@ impl Display for ImageGenerationOptionsError {
match self { match self {
Self::InvalidModel(model) => write!( Self::InvalidModel(model) => write!(
f, 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!( Self::InvalidAspectRatio(aspect_ratio) => write!(
f, f,
@@ -75,9 +75,9 @@ pub fn validate_image_generation_options(
pub fn image_model_parameter_schema() -> Value { pub fn image_model_parameter_schema() -> Value {
json!({ json!({
"type": "string", "type": "string",
"enum": [NANOBANANA_2_MODEL, GPT_IMAGE_2_MODEL], "enum": [NANOBANANA_2_MODEL, GPT_IMAGE_2_5_BUSINESS_NAME],
"default": NANOBANANA_2_MODEL, "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", "type": "string",
"enum": NANOBANANA_2_IMAGE_SIZES, "enum": NANOBANANA_2_IMAGE_SIZES,
"default": DEFAULT_IMAGE_SIZE, "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", "type": "string",
"enum": GPT_IMAGE_2_IMAGE_SIZES, "enum": GPT_IMAGE_2_IMAGE_SIZES,
"default": DEFAULT_IMAGE_SIZE, "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 { pub fn image_model_size_constraint_schema() -> Value {
json!({ json!({
"if": { "if": {
"properties": { "model": { "const": GPT_IMAGE_2_MODEL } }, "properties": { "model": { "const": GPT_IMAGE_2_5_BUSINESS_NAME } },
// model 省略时运行时默认 nanobanana2,仍允许 0.5K。 // model 省略时运行时默认 nanobanana2,仍允许 0.5K。
"required": ["model"] "required": ["model"]
}, },
@@ -126,7 +126,7 @@ pub fn image_model_size_constraint_schema() -> Value {
fn supported_image_sizes(model: &str) -> Option<&'static [&'static str]> { fn supported_image_sizes(model: &str) -> Option<&'static [&'static str]> {
match model { match model {
NANOBANANA_2_MODEL => Some(NANOBANANA_2_IMAGE_SIZES), 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, _ => None,
} }
} }
@@ -150,14 +150,18 @@ mod tests {
} }
for image_size in GPT_IMAGE_2_IMAGE_SIZES { for image_size in GPT_IMAGE_2_IMAGE_SIZES {
assert!( assert!(
validate_image_generation_options(GPT_IMAGE_2_MODEL, aspect_ratio, image_size,) validate_image_generation_options(
.is_ok() GPT_IMAGE_2_5_BUSINESS_NAME,
aspect_ratio,
image_size,
)
.is_ok()
); );
} }
} }
assert!(matches!( 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 { .. }) Err(ImageGenerationOptionsError::InvalidImageSize { .. })
)); ));
assert!(matches!( assert!(matches!(
@@ -192,7 +196,7 @@ mod tests {
let model_size_constraint = image_model_size_constraint_schema(); let model_size_constraint = image_model_size_constraint_schema();
assert_eq!( assert_eq!(
model_size_constraint["if"]["properties"]["model"]["const"], 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!(model_size_constraint["if"]["required"], json!(["model"]));
assert_eq!( assert_eq!(
@@ -26,7 +26,7 @@ mod tests {
use super::generate_video::{GenerateVideoTool, GenerateVideoToolArgs}; use super::generate_video::{GenerateVideoTool, GenerateVideoToolArgs};
use crate::framework::tool::Tool; use crate::framework::tool::Tool;
use platform_audio::{ELEVENLABS_SOUND_EFFECT_MODEL, SUNO_DEFAULT_MODEL}; 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; use serde_json::json;
#[test] #[test]
@@ -69,11 +69,11 @@ mod tests {
assert_eq!(image.model, NANOBANANA_2_MODEL); assert_eq!(image.model, NANOBANANA_2_MODEL);
assert_eq!(image.aspect_ratio, "1:1"); assert_eq!(image.aspect_ratio, "1:1");
assert_eq!(image.image_size, "1K"); 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.model, NANOBANANA_2_MODEL);
assert_eq!(character.aspect_ratio, "1:1"); assert_eq!(character.aspect_ratio, "1:1");
assert_eq!(character.image_size, "1K"); 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.aspect_ratio, "1:1");
assert_eq!(ui_design.image_size, "1K"); assert_eq!(ui_design.image_size, "1K");
assert_eq!(icon.model, NANOBANANA_2_MODEL); 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(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!( assert!(matches!(
tool.validate_args(&args("unknown-image-model")), tool.validate_args(&args("unknown-image-model")),
Err(GenerateImageError::InvalidModel(_)) Err(GenerateImageError::InvalidModel(_))
)); ));
assert_eq!( assert_eq!(
tool.parameters()["properties"]["model"]["enum"], 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 { let ui_args = GenerateUiDesignToolArgs {
prompt: "生成游戏主界面".to_string(), 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()], reference_image_ids: vec![missing_image.clone()],
aspect_ratio: "16:9".to_string(), aspect_ratio: "16:9".to_string(),
image_size: "1K".to_string(), image_size: "1K".to_string(),
@@ -208,7 +211,7 @@ mod tests {
assert_eq!( assert_eq!(
edit["properties"]["model"]["enum"], edit["properties"]["model"]["enum"],
json!([GPT_IMAGE_2_MODEL]) json!([GPT_IMAGE_2_5_BUSINESS_NAME])
); );
assert_eq!( assert_eq!(
video["properties"]["duration_seconds"]["enum"], video["properties"]["duration_seconds"]["enum"],
@@ -229,7 +232,7 @@ mod tests {
for schema in [&image, &character, &icon] { for schema in [&image, &character, &icon] {
assert_eq!( assert_eq!(
schema["allOf"][0]["if"]["properties"]["model"]["const"], schema["allOf"][0]["if"]["properties"]["model"]["const"],
json!(GPT_IMAGE_2_MODEL) json!(GPT_IMAGE_2_5_BUSINESS_NAME)
); );
assert_eq!( assert_eq!(
schema["allOf"][0]["then"]["properties"]["image_size"]["enum"], 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_MODEL: &str = "gpt-image-2";
pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c"; 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 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 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_IMAGE_EDIT_MAX_REFERENCE_IMAGES: usize = 5;
pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14; pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14;
pub const GPT_IMAGE_2_MIN_PIXELS: u64 = 655_360; 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