Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3da0936c1f | |||
| c8311a47b0 | |||
| 1c1deff732 | |||
| 0459109897 | |||
| 6318baf7c6 | |||
| 28e6e67e98 | |||
| 4415723f7f | |||
| fa84a364d6 | |||
| bd0a06e3d3 | |||
| 939c35a9b9 | |||
| 42fdb57efe | |||
| 0c54cca81c | |||
| 1a2e60bf05 | |||
| 5ce267ae85 | |||
| 8924bf335e | |||
| ae257a6ff8 | |||
| ff4f42d2f2 | |||
| 2e2ed47bec | |||
| dedc58cce1 | |||
| c86ac04220 | |||
| 0817ea8158 | |||
| e393a803f2 | |||
| 89587c9e9e | |||
| 5cba2dbeb7 | |||
| d3407e8910 | |||
| 4888a0eb01 | |||
| 90b77d6bd0 | |||
| 1c950df116 | |||
| 6c33459b90 | |||
| 874d061cb9 | |||
| fa2b36654b | |||
| 345c9c3a99 | |||
| 99081455ee | |||
| 5b1b0ddb9a | |||
| f4315ffd23 | |||
| beb3c99240 | |||
| 1574f206a2 | |||
| 1367e9c04e | |||
| b294cbc3db | |||
| c34d24c2c8 | |||
| 0dbc34d13c |
@@ -102,7 +102,7 @@ Use OpenAPI as the final authority; these common values are a routing aid:
|
|||||||
|
|
||||||
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
||||||
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
||||||
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
|
- Image `model`: `gpt-image-2.5`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. Persisted `gpt-image-2` / `gpt-image-2-c` are legacy values resolved only when submitting a new task.
|
||||||
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
||||||
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
||||||
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
|
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: gpt-image-2-apimart
|
name: gpt-image-2-apimart
|
||||||
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow with gpt-image-2-c fallback. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
description: Generate or inspect project image assets through this repository's image workflow using the GPT Image 2.5 business model. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
||||||
---
|
---
|
||||||
|
|
||||||
# gpt-image-2 VectorEngine
|
# GPT Image 2.5 project image workflow
|
||||||
|
|
||||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine image path. Keep the product/price model identifier and primary provider request as `gpt-image-2`, then fall back once to `gpt-image-2-c` for eligible provider failures. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
Use this skill for project-local image asset generation that must match the repository's image request contract. Use the business model identifier `gpt-image-2.5`; provider concrete model routing is owned by `server-rs`, and this client must not perform a cross-model fallback. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Default body:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-image-2",
|
"model": "gpt-image-2.5",
|
||||||
"prompt": "<prompt>",
|
"prompt": "<prompt>",
|
||||||
"n": 1,
|
"n": 1,
|
||||||
"size": "1024x1024"
|
"size": "1024x1024"
|
||||||
@@ -58,14 +58,14 @@ Content-Type: multipart/form-data
|
|||||||
Multipart fields:
|
Multipart fields:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
model=gpt-image-2
|
model=gpt-image-2.5
|
||||||
prompt=<prompt>
|
prompt=<prompt>
|
||||||
n=1
|
n=1
|
||||||
size=1024x1024
|
size=1024x1024
|
||||||
image=@reference.png
|
image=@reference.png
|
||||||
```
|
```
|
||||||
|
|
||||||
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths prefer `gpt-image-2`; on an eligible upstream/model failure they retry with `gpt-image-2-c`. Do not fall back for authentication, local validation, request-budget exhaustion, uncertain send/connection failure, content-safety rejection, or a generated image URL download failure. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths send the business model `gpt-image-2.5`; provider routing and retry policy remain server-owned. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
||||||
|
|
||||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
|
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ const skillRoot = path.resolve(__dirname, '..');
|
|||||||
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
|
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
|
||||||
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
|
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
|
||||||
const defaultTimeoutMs = 1000000;
|
const defaultTimeoutMs = 1000000;
|
||||||
const preferredImageModel = 'gpt-image-2';
|
const preferredImageModel = 'gpt-image-2.5';
|
||||||
const fallbackImageModel = 'gpt-image-2-c';
|
|
||||||
|
|
||||||
const prompts = [
|
const prompts = [
|
||||||
{
|
{
|
||||||
@@ -256,41 +255,8 @@ async function fetchJson(url, options, timeoutMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldFallbackImageModel(error) {
|
|
||||||
const raw =
|
|
||||||
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
|
||||||
if (error?.vectorEngineResponseParse) {
|
|
||||||
return !containsContentRejection(raw);
|
|
||||||
}
|
|
||||||
const status = Number(error?.vectorEngineStatus || 0);
|
|
||||||
if (status === 408 || status >= 500) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (status === 429) {
|
|
||||||
return !containsContentRejection(raw);
|
|
||||||
}
|
|
||||||
const mentionsImageModel =
|
|
||||||
raw.includes('model') ||
|
|
||||||
raw.includes('模型') ||
|
|
||||||
raw.includes(preferredImageModel) ||
|
|
||||||
raw.includes(fallbackImageModel);
|
|
||||||
return (
|
|
||||||
[400, 404, 422].includes(status) &&
|
|
||||||
mentionsImageModel &&
|
|
||||||
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
|
|
||||||
raw,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function containsContentRejection(raw) {
|
|
||||||
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
|
|
||||||
raw,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestImagePayload(env, entry) {
|
async function requestImagePayload(env, entry) {
|
||||||
for (const model of [preferredImageModel, fallbackImageModel]) {
|
for (const model of [preferredImageModel]) {
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
model,
|
model,
|
||||||
prompt: buildPrompt(entry),
|
prompt: buildPrompt(entry),
|
||||||
@@ -322,12 +288,7 @@ async function requestImagePayload(env, entry) {
|
|||||||
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||||
throw error;
|
throw error;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
|
throw error;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
console.warn(
|
|
||||||
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(`VectorEngine returned no image for ${entry.id}`);
|
throw new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||||
@@ -408,7 +369,7 @@ if (dryRun) {
|
|||||||
requests: selectedPrompts.map((entry) => ({
|
requests: selectedPrompts.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
fallbackModel: fallbackImageModel,
|
fallbackModel: null,
|
||||||
body: {
|
body: {
|
||||||
model: preferredImageModel,
|
model: preferredImageModel,
|
||||||
prompt: buildPrompt(entry),
|
prompt: buildPrompt(entry),
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ const defaultOutDir = path.join(
|
|||||||
'puzzle-creation-templates',
|
'puzzle-creation-templates',
|
||||||
);
|
);
|
||||||
const defaultTimeoutMs = 1000000;
|
const defaultTimeoutMs = 1000000;
|
||||||
const preferredImageModel = 'gpt-image-2';
|
const preferredImageModel = 'gpt-image-2.5';
|
||||||
const fallbackImageModel = 'gpt-image-2-c';
|
|
||||||
|
|
||||||
const args = new Map();
|
const args = new Map();
|
||||||
for (let index = 2; index < process.argv.length; index += 1) {
|
for (let index = 2; index < process.argv.length; index += 1) {
|
||||||
@@ -226,41 +225,8 @@ async function fetchJson(url, options, timeoutMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldFallbackImageModel(error) {
|
|
||||||
const raw =
|
|
||||||
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
|
||||||
if (error?.vectorEngineResponseParse) {
|
|
||||||
return !containsContentRejection(raw);
|
|
||||||
}
|
|
||||||
const status = Number(error?.vectorEngineStatus || 0);
|
|
||||||
if (status === 408 || status >= 500) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (status === 429) {
|
|
||||||
return !containsContentRejection(raw);
|
|
||||||
}
|
|
||||||
const mentionsImageModel =
|
|
||||||
raw.includes('model') ||
|
|
||||||
raw.includes('模型') ||
|
|
||||||
raw.includes(preferredImageModel) ||
|
|
||||||
raw.includes(fallbackImageModel);
|
|
||||||
return (
|
|
||||||
[400, 404, 422].includes(status) &&
|
|
||||||
mentionsImageModel &&
|
|
||||||
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
|
|
||||||
raw,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function containsContentRejection(raw) {
|
|
||||||
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
|
|
||||||
raw,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestImagePayload(env, template) {
|
async function requestImagePayload(env, template) {
|
||||||
for (const model of [preferredImageModel, fallbackImageModel]) {
|
for (const model of [preferredImageModel]) {
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
model,
|
model,
|
||||||
prompt: buildPrompt(template),
|
prompt: buildPrompt(template),
|
||||||
@@ -294,12 +260,7 @@ async function requestImagePayload(env, template) {
|
|||||||
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||||
throw error;
|
throw error;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
|
throw error;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
console.warn(
|
|
||||||
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(`VectorEngine returned no image for ${template.id}`);
|
throw new Error(`VectorEngine returned no image for ${template.id}`);
|
||||||
@@ -384,7 +345,7 @@ if (dryRun) {
|
|||||||
requests: selectedTemplates.map((template) => ({
|
requests: selectedTemplates.map((template) => ({
|
||||||
id: template.id,
|
id: template.id,
|
||||||
title: template.title,
|
title: template.title,
|
||||||
fallbackModel: fallbackImageModel,
|
fallbackModel: null,
|
||||||
body: {
|
body: {
|
||||||
model: preferredImageModel,
|
model: preferredImageModel,
|
||||||
prompt: buildPrompt(template),
|
prompt: buildPrompt(template),
|
||||||
|
|||||||
+29
@@ -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 key;VectorEngine 与 Tiantoken client 共享图片协议执行器,不复制请求/响应业务逻辑。两套 required client 在 api-server 启动时构造。
|
||||||
|
_Avoid_: 在首次请求时才创建 client、在 provider client 中复制尺寸/重试/审计逻辑、跨 provider credential fallback
|
||||||
|
|
||||||
|
**历史模型值**:
|
||||||
|
已持久化的 `gpt-image-2` 或 `gpt-image-2-c` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。
|
||||||
|
_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端
|
||||||
|
|
||||||
|
**GPT Image 2.5 新生成展示名**:
|
||||||
|
`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。
|
||||||
|
_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文
|
||||||
|
|
||||||
**系列素材图集生成**:
|
**系列素材图集生成**:
|
||||||
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
|
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
|
||||||
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
|
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
|
||||||
|
|||||||
@@ -74,6 +74,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,19 @@
|
|||||||
|
# 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。
|
||||||
|
|
||||||
|
主站前端只把原本明确使用 GPT Image 2 的专用新任务改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为保持不变。画布参数或编辑布局读到 `gpt-image-2` / `gpt-image-2-c` 时,在使用端解析为 `gpt-image-2.5` 并触发参数迁移警告,原始资源、审计、metadata 和历史 fixture 不回写。AGC 现有资源生成界面与请求默认保持不变,不因本决策新增模型字段、选择器或尺寸行为。
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- 定价配置的活动 key 是两个具体 provider model;旧单 key 配置只允许受控 backfill,并留下兼容 TODO。
|
||||||
|
- 新任务的同模型重试固定使用 api-server dispatch 的具体 model,不切换到另一个 model。
|
||||||
|
- 两套 provider client 在 api-server 启动阶段同时构造;任一 required provider 配置缺失,启动失败而不是延迟到首次图片请求。
|
||||||
|
- provider routing 只依据 concrete model 的白名单;provider client 不复制共享协议执行逻辑。
|
||||||
|
- 公开资源、`generationInputs` 和普通前端契约不包含具体 provider key;admin 定价管理是明确例外。
|
||||||
@@ -3017,7 +3017,7 @@
|
|||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时沿用编辑器默认。"
|
"description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 / gpt-image-2-c 仅作为历史值在新任务提交边界兼容解析。未传时沿用编辑器默认。"
|
||||||
},
|
},
|
||||||
"aspectRatio": {
|
"aspectRatio": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -3033,7 +3033,7 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 gpt-image-2 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。"
|
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 GPT Image 2.5 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。"
|
||||||
},
|
},
|
||||||
"maxItems": 9
|
"maxItems": 9
|
||||||
},
|
},
|
||||||
@@ -3170,7 +3170,7 @@
|
|||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。"
|
"description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 / gpt-image-2-c 仅作为历史值兼容解析。"
|
||||||
},
|
},
|
||||||
"aspectRatio": {
|
"aspectRatio": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -3184,7 +3184,7 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
|
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
|
||||||
},
|
},
|
||||||
"maxItems": 8
|
"maxItems": 8
|
||||||
},
|
},
|
||||||
@@ -3373,7 +3373,7 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
|
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
|
||||||
},
|
},
|
||||||
"maxItems": 8
|
"maxItems": 8
|
||||||
},
|
},
|
||||||
@@ -3488,13 +3488,13 @@
|
|||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "gemini-3.1-flash-image-preview",
|
"default": "gemini-3.1-flash-image-preview",
|
||||||
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时默认使用 nanobanana。"
|
"description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 / gpt-image-2-c 仅作为历史值兼容解析。未传时默认使用 nanobanana。"
|
||||||
},
|
},
|
||||||
"referenceImageSrcs": {
|
"referenceImageSrcs": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。"
|
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。"
|
||||||
},
|
},
|
||||||
"maxItems": 5
|
"maxItems": 5
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# GPT Image 2.5 provider 边界重构实施计划
|
||||||
|
|
||||||
|
- Version: 3
|
||||||
|
- Status: active
|
||||||
|
- Date: 2026-09-18
|
||||||
|
- Parent Milestone: `docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`
|
||||||
|
|
||||||
|
## 实施边界
|
||||||
|
|
||||||
|
1. 先抽象 provider-neutral settings/client 与共享图片执行器接口;保留一套 body、multipart、尺寸、retry、响应和 audit 逻辑。
|
||||||
|
2. 在 api-server 配置/state 初始化阶段分别构造 VectorEngine 与 Tiantoken client;删除 Tiantoken 对 VectorEngine URL/key 的任何 fallback。
|
||||||
|
3. 在 platform-image 建立 concrete model 白名单路由:GPT Image 2.5 → Tiantoken,nanobanana → VectorEngine;legacy/unknown 按合同处理。
|
||||||
|
4. 重命名 provider-specific client/build/transport 符号,避免共享逻辑继续伪装成 `vector_engine_*`;仅保留确有 VectorEngine 语义的名称。
|
||||||
|
5. 迁移 api-server、Agent、raw edit、角色/图标/UI 入口和测试;核对 pricing/admin/public DTO 可见性。
|
||||||
|
6. 删除跨模型/跨 provider fallback 分支,保留同 concrete model retry。
|
||||||
|
|
||||||
|
## 前端同步边界(已确认)
|
||||||
|
|
||||||
|
1. 主站 `src/components/image-editor/` 中,凡是原本明确写死 `gpt-image-2` 的 GPT 专用新任务(快速编辑、UI 设计、宣发、规范及对应提交/锁定模型)统一改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为不变。
|
||||||
|
2. `normalizeEditorImageModel` 在编辑面板、画布生成参数和历史布局恢复的使用端把 `gpt-image-2` 与 `gpt-image-2-c` 解析为 `gpt-image-2.5`。该兼容命中必须触发既有参数回退警告;未知模型仍按原有无效参数处理。原始资源、审计、metadata 和历史 fixture 不回写。
|
||||||
|
3. 主站新请求的 `model` 字段只发送业务模型值或 nanobanana 业务值,不发送 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 等 concrete provider model。
|
||||||
|
4. AGC(`apps/ai-game-creator-shell/src` 及其 Tauri 本地资源生成链路)本里程碑不增加模型字段、选择器或尺寸行为;继续使用现有请求默认值。AGC 代码只在确属历史兼容读取/测试证明的位置保留旧值,不借本次同步引入新功能。
|
||||||
|
5. OpenAPI、External editor skill reference、现役脚本和当前技术方案中的“新任务使用 GPT Image 2 / 2-c fallback”改为 GPT Image 2.5 口径;静态已生成 manifest、历史审计和兼容 fixture 保持旧值并补充历史语义断言。
|
||||||
|
|
||||||
|
## 前端实现顺序
|
||||||
|
|
||||||
|
1. 先在 `ImageCanvasGenerationModel` 集中定义历史别名解析与迁移警告结果,保持 nanobanana 默认值、尺寸矩阵和可选模型顺序不变。
|
||||||
|
2. 迁移 `ImageCanvasGenerationDialogModel`、`ImageCanvasGenerationSubmissionModel`、生成工作流、快速编辑弹窗、规范/宣发面板的 GPT 专用默认、锁定值和提交 payload。
|
||||||
|
3. 为 `generationInputs` / 画布布局 / 资源历史恢复补齐旧值迁移警告测试;确认新请求字段为 `gpt-image-2.5`,而历史原文仍未被改写。
|
||||||
|
4. 更新 OpenAPI 与现役工具/专题文档,区分业务模型、concrete provider model、历史值和产品展示名;不得把 AGC 默认行为改成前端显式模型选择。
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
- `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`
|
||||||
|
- `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`
|
||||||
|
- 主站图片编辑定向测试:模型选项/展示名、GPT 专用提交值、历史 `gpt-image-2` 与 `gpt-image-2-c` 恢复及警告、nanobanana 默认回归。
|
||||||
|
- AGC 定向测试:确认未新增 `model` IPC 入参、未改变原有尺寸/UI 行为,现有资源生成请求继续依赖服务端默认。
|
||||||
|
- OpenAPI/External editor 契约测试:模型说明、参考图容量说明和新业务模型值一致,provider concrete model 不进入普通前端契约。
|
||||||
|
|
||||||
|
## 风险与回滚
|
||||||
|
|
||||||
|
- 风险:启动阶段依赖变化、历史任务兼容解析遗漏、nanobanana 被误路由到 Tiantoken、provider key 泄露到公开 DTO。
|
||||||
|
- 风险:主站把 nanobanana 的默认路径误改成 GPT Image 2.5,或把历史兼容值静默吞掉导致用户无法识别参数迁移。
|
||||||
|
- 回滚:以 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 通过 Tiantoken,nanobanana 通过 VectorEngine;路由依据 concrete model 严格白名单决定;两个 client 在 api-server 启动阶段构造。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- `platform-image`:provider-neutral 图片执行器、provider client 注入 seam、concrete model 路由和错误/审计 provider 标识。
|
||||||
|
- `api-server`:启动时构造 VectorEngine/Tiantoken 两个 client,分别读取各自环境变量;任务提交边界的历史模型兼容解析。
|
||||||
|
- 共享请求、multipart、尺寸、retry、响应和 audit 逻辑保持单一实现。
|
||||||
|
- Agent 与其它 server-side 图片调用方迁移到业务模型/concrete model 合同。
|
||||||
|
- 定价、公开 DTO、admin DTO 与 provider model 可见性保持既定 ADR 约束。
|
||||||
|
|
||||||
|
## 现役路由
|
||||||
|
|
||||||
|
| Concrete model | Provider client | 业务用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gpt-image-2.5-flare-c` | Tiantoken | Generate,包括带参考图的普通生成 |
|
||||||
|
| `gpt-image-2.5-sunburst-c` | Tiantoken | Edit,包括快速编辑、原位修改、raw edit |
|
||||||
|
| `gemini-3.1-flash-image-preview` | VectorEngine | nanobanana 生成/编辑能力 |
|
||||||
|
|
||||||
|
`gpt-image-2` 与 `gpt-image-2-c` 只保留为历史持久化/审计字符串;新任务不得 dispatch 到旧 GPT Image 2 路由。未知 model 直接拒绝。
|
||||||
|
|
||||||
|
## 必须成立的行为
|
||||||
|
|
||||||
|
1. api-server 启动时同时构造两个 required provider client;任一对应环境变量缺失,启动失败。
|
||||||
|
2. Tiantoken 只读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`;VectorEngine 只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,互不回退。
|
||||||
|
3. platform-image 根据 concrete model 选择已注入 client;共享执行器不复制 provider 协议逻辑。
|
||||||
|
4. 同 concrete model 可以 retry,但永不跨 concrete model 或跨 provider fallback。
|
||||||
|
5. 历史值读取不改写;新任务提交边界将旧值兼容为 GPT Image 2.5 业务任务。
|
||||||
|
6. 普通前端不接收 concrete provider model;admin 定价界面可查看和编辑两个具体 pricing key。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不复制两套完整图片 client。
|
||||||
|
- 不新增 GPT Image 2 现役 VectorEngine 路由。
|
||||||
|
- 不修改历史数据库记录或旧审计字符串。
|
||||||
|
- 不把 provider client 选择下沉给普通前端。
|
||||||
|
|
||||||
|
## 验收证据
|
||||||
|
|
||||||
|
- provider routing 单元测试覆盖 flare/sunburst/nanobanana/legacy/unknown。
|
||||||
|
- 启动配置测试证明两套 client 独立读取环境变量,缺失任一配置即失败且无 VectorEngine/Tiantoken 回退。
|
||||||
|
- 请求审计测试证明 provider 与 concrete model 正确记录。
|
||||||
|
- platform-image 与 Agent 定向测试通过。
|
||||||
|
- api-server 类型/编译检查、前端类型检查、编码/文档/diff 门禁通过。
|
||||||
@@ -8940,6 +8940,31 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
|||||||
- 验证:限速后 `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)。
|
||||||
|
|
||||||
|
## 2026-09-19 Tiantoken 凭据落到 AppConfig 字段并修复编辑器 LLM 测试夹具
|
||||||
|
|
||||||
|
- **决策**:Tiantoken 凭据仍然只从 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 读取,不回退 VectorEngine;读取点从 `AppState::new` 前移到 `AppConfig::from_env()`,落到 `AppConfig.tiantoken_base_url` / `AppConfig.tiantoken_api_key` 两个字段,与其它 provider 的配置形态一致,`AppState` 构造与测试构造复用同一条路径;删除 `config::tiantoken_base_url()` / `config::tiantoken_api_key()` 两个现读环境变量的访问器。
|
||||||
|
- **原因**:2026-09-18 的「双 provider 启动边界」改成现读环境变量后,`editor_background_music_prompt_assist`、`editor_sound_effect_prompt_assist`、`vector_engine_audio_generation/sound_effect_translation` 三处 mock LLM 夹具仍用 `AppConfig.vector_engine_*` 构造状态,28 条用例拿到 503 `editor_llm_unavailable`。改用进程环境变量做夹具会让并行用例互相踩 `TIANTOKEN_*`,因此把凭据落到 config 字段而不是在测试里 set_var。
|
||||||
|
- **边界**:ADR 的「不得回退 VectorEngine 或其 API key」仍然成立,`from_env` 只读 `TIANTOKEN_*`,并有用例固定该行为。
|
||||||
|
- **验证**:`cargo test -p api-server` 1060 passed / 0 failed / 6 ignored;`cargo fmt --all --check`、`npm run check:encoding`、`git diff --check` 通过。
|
||||||
|
- **关联文档**:[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。
|
||||||
|
|
||||||
## 2026-09-17 AGC 抠图接入本地资源编辑恢复闭环
|
## 2026-09-17 AGC 抠图接入本地资源编辑恢复闭环
|
||||||
|
|
||||||
- 背景:`agc_remove_background` 原先只提交 `/api/editor/images/background-removals` 并返回 `queued`,没有轮询远端任务、下载完成媒体或写入本地 manifest;BgFilter 已成功处理但 Agent 因此永远只能看到受理回执。
|
- 背景:`agc_remove_background` 原先只提交 `/api/editor/images/background-removals` 并返回 `queued`,没有轮询远端任务、下载完成媒体或写入本地 manifest;BgFilter 已成功处理但 Agent 因此永远只能看到受理回执。
|
||||||
|
|||||||
@@ -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 引入全局字节类型迁移。
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -228,7 +228,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": {
|
||||||
|
|||||||
@@ -5855,14 +5855,29 @@ mod tests {
|
|||||||
payload["models"]["gemini-3.1-flash-image-preview"]["prices"]["1K"],
|
payload["models"]["gemini-3.1-flash-image-preview"]["prices"]["1K"],
|
||||||
Value::Number(12.into())
|
Value::Number(12.into())
|
||||||
);
|
);
|
||||||
|
// 公开投影只暴露业务模型名 gpt-image-2.5(取生成档价格),
|
||||||
|
// 不外泄具体 provider key,也不再暴露历史 gpt-image-2。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["models"]["gpt-image-2"]["prices"]["1K"],
|
payload["models"]["gpt-image-2.5"]["prices"]["1K"],
|
||||||
Value::Number(3.into())
|
Value::Number(3.into())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["models"]["gpt-image-2"]["prices"]["2K"],
|
payload["models"]["gpt-image-2.5"]["prices"]["2K"],
|
||||||
Value::Number(5.into())
|
Value::Number(5.into())
|
||||||
);
|
);
|
||||||
|
let public_models = payload["models"]
|
||||||
|
.as_object()
|
||||||
|
.expect("公开定价必须暴露 models 对象");
|
||||||
|
for hidden_model in [
|
||||||
|
"gpt-image-2",
|
||||||
|
"gpt-image-2.5-flare-c",
|
||||||
|
"gpt-image-2.5-sunburst-c",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!public_models.contains_key(hidden_model),
|
||||||
|
"公开定价不应暴露 {hidden_model}"
|
||||||
|
);
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["models"]["audio1.0"]["price"],
|
payload["models"]["audio1.0"]["price"],
|
||||||
Value::Number(5.into())
|
Value::Number(5.into())
|
||||||
@@ -5908,10 +5923,14 @@ mod tests {
|
|||||||
"unit": "perGeneration",
|
"unit": "perGeneration",
|
||||||
"prices": { "0.5K": 9, "1K": 18, "2K": 36 }
|
"prices": { "0.5K": 9, "1K": 18, "2K": 36 }
|
||||||
},
|
},
|
||||||
"gpt-image-2": {
|
"gpt-image-2.5-flare-c": {
|
||||||
"unit": "perGeneration",
|
"unit": "perGeneration",
|
||||||
"prices": { "1K": 31, "2K": 62 }
|
"prices": { "1K": 31, "2K": 62 }
|
||||||
},
|
},
|
||||||
|
"gpt-image-2.5-sunburst-c": {
|
||||||
|
"unit": "perGeneration",
|
||||||
|
"prices": { "1K": 41, "2K": 82 }
|
||||||
|
},
|
||||||
"seedance2.0-fast": {
|
"seedance2.0-fast": {
|
||||||
"unit": "perSecond",
|
"unit": "perSecond",
|
||||||
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
|
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
|
||||||
@@ -5951,6 +5970,7 @@ mod tests {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let public_response = app
|
let public_response = app
|
||||||
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.uri("/api/editor/generation-pricing")
|
.uri("/api/editor/generation-pricing")
|
||||||
@@ -5968,10 +5988,24 @@ mod tests {
|
|||||||
let payload: Value =
|
let payload: Value =
|
||||||
serde_json::from_slice(&body).expect("public pricing payload should be json");
|
serde_json::from_slice(&body).expect("public pricing payload should be json");
|
||||||
|
|
||||||
|
// 公开投影按业务模型名暴露生成档价格;具体 provider key 不下发普通前端。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["models"]["gpt-image-2"]["prices"]["2K"],
|
payload["models"]["gpt-image-2.5"]["prices"]["2K"],
|
||||||
Value::Number(62.into())
|
Value::Number(62.into())
|
||||||
);
|
);
|
||||||
|
let public_models = payload["models"]
|
||||||
|
.as_object()
|
||||||
|
.expect("公开定价必须暴露 models 对象");
|
||||||
|
for hidden_model in [
|
||||||
|
"gpt-image-2",
|
||||||
|
"gpt-image-2.5-flare-c",
|
||||||
|
"gpt-image-2.5-sunburst-c",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!public_models.contains_key(hidden_model),
|
||||||
|
"公开定价不应暴露 {hidden_model}"
|
||||||
|
);
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["models"]["audio1.0"]["unit"],
|
payload["models"]["audio1.0"]["unit"],
|
||||||
Value::String("perGeneration".to_string())
|
Value::String("perGeneration".to_string())
|
||||||
@@ -5984,6 +6018,35 @@ mod tests {
|
|||||||
payload["models"]["seedance2.0"]["prices"]["720p"],
|
payload["models"]["seedance2.0"]["prices"]["720p"],
|
||||||
Value::Number(26.into())
|
Value::Number(26.into())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// admin 定价接口是 concrete provider key 的明确例外,读回的是保存时的原始 key。
|
||||||
|
let admin_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/admin/api/editor-generation-pricing")
|
||||||
|
.header("authorization", format!("Bearer {admin_token}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("admin pricing request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("admin pricing request should succeed");
|
||||||
|
assert_eq!(admin_response.status(), StatusCode::OK);
|
||||||
|
let admin_body = admin_response
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("admin pricing body should collect")
|
||||||
|
.to_bytes();
|
||||||
|
let admin_payload: Value =
|
||||||
|
serde_json::from_slice(&admin_body).expect("admin pricing payload should be json");
|
||||||
|
assert_eq!(
|
||||||
|
admin_payload["models"]["gpt-image-2.5-flare-c"]["prices"]["2K"],
|
||||||
|
Value::Number(62.into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
admin_payload["models"]["gpt-image-2.5-sunburst-c"]["prices"]["2K"],
|
||||||
|
Value::Number(82.into())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。
|
/// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。
|
||||||
|
|||||||
@@ -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_EDIT_MODEL,
|
||||||
build_openai_image_http_client, create_openai_image_generation,
|
GPT_IMAGE_2_5_GENERATION_MODEL, 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-pro;RPG 主图当前统一归一到 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,16 @@ 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 provider_model = if reference_images.is_empty() {
|
||||||
|
GPT_IMAGE_2_5_GENERATION_MODEL
|
||||||
|
} else {
|
||||||
|
GPT_IMAGE_2_5_EDIT_MODEL
|
||||||
|
};
|
||||||
|
let generated = create_openai_image_generation_with_model(
|
||||||
http_client,
|
http_client,
|
||||||
settings,
|
settings,
|
||||||
|
provider_model,
|
||||||
prompt,
|
prompt,
|
||||||
Some(build_character_visual_negative_prompt().as_str()),
|
Some(build_character_visual_negative_prompt().as_str()),
|
||||||
size,
|
size,
|
||||||
@@ -1921,12 +1928,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]
|
||||||
|
|||||||
@@ -213,6 +213,12 @@ pub struct AppConfig {
|
|||||||
pub vector_engine_api_key: Option<String>,
|
pub vector_engine_api_key: Option<String>,
|
||||||
pub vector_engine_image_request_timeout_ms: u64,
|
pub vector_engine_image_request_timeout_ms: u64,
|
||||||
pub vector_engine_audio_request_timeout_ms: u64,
|
pub vector_engine_audio_request_timeout_ms: u64,
|
||||||
|
/// Tiantoken 是 GPT Image 2.5 图片链路和创意 Agent 文本链路的 provider。
|
||||||
|
///
|
||||||
|
/// 只从 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 读取,刻意不回退 VectorEngine 凭据;
|
||||||
|
/// 启动时落到 `AppConfig` 字段,使 `AppState` 构造与测试构造复用同一条路径。
|
||||||
|
pub tiantoken_base_url: String,
|
||||||
|
pub tiantoken_api_key: Option<String>,
|
||||||
pub elevenlabs_base_url: String,
|
pub elevenlabs_base_url: String,
|
||||||
pub elevenlabs_api_key: Option<String>,
|
pub elevenlabs_api_key: Option<String>,
|
||||||
pub elevenlabs_request_timeout_ms: u64,
|
pub elevenlabs_request_timeout_ms: u64,
|
||||||
@@ -523,6 +529,8 @@ impl Default for AppConfig {
|
|||||||
vector_engine_api_key: None,
|
vector_engine_api_key: None,
|
||||||
vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
|
vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
|
||||||
vector_engine_audio_request_timeout_ms: 180_000,
|
vector_engine_audio_request_timeout_ms: 180_000,
|
||||||
|
tiantoken_base_url: String::new(),
|
||||||
|
tiantoken_api_key: None,
|
||||||
elevenlabs_base_url: String::new(),
|
elevenlabs_base_url: String::new(),
|
||||||
elevenlabs_api_key: None,
|
elevenlabs_api_key: None,
|
||||||
elevenlabs_request_timeout_ms: DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS,
|
elevenlabs_request_timeout_ms: DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS,
|
||||||
@@ -1308,6 +1316,12 @@ impl AppConfig {
|
|||||||
|
|
||||||
config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]);
|
config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]);
|
||||||
|
|
||||||
|
// Tiantoken 只读取自己的环境变量,不从 VectorEngine 配置回退;启动时冻结进 AppConfig,
|
||||||
|
// 供 AppState 构造编辑器 LLM / 图片 client 时复用同一条配置路径。
|
||||||
|
config.tiantoken_base_url =
|
||||||
|
read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]).unwrap_or_default();
|
||||||
|
config.tiantoken_api_key = read_first_non_empty_env(&["TIANTOKEN_API_KEY"]);
|
||||||
|
|
||||||
if let Some(tiantoken_image_request_timeout_ms) = read_first_positive_u64_env(&[
|
if let Some(tiantoken_image_request_timeout_ms) = read_first_positive_u64_env(&[
|
||||||
"TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS",
|
"TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS",
|
||||||
"VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS",
|
"VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS",
|
||||||
@@ -1459,20 +1473,6 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tiantoken 是图片、文本和旧版非 Suno 音频生成的新 provider。
|
|
||||||
///
|
|
||||||
/// 这里保留对 `AppConfig.vector_engine_*` 的回退,方便测试构造的旧配置继续工作;
|
|
||||||
/// 生产环境一旦设置了新的 `TIANTOKEN_*` 变量,就不会再把非 Suno 请求发往 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())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option<String> {
|
|
||||||
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> {
|
||||||
keys.iter().find_map(|key| {
|
keys.iter().find_map(|key| {
|
||||||
env::var(key).ok().and_then(|value| {
|
env::var(key).ok().and_then(|value| {
|
||||||
@@ -1778,7 +1778,6 @@ mod tests {
|
|||||||
DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS,
|
DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS,
|
||||||
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode,
|
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode,
|
||||||
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
|
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
|
||||||
tiantoken_api_key, tiantoken_base_url,
|
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
@@ -1872,37 +1871,52 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tiantoken_provider_prefers_new_env_names_over_legacy_vector_engine_config() {
|
fn tiantoken_credentials_never_fall_back_to_vector_engine_env() {
|
||||||
let _guard = ENV_LOCK
|
let _guard = ENV_LOCK
|
||||||
.get_or_init(|| Mutex::new(()))
|
.get_or_init(|| Mutex::new(()))
|
||||||
.lock()
|
.lock()
|
||||||
.expect("env lock should not poison");
|
.expect("env lock should not poison");
|
||||||
let mut config = AppConfig::default();
|
|
||||||
config.vector_engine_base_url = "https://vector.example.invalid".to_string();
|
|
||||||
config.vector_engine_api_key = Some("legacy-vector-key".to_string());
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("TIANTOKEN_BASE_URL", " https://api.tiantoken.example/ ");
|
std::env::set_var("TIANTOKEN_BASE_URL", " https://api.tiantoken.example/ ");
|
||||||
std::env::set_var("TIANTOKEN_API_KEY", " tiantoken-key ");
|
std::env::set_var("TIANTOKEN_API_KEY", " tiantoken-key ");
|
||||||
|
std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.example.invalid");
|
||||||
|
std::env::set_var("VECTOR_ENGINE_API_KEY", "legacy-vector-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
|
||||||
|
assert_eq!(config.tiantoken_base_url, "https://api.tiantoken.example/");
|
||||||
|
assert_eq!(config.tiantoken_api_key.as_deref(), Some("tiantoken-key"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tiantoken_base_url(&config),
|
config.vector_engine_base_url,
|
||||||
"https://api.tiantoken.example/"
|
"https://vector.example.invalid"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.vector_engine_api_key.as_deref(),
|
||||||
|
Some("legacy-vector-key")
|
||||||
);
|
);
|
||||||
assert_eq!(tiantoken_api_key(&config).as_deref(), Some("tiantoken-key"));
|
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
|
||||||
|
assert_eq!(config.tiantoken_base_url, "");
|
||||||
|
assert_eq!(config.tiantoken_api_key, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tiantoken_base_url(&config),
|
config.vector_engine_base_url,
|
||||||
"https://vector.example.invalid"
|
"https://vector.example.invalid"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tiantoken_api_key(&config).as_deref(),
|
config.vector_engine_api_key.as_deref(),
|
||||||
Some("legacy-vector-key")
|
Some("legacy-vector-key")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
|
||||||
|
std::env::remove_var("VECTOR_ENGINE_API_KEY");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use platform_editor_agent::agent::tools::generate_video::{
|
|||||||
};
|
};
|
||||||
use platform_editor_agent::framework::error::PromptError;
|
use platform_editor_agent::framework::error::PromptError;
|
||||||
use platform_editor_agent::framework::tool::{Tool, ToolDyn, null_tool_args_as_missing};
|
use platform_editor_agent::framework::tool::{Tool, ToolDyn, null_tool_args_as_missing};
|
||||||
use platform_image::GPT_IMAGE_2_MODEL;
|
use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -224,6 +224,45 @@ fn parse_internal<T: DeserializeOwned>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 把持久化的历史 GPT Image 2 模型别名(`gpt-image-2` / `gpt-image-2-c`)改写为当前
|
||||||
|
/// 业务模型 `gpt-image-2.5`。
|
||||||
|
///
|
||||||
|
/// 平台层图片工具只接受 `gemini-3.1-flash-image-preview` 与 `gpt-image-2.5`,但历史会话
|
||||||
|
/// 里仍可能保存着 `gpt-image-2` 的工具调用参数。这里在进入强类型校验前做一次别名归一,
|
||||||
|
/// 既让旧调用继续可用,也把计价统一到当前业务模型档位。
|
||||||
|
fn normalize_legacy_image_model_args(value: &Value) -> Value {
|
||||||
|
let Some(model) = value.get("model").and_then(Value::as_str) else {
|
||||||
|
return value.clone();
|
||||||
|
};
|
||||||
|
if !matches!(model.trim(), GPT_IMAGE_2_MODEL | GPT_IMAGE_2_C_MODEL) {
|
||||||
|
return value.clone();
|
||||||
|
}
|
||||||
|
let mut normalized = value.clone();
|
||||||
|
if let Some(fields) = normalized.as_object_mut() {
|
||||||
|
fields.insert(
|
||||||
|
"model".to_string(),
|
||||||
|
Value::String(GPT_IMAGE_2_5_BUSINESS_NAME.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图片类工具的 `validate_args` 入口:先归一历史模型别名,再走强类型反序列化。
|
||||||
|
fn parse_invalid_image_args<T: DeserializeOwned>(
|
||||||
|
tool_name: &str,
|
||||||
|
value: &Value,
|
||||||
|
) -> Result<T, EditorAgentToolError> {
|
||||||
|
parse_invalid_args(tool_name, &normalize_legacy_image_model_args(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图片类工具的 `pricing` 入口:即使调用方直接传入原始 args,也要先归一历史模型别名。
|
||||||
|
fn parse_internal_image_args<T: DeserializeOwned>(
|
||||||
|
label: &str,
|
||||||
|
value: &Value,
|
||||||
|
) -> Result<T, EditorAgentToolError> {
|
||||||
|
parse_internal(label, &normalize_legacy_image_model_args(value))
|
||||||
|
}
|
||||||
|
|
||||||
/// SFX V2 的显式 `duration: null` 表示自动时长,必须原样保留,不能被通用的
|
/// SFX V2 的显式 `duration: null` 表示自动时长,必须原样保留,不能被通用的
|
||||||
/// “顶层 null 当缺省”兼容层恢复成手动 5 秒。但其余字段仍要走该兼容层:
|
/// “顶层 null 当缺省”兼容层恢复成手动 5 秒。但其余字段仍要走该兼容层:
|
||||||
/// `model` 是无 Option 的 String,LLM 传 `model: null` 时若不剥离会直接反序列化失败。
|
/// `model` 是无 Option 的 String,LLM 传 `model: null` 时若不剥离会直接反序列化失败。
|
||||||
@@ -311,7 +350,7 @@ fn editor_agent_image_mud_points(
|
|||||||
|
|
||||||
impl EditorAgentTool for GenerateImageTool {
|
impl EditorAgentTool for GenerateImageTool {
|
||||||
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
|
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
|
||||||
let args: GenerateImageToolArgs = parse_invalid_args(Self::NAME, args)?;
|
let args: GenerateImageToolArgs = parse_invalid_image_args(Self::NAME, args)?;
|
||||||
GenerateImageTool::validate_args(self, &args)
|
GenerateImageTool::validate_args(self, &args)
|
||||||
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
|
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
|
||||||
serialize_normalized_args(Self::NAME, &args)
|
serialize_normalized_args(Self::NAME, &args)
|
||||||
@@ -322,7 +361,7 @@ impl EditorAgentTool for GenerateImageTool {
|
|||||||
pricing: &EditorGenerationPricingConfig,
|
pricing: &EditorGenerationPricingConfig,
|
||||||
args: &Value,
|
args: &Value,
|
||||||
) -> Result<u32, EditorAgentToolError> {
|
) -> Result<u32, EditorAgentToolError> {
|
||||||
let args: GenerateImageToolArgs = parse_internal("generate image args", args)?;
|
let args: GenerateImageToolArgs = parse_internal_image_args("generate image args", args)?;
|
||||||
Ok(editor_agent_image_mud_points(
|
Ok(editor_agent_image_mud_points(
|
||||||
pricing,
|
pricing,
|
||||||
None,
|
None,
|
||||||
@@ -337,7 +376,8 @@ impl EditorAgentTool for GenerateImageTool {
|
|||||||
pricing: &EditorGenerationPricingConfig,
|
pricing: &EditorGenerationPricingConfig,
|
||||||
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
|
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
|
||||||
let price_mud_points = self.pricing(pricing, args)?;
|
let price_mud_points = self.pricing(pricing, args)?;
|
||||||
let args: GenerateImageToolArgs = parse_internal("generate image display args", args)?;
|
let args: GenerateImageToolArgs =
|
||||||
|
parse_internal_image_args("generate image display args", args)?;
|
||||||
let mut display_args = EditorAgentToolCallDisplayArgs::default();
|
let mut display_args = EditorAgentToolCallDisplayArgs::default();
|
||||||
push_image_generation_display_args(
|
push_image_generation_display_args(
|
||||||
&mut display_args,
|
&mut display_args,
|
||||||
@@ -358,7 +398,7 @@ impl EditorAgentTool for GenerateImageTool {
|
|||||||
context: &EditorAgentPrepareJobContext<'_>,
|
context: &EditorAgentPrepareJobContext<'_>,
|
||||||
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
|
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
|
||||||
let price_mud_points = self.pricing(context.pricing, args)?;
|
let price_mud_points = self.pricing(context.pricing, args)?;
|
||||||
let args: GenerateImageToolArgs = parse_invalid_args(Self::NAME, args)?;
|
let args: GenerateImageToolArgs = parse_invalid_image_args(Self::NAME, args)?;
|
||||||
let title = args.prompt.clone();
|
let title = args.prompt.clone();
|
||||||
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
|
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
|
||||||
let payload = EditorImageGenerationRequest {
|
let payload = EditorImageGenerationRequest {
|
||||||
@@ -397,7 +437,7 @@ impl EditorAgentTool for GenerateImageTool {
|
|||||||
args: &Value,
|
args: &Value,
|
||||||
result: &Value,
|
result: &Value,
|
||||||
) -> Result<String, EditorAgentToolError> {
|
) -> Result<String, EditorAgentToolError> {
|
||||||
let args: GenerateImageToolArgs = parse_internal("generate image args", args)?;
|
let args: GenerateImageToolArgs = parse_internal_image_args("generate image args", args)?;
|
||||||
let result: EditorImageGenerationResult = parse_internal("generate image result", result)?;
|
let result: EditorImageGenerationResult = parse_internal("generate image result", result)?;
|
||||||
Ok(GenerateImageTool::format_execute_message(
|
Ok(GenerateImageTool::format_execute_message(
|
||||||
self, &args, &result,
|
self, &args, &result,
|
||||||
@@ -418,7 +458,7 @@ impl EditorAgentTool for GenerateImageTool {
|
|||||||
|
|
||||||
impl EditorAgentTool for GenerateCharacterTool {
|
impl EditorAgentTool for GenerateCharacterTool {
|
||||||
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
|
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
|
||||||
let args: GenerateCharacterToolArgs = parse_invalid_args(Self::NAME, args)?;
|
let args: GenerateCharacterToolArgs = parse_invalid_image_args(Self::NAME, args)?;
|
||||||
GenerateCharacterTool::validate_args(self, &args)
|
GenerateCharacterTool::validate_args(self, &args)
|
||||||
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
|
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
|
||||||
serialize_normalized_args(Self::NAME, &args)
|
serialize_normalized_args(Self::NAME, &args)
|
||||||
@@ -429,7 +469,8 @@ impl EditorAgentTool for GenerateCharacterTool {
|
|||||||
pricing: &EditorGenerationPricingConfig,
|
pricing: &EditorGenerationPricingConfig,
|
||||||
args: &Value,
|
args: &Value,
|
||||||
) -> Result<u32, EditorAgentToolError> {
|
) -> Result<u32, EditorAgentToolError> {
|
||||||
let args: GenerateCharacterToolArgs = parse_internal("generate character args", args)?;
|
let args: GenerateCharacterToolArgs =
|
||||||
|
parse_internal_image_args("generate character args", args)?;
|
||||||
Ok(editor_agent_image_mud_points(
|
Ok(editor_agent_image_mud_points(
|
||||||
pricing,
|
pricing,
|
||||||
Some("character"),
|
Some("character"),
|
||||||
@@ -445,7 +486,7 @@ impl EditorAgentTool for GenerateCharacterTool {
|
|||||||
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
|
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
|
||||||
let price_mud_points = self.pricing(pricing, args)?;
|
let price_mud_points = self.pricing(pricing, args)?;
|
||||||
let args: GenerateCharacterToolArgs =
|
let args: GenerateCharacterToolArgs =
|
||||||
parse_internal("generate character display args", args)?;
|
parse_internal_image_args("generate character display args", args)?;
|
||||||
let mut display_args = EditorAgentToolCallDisplayArgs::default();
|
let mut display_args = EditorAgentToolCallDisplayArgs::default();
|
||||||
push_image_generation_display_args(
|
push_image_generation_display_args(
|
||||||
&mut display_args,
|
&mut display_args,
|
||||||
@@ -466,7 +507,7 @@ impl EditorAgentTool for GenerateCharacterTool {
|
|||||||
context: &EditorAgentPrepareJobContext<'_>,
|
context: &EditorAgentPrepareJobContext<'_>,
|
||||||
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
|
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
|
||||||
let price_mud_points = self.pricing(context.pricing, args)?;
|
let price_mud_points = self.pricing(context.pricing, args)?;
|
||||||
let args: GenerateCharacterToolArgs = parse_invalid_args(Self::NAME, args)?;
|
let args: GenerateCharacterToolArgs = parse_invalid_image_args(Self::NAME, args)?;
|
||||||
let title = args.prompt.clone();
|
let title = args.prompt.clone();
|
||||||
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
|
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
|
||||||
let payload = EditorImageGenerationRequest {
|
let payload = EditorImageGenerationRequest {
|
||||||
@@ -505,7 +546,8 @@ impl EditorAgentTool for GenerateCharacterTool {
|
|||||||
args: &Value,
|
args: &Value,
|
||||||
result: &Value,
|
result: &Value,
|
||||||
) -> Result<String, EditorAgentToolError> {
|
) -> Result<String, EditorAgentToolError> {
|
||||||
let args: GenerateCharacterToolArgs = parse_internal("generate character args", args)?;
|
let args: GenerateCharacterToolArgs =
|
||||||
|
parse_internal_image_args("generate character args", args)?;
|
||||||
let result: EditorImageGenerationResult =
|
let result: EditorImageGenerationResult =
|
||||||
parse_internal("generate character result", result)?;
|
parse_internal("generate character result", result)?;
|
||||||
Ok(GenerateCharacterTool::format_execute_message(
|
Ok(GenerateCharacterTool::format_execute_message(
|
||||||
@@ -528,7 +570,7 @@ impl EditorAgentTool for GenerateCharacterTool {
|
|||||||
|
|
||||||
impl EditorAgentTool for GenerateUiDesignTool {
|
impl EditorAgentTool for GenerateUiDesignTool {
|
||||||
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
|
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
|
||||||
let args: GenerateUiDesignToolArgs = parse_invalid_args(Self::NAME, args)?;
|
let args: GenerateUiDesignToolArgs = parse_invalid_image_args(Self::NAME, args)?;
|
||||||
GenerateUiDesignTool::validate_args(self, &args)
|
GenerateUiDesignTool::validate_args(self, &args)
|
||||||
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
|
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
|
||||||
serialize_normalized_args(Self::NAME, &args)
|
serialize_normalized_args(Self::NAME, &args)
|
||||||
@@ -539,7 +581,8 @@ impl EditorAgentTool for GenerateUiDesignTool {
|
|||||||
pricing: &EditorGenerationPricingConfig,
|
pricing: &EditorGenerationPricingConfig,
|
||||||
args: &Value,
|
args: &Value,
|
||||||
) -> Result<u32, EditorAgentToolError> {
|
) -> Result<u32, EditorAgentToolError> {
|
||||||
let args: GenerateUiDesignToolArgs = parse_internal("generate UI design args", args)?;
|
let args: GenerateUiDesignToolArgs =
|
||||||
|
parse_internal_image_args("generate UI design args", args)?;
|
||||||
Ok(editor_agent_image_mud_points(
|
Ok(editor_agent_image_mud_points(
|
||||||
pricing,
|
pricing,
|
||||||
Some("ui-design"),
|
Some("ui-design"),
|
||||||
@@ -555,7 +598,7 @@ impl EditorAgentTool for GenerateUiDesignTool {
|
|||||||
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
|
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
|
||||||
let price_mud_points = self.pricing(pricing, args)?;
|
let price_mud_points = self.pricing(pricing, args)?;
|
||||||
let args: GenerateUiDesignToolArgs =
|
let args: GenerateUiDesignToolArgs =
|
||||||
parse_internal("generate UI design display args", args)?;
|
parse_internal_image_args("generate UI design display args", args)?;
|
||||||
let mut display_args = EditorAgentToolCallDisplayArgs::default();
|
let mut display_args = EditorAgentToolCallDisplayArgs::default();
|
||||||
push_image_generation_display_args(
|
push_image_generation_display_args(
|
||||||
&mut display_args,
|
&mut display_args,
|
||||||
@@ -576,7 +619,7 @@ impl EditorAgentTool for GenerateUiDesignTool {
|
|||||||
context: &EditorAgentPrepareJobContext<'_>,
|
context: &EditorAgentPrepareJobContext<'_>,
|
||||||
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
|
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
|
||||||
let price_mud_points = self.pricing(context.pricing, args)?;
|
let price_mud_points = self.pricing(context.pricing, args)?;
|
||||||
let args: GenerateUiDesignToolArgs = parse_invalid_args(Self::NAME, args)?;
|
let args: GenerateUiDesignToolArgs = parse_invalid_image_args(Self::NAME, args)?;
|
||||||
let title = args.prompt.clone();
|
let title = args.prompt.clone();
|
||||||
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
|
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
|
||||||
let payload = EditorImageGenerationRequest {
|
let payload = EditorImageGenerationRequest {
|
||||||
@@ -615,7 +658,8 @@ impl EditorAgentTool for GenerateUiDesignTool {
|
|||||||
args: &Value,
|
args: &Value,
|
||||||
result: &Value,
|
result: &Value,
|
||||||
) -> Result<String, EditorAgentToolError> {
|
) -> Result<String, EditorAgentToolError> {
|
||||||
let args: GenerateUiDesignToolArgs = parse_internal("generate UI design args", args)?;
|
let args: GenerateUiDesignToolArgs =
|
||||||
|
parse_internal_image_args("generate UI design args", args)?;
|
||||||
let result: EditorImageGenerationResult =
|
let result: EditorImageGenerationResult =
|
||||||
parse_internal("generate UI design result", result)?;
|
parse_internal("generate UI design result", result)?;
|
||||||
Ok(GenerateUiDesignTool::format_execute_message(
|
Ok(GenerateUiDesignTool::format_execute_message(
|
||||||
@@ -638,7 +682,7 @@ impl EditorAgentTool for GenerateUiDesignTool {
|
|||||||
|
|
||||||
impl EditorAgentTool for EditImageTool {
|
impl EditorAgentTool for EditImageTool {
|
||||||
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
|
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
|
||||||
let args: EditImageToolArgs = parse_invalid_args(Self::NAME, args)?;
|
let args: EditImageToolArgs = parse_invalid_image_args(Self::NAME, args)?;
|
||||||
if let Some(error) = EditImageTool::validate_args(self, &args) {
|
if let Some(error) = EditImageTool::validate_args(self, &args) {
|
||||||
return Err(EditorAgentToolError::invalid_args(error.to_string()));
|
return Err(EditorAgentToolError::invalid_args(error.to_string()));
|
||||||
}
|
}
|
||||||
@@ -650,11 +694,11 @@ impl EditorAgentTool for EditImageTool {
|
|||||||
pricing: &EditorGenerationPricingConfig,
|
pricing: &EditorGenerationPricingConfig,
|
||||||
args: &Value,
|
args: &Value,
|
||||||
) -> Result<u32, EditorAgentToolError> {
|
) -> Result<u32, EditorAgentToolError> {
|
||||||
let _: EditImageToolArgs = parse_internal("edit image args", args)?;
|
let _: EditImageToolArgs = parse_internal_image_args("edit image args", args)?;
|
||||||
Ok(editor_agent_image_mud_points(
|
Ok(editor_agent_image_mud_points(
|
||||||
pricing,
|
pricing,
|
||||||
Some("quick-edit"),
|
Some("quick-edit"),
|
||||||
GPT_IMAGE_2_MODEL,
|
GPT_IMAGE_2_5_BUSINESS_NAME,
|
||||||
Some("1K"),
|
Some("1K"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -665,7 +709,7 @@ impl EditorAgentTool for EditImageTool {
|
|||||||
pricing: &EditorGenerationPricingConfig,
|
pricing: &EditorGenerationPricingConfig,
|
||||||
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
|
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
|
||||||
let price_mud_points = self.pricing(pricing, args)?;
|
let price_mud_points = self.pricing(pricing, args)?;
|
||||||
let args: EditImageToolArgs = parse_internal("edit image display args", args)?;
|
let args: EditImageToolArgs = parse_internal_image_args("edit image display args", args)?;
|
||||||
let mut display_args = EditorAgentToolCallDisplayArgs::default();
|
let mut display_args = EditorAgentToolCallDisplayArgs::default();
|
||||||
push_string_display_arg(&mut display_args, "prompt", "修改要求", args.prompt);
|
push_string_display_arg(&mut display_args, "prompt", "修改要求", args.prompt);
|
||||||
push_string_display_arg(&mut display_args, "model", "模型", args.model);
|
push_string_display_arg(&mut display_args, "model", "模型", args.model);
|
||||||
@@ -694,7 +738,7 @@ impl EditorAgentTool for EditImageTool {
|
|||||||
context: &EditorAgentPrepareJobContext<'_>,
|
context: &EditorAgentPrepareJobContext<'_>,
|
||||||
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
|
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
|
||||||
let price_mud_points = self.pricing(context.pricing, args)?;
|
let price_mud_points = self.pricing(context.pricing, args)?;
|
||||||
let args: EditImageToolArgs = parse_invalid_args(Self::NAME, args)?;
|
let args: EditImageToolArgs = parse_invalid_image_args(Self::NAME, args)?;
|
||||||
let source_reference_id = self
|
let source_reference_id = self
|
||||||
.context
|
.context
|
||||||
.image_metadata(&args.object_image_id)
|
.image_metadata(&args.object_image_id)
|
||||||
@@ -739,7 +783,7 @@ impl EditorAgentTool for EditImageTool {
|
|||||||
args: &Value,
|
args: &Value,
|
||||||
result: &Value,
|
result: &Value,
|
||||||
) -> Result<String, EditorAgentToolError> {
|
) -> Result<String, EditorAgentToolError> {
|
||||||
let args: EditImageToolArgs = parse_internal("edit image args", args)?;
|
let args: EditImageToolArgs = parse_internal_image_args("edit image args", args)?;
|
||||||
let result: EditorImageEditResult = parse_internal("edit image result", result)?;
|
let result: EditorImageEditResult = parse_internal("edit image result", result)?;
|
||||||
Ok(EditImageTool::format_execute_message(self, args, result))
|
Ok(EditImageTool::format_execute_message(self, args, result))
|
||||||
}
|
}
|
||||||
@@ -785,7 +829,7 @@ fn resolve_icon_spec_reference_id<'a>(
|
|||||||
|
|
||||||
impl EditorAgentTool for GenerateIconSpritesheetTool {
|
impl EditorAgentTool for GenerateIconSpritesheetTool {
|
||||||
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
|
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
|
||||||
let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?;
|
let args: GenerateIconSpritesheetToolArgs = parse_invalid_image_args(Self::NAME, args)?;
|
||||||
let args = GenerateIconSpritesheetTool::normalize_args(self, args)
|
let args = GenerateIconSpritesheetTool::normalize_args(self, args)
|
||||||
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
|
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
|
||||||
resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?;
|
resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?;
|
||||||
@@ -798,7 +842,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
|
|||||||
args: &Value,
|
args: &Value,
|
||||||
) -> Result<u32, EditorAgentToolError> {
|
) -> Result<u32, EditorAgentToolError> {
|
||||||
let args: GenerateIconSpritesheetToolArgs =
|
let args: GenerateIconSpritesheetToolArgs =
|
||||||
parse_internal("generate icon spritesheet args", args)?;
|
parse_internal_image_args("generate icon spritesheet args", args)?;
|
||||||
Ok(editor_agent_image_mud_points(
|
Ok(editor_agent_image_mud_points(
|
||||||
pricing,
|
pricing,
|
||||||
Some("icon"),
|
Some("icon"),
|
||||||
@@ -814,7 +858,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
|
|||||||
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
|
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
|
||||||
let price_mud_points = self.pricing(pricing, args)?;
|
let price_mud_points = self.pricing(pricing, args)?;
|
||||||
let args: GenerateIconSpritesheetToolArgs =
|
let args: GenerateIconSpritesheetToolArgs =
|
||||||
parse_internal("generate icon spritesheet display args", args)?;
|
parse_internal_image_args("generate icon spritesheet display args", args)?;
|
||||||
let mut display_args = EditorAgentToolCallDisplayArgs::default();
|
let mut display_args = EditorAgentToolCallDisplayArgs::default();
|
||||||
push_string_display_arg(
|
push_string_display_arg(
|
||||||
&mut display_args,
|
&mut display_args,
|
||||||
@@ -855,7 +899,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
|
|||||||
context: &EditorAgentPrepareJobContext<'_>,
|
context: &EditorAgentPrepareJobContext<'_>,
|
||||||
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
|
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
|
||||||
let price_mud_points = self.pricing(context.pricing, args)?;
|
let price_mud_points = self.pricing(context.pricing, args)?;
|
||||||
let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?;
|
let args: GenerateIconSpritesheetToolArgs = parse_invalid_image_args(Self::NAME, args)?;
|
||||||
let reference_id =
|
let reference_id =
|
||||||
resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?.to_string();
|
resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?.to_string();
|
||||||
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
|
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
|
||||||
@@ -903,7 +947,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
|
|||||||
result: &Value,
|
result: &Value,
|
||||||
) -> Result<String, EditorAgentToolError> {
|
) -> Result<String, EditorAgentToolError> {
|
||||||
let args: GenerateIconSpritesheetToolArgs =
|
let args: GenerateIconSpritesheetToolArgs =
|
||||||
parse_internal("generate icon spritesheet args", args)?;
|
parse_internal_image_args("generate icon spritesheet args", args)?;
|
||||||
let result: EditorIconSpritesheetResult =
|
let result: EditorIconSpritesheetResult =
|
||||||
parse_internal("generate icon spritesheet result", result)?;
|
parse_internal("generate icon spritesheet result", result)?;
|
||||||
Ok(GenerateIconSpritesheetTool::format_execute_message(
|
Ok(GenerateIconSpritesheetTool::format_execute_message(
|
||||||
@@ -1250,7 +1294,9 @@ mod tests {
|
|||||||
use spacetime_client::{EditorCanvasRecord, EditorCanvasViewportRecord};
|
use spacetime_client::{EditorCanvasRecord, EditorCanvasViewportRecord};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::editor_generation_config::load_editor_generation_pricing_from_paths;
|
use crate::editor_generation_config::{
|
||||||
|
EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, load_editor_generation_pricing_from_paths,
|
||||||
|
};
|
||||||
|
|
||||||
fn pricing() -> EditorGenerationPricingConfig {
|
fn pricing() -> EditorGenerationPricingConfig {
|
||||||
load_editor_generation_pricing_from_paths(None).expect("default editor pricing should load")
|
load_editor_generation_pricing_from_paths(None).expect("default editor pricing should load")
|
||||||
@@ -1350,7 +1396,7 @@ mod tests {
|
|||||||
"unknown": "drop-me"
|
"unknown": "drop-me"
|
||||||
}))
|
}))
|
||||||
.expect("typed UI validation should pass");
|
.expect("typed UI validation should pass");
|
||||||
assert_eq!(normalized["model"], GPT_IMAGE_2_MODEL);
|
assert_eq!(normalized["model"], GPT_IMAGE_2_5_BUSINESS_NAME);
|
||||||
assert_eq!(normalized["aspect_ratio"], "1:1");
|
assert_eq!(normalized["aspect_ratio"], "1:1");
|
||||||
assert_eq!(normalized["image_size"], "1K");
|
assert_eq!(normalized["image_size"], "1K");
|
||||||
assert!(normalized.get("unknown").is_none());
|
assert!(normalized.get("unknown").is_none());
|
||||||
@@ -1495,6 +1541,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dyn_validation_normalizes_legacy_gpt_image_2_alias() {
|
||||||
|
let edit = editor_agent_tool(EditImageTool::NAME, &context_with_image("image-1"))
|
||||||
|
.expect("edit tool should resolve");
|
||||||
|
let normalized = edit
|
||||||
|
.validate_args(&json!({
|
||||||
|
"object_image_id": "image-1",
|
||||||
|
"prompt": "把背景换成夜晚",
|
||||||
|
"model": GPT_IMAGE_2_MODEL
|
||||||
|
}))
|
||||||
|
.expect("legacy edit args should validate");
|
||||||
|
assert_eq!(normalized["model"], GPT_IMAGE_2_5_BUSINESS_NAME);
|
||||||
|
|
||||||
|
let image = editor_agent_tool(GenerateImageTool::NAME, &EditorToolContext::default())
|
||||||
|
.expect("image tool should resolve");
|
||||||
|
let normalized = image
|
||||||
|
.validate_args(&json!({
|
||||||
|
"prompt": "生成图片",
|
||||||
|
"model": GPT_IMAGE_2_C_MODEL,
|
||||||
|
"image_size": "2K"
|
||||||
|
}))
|
||||||
|
.expect("legacy generation alias should validate");
|
||||||
|
assert_eq!(normalized["model"], GPT_IMAGE_2_5_BUSINESS_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dyn_pricing_keeps_all_existing_tool_formulas() {
|
fn dyn_pricing_keeps_all_existing_tool_formulas() {
|
||||||
let pricing = pricing();
|
let pricing = pricing();
|
||||||
@@ -1512,6 +1583,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
GenerateUiDesignTool::NAME,
|
GenerateUiDesignTool::NAME,
|
||||||
|
// 历史持久化的 `gpt-image-2` 会先归一为 `gpt-image-2.5`,所以计价仍走当前档位。
|
||||||
json!({ "prompt": "界面", "model": platform_image::GPT_IMAGE_2_MODEL, "image_size": "2K" }),
|
json!({ "prompt": "界面", "model": platform_image::GPT_IMAGE_2_MODEL, "image_size": "2K" }),
|
||||||
5,
|
5,
|
||||||
),
|
),
|
||||||
@@ -1554,9 +1626,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn dyn_pricing_uses_the_supplied_runtime_snapshot() {
|
fn dyn_pricing_uses_the_supplied_runtime_snapshot() {
|
||||||
let mut pricing = pricing();
|
let mut pricing = pricing();
|
||||||
|
// 历史 `gpt-image-2` 输入会归一为 `gpt-image-2.5-flare-c`,所以要改这个档位的价格。
|
||||||
pricing
|
pricing
|
||||||
.models
|
.models
|
||||||
.get_mut(GPT_IMAGE_2_MODEL)
|
.get_mut(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION)
|
||||||
.expect("gpt image pricing should exist")
|
.expect("gpt image pricing should exist")
|
||||||
.prices
|
.prices
|
||||||
.insert("2K".to_string(), 37);
|
.insert("2K".to_string(), 37);
|
||||||
|
|||||||
@@ -1480,8 +1480,8 @@ mod tests {
|
|||||||
|
|
||||||
fn editor_llm_test_config(base_url: String) -> AppConfig {
|
fn editor_llm_test_config(base_url: String) -> AppConfig {
|
||||||
AppConfig {
|
AppConfig {
|
||||||
vector_engine_base_url: base_url,
|
tiantoken_base_url: base_url,
|
||||||
vector_engine_api_key: Some("test-vector-engine-key".to_string()),
|
tiantoken_api_key: Some("test-tiantoken-key".to_string()),
|
||||||
llm_max_retries: 0,
|
llm_max_retries: 0,
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,40 @@ 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);
|
// 中文注释:生成规范缺省模型与 normalize_editor_generation_options 保持一致,
|
||||||
|
// 未显式传模型时按 GPT Image 2.5 生成档计价,不要落到 nanobanana 档。
|
||||||
|
let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
None => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION,
|
||||||
|
Some(_) => normalize_editor_image_model(model),
|
||||||
|
};
|
||||||
read_tier_price(
|
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 +269,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 +380,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 +393,48 @@ fn load_editor_generation_pricing_from_candidates(
|
|||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 兼容升级前只含单个历史 `gpt-image-2` key 的持久化定价配置。
|
||||||
|
///
|
||||||
|
/// 磁盘 override 与 SpacetimeDB record 两条加载路径都要调用,补齐缺失的
|
||||||
|
/// `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 后才允许 `validate()`。
|
||||||
|
pub(crate) fn backfill_legacy_gpt_image_2_5_pricing(
|
||||||
|
config: &mut EditorGenerationPricingConfig,
|
||||||
|
fallback: &EditorGenerationPricingConfig,
|
||||||
|
source: &str,
|
||||||
|
) -> 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
|
||||||
|
// every persisted override and SpacetimeDB record contains the two explicit
|
||||||
|
// GPT Image 2.5 keys.
|
||||||
|
let pricing = config
|
||||||
|
.models
|
||||||
|
.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2)
|
||||||
|
.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 +534,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 +548,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>,
|
||||||
@@ -997,10 +1113,14 @@ mod tests {
|
|||||||
"unit": "perGeneration",
|
"unit": "perGeneration",
|
||||||
"prices": { "0.5K": 8, "1K": 12, "2K": 24 }
|
"prices": { "0.5K": 8, "1K": 12, "2K": 24 }
|
||||||
},
|
},
|
||||||
"gpt-image-2": {
|
"gpt-image-2.5-flare-c": {
|
||||||
"unit": "perGeneration",
|
"unit": "perGeneration",
|
||||||
"prices": { "1K": 20 }
|
"prices": { "1K": 20 }
|
||||||
},
|
},
|
||||||
|
"gpt-image-2.5-sunburst-c": {
|
||||||
|
"unit": "perGeneration",
|
||||||
|
"prices": { "1K": 3, "2K": 5 }
|
||||||
|
},
|
||||||
"seedance2.0-fast": {
|
"seedance2.0-fast": {
|
||||||
"unit": "perSecond",
|
"unit": "perSecond",
|
||||||
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
|
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
|
||||||
@@ -1034,6 +1154,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect_err("missing 2K price should fail");
|
.expect_err("missing 2K price should fail");
|
||||||
|
|
||||||
assert!(error.to_string().contains("gpt-image-2 缺少 2K"));
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("models.gpt-image-2.5-flare-c 缺少 2K 的泥点配置")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,9 +113,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,
|
||||||
},
|
},
|
||||||
@@ -375,6 +376,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,
|
||||||
@@ -393,7 +397,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,
|
||||||
@@ -406,7 +410,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,
|
||||||
@@ -419,7 +423,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,
|
||||||
@@ -1927,7 +1931,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(
|
||||||
@@ -2765,7 +2772,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()
|
||||||
},
|
},
|
||||||
@@ -2855,7 +2862,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()
|
||||||
},
|
},
|
||||||
@@ -2988,7 +2995,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()
|
||||||
},
|
},
|
||||||
@@ -3182,6 +3189,13 @@ 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 if reference_images.is_empty() {
|
||||||
|
GPT_IMAGE_2_5_GENERATION_MODEL
|
||||||
|
} else {
|
||||||
|
GPT_IMAGE_2_5_EDIT_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(),
|
||||||
@@ -3833,7 +3847,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3976,15 +3990,24 @@ 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
|
||||||
| EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS,
|
| EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS,
|
||||||
) => EDITOR_IMAGE_MODEL_NANOBANANA2,
|
) => EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||||||
// 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2;
|
// 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范统一归一到 gpt-image-2.5;
|
||||||
// 角色 / 图标素材入口由前端显式传入 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);
|
||||||
@@ -6014,7 +6037,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(),
|
||||||
@@ -8461,7 +8484,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_EDIT_MODEL,
|
||||||
prompt.as_str(),
|
prompt.as_str(),
|
||||||
None,
|
None,
|
||||||
generation_options.provider_size.as_str(),
|
generation_options.provider_size.as_str(),
|
||||||
@@ -17803,7 +17826,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(
|
||||||
@@ -17833,7 +17856,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");
|
||||||
@@ -17848,21 +17871,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");
|
||||||
@@ -18065,7 +18088,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn publication_material_generation_is_locked_to_gpt_image_2() {
|
fn publication_material_generation_is_locked_to_gpt_image_2_5() {
|
||||||
let source = concat!(
|
let source = concat!(
|
||||||
include_str!("editor_project_icon.rs"),
|
include_str!("editor_project_icon.rs"),
|
||||||
include_str!("editor_project.rs")
|
include_str!("editor_project.rs")
|
||||||
@@ -18077,7 +18100,7 @@ mod tests {
|
|||||||
"let effective_price_mud_points",
|
"let effective_price_mud_points",
|
||||||
&[
|
&[
|
||||||
"is_publication_material_generation",
|
"is_publication_material_generation",
|
||||||
"Some(GPT_IMAGE_2_MODEL)",
|
"Some(GPT_IMAGE_2_5_BUSINESS_NAME)",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -19975,7 +19998,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(
|
||||||
|
|||||||
@@ -57,14 +57,17 @@ use crate::{
|
|||||||
normalize_generated_image_asset_mime,
|
normalize_generated_image_asset_mime,
|
||||||
},
|
},
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
openai_image_generation::{build_openai_image_http_client, require_openai_image_settings},
|
openai_image_generation::{
|
||||||
|
GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, build_openai_image_http_client,
|
||||||
|
require_openai_image_settings,
|
||||||
|
},
|
||||||
platform_errors::{is_retryable_llm_error, map_llm_error, map_oss_error},
|
platform_errors::{is_retryable_llm_error, map_llm_error, map_oss_error},
|
||||||
prompt::icon_spec::{PROMPT_MAX_LEN, build_extra_param_prompt, get_gen_icon_spec_prompt},
|
prompt::icon_spec::{PROMPT_MAX_LEN, build_extra_param_prompt, get_gen_icon_spec_prompt},
|
||||||
request_context::RequestContext,
|
request_context::RequestContext,
|
||||||
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";
|
||||||
@@ -1771,6 +1774,15 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
|||||||
&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 if reference_images.is_empty() {
|
||||||
|
GPT_IMAGE_2_5_GENERATION_MODEL
|
||||||
|
} else {
|
||||||
|
GPT_IMAGE_2_5_EDIT_MODEL
|
||||||
|
},
|
||||||
prompt: prompt.as_str(),
|
prompt: prompt.as_str(),
|
||||||
negative_prompt: None,
|
negative_prompt: None,
|
||||||
size,
|
size,
|
||||||
|
|||||||
@@ -574,8 +574,8 @@ mod tests {
|
|||||||
|
|
||||||
fn editor_llm_test_config(base_url: String, max_retries: u32) -> AppConfig {
|
fn editor_llm_test_config(base_url: String, max_retries: u32) -> AppConfig {
|
||||||
AppConfig {
|
AppConfig {
|
||||||
vector_engine_base_url: base_url,
|
tiantoken_base_url: base_url,
|
||||||
vector_engine_api_key: Some("test-vector-engine-key".to_string()),
|
tiantoken_api_key: Some("test-tiantoken-key".to_string()),
|
||||||
llm_max_retries: max_retries,
|
llm_max_retries: max_retries,
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, missing_reference_images_error,
|
||||||
};
|
};
|
||||||
#[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_C_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,26 @@ 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(
|
// 中文注释:先做本地参考图校验,避免 provider 客户端缺失时在校验前 panic。
|
||||||
http_client,
|
if reference_images.is_empty() {
|
||||||
&settings.provider_settings(),
|
return map_platform_image_result(
|
||||||
|
settings,
|
||||||
|
Err(missing_reference_images_error(
|
||||||
|
ImageProvider::Tiantoken,
|
||||||
|
failure_context,
|
||||||
|
)),
|
||||||
|
"image_edit_with_references",
|
||||||
|
failure_context,
|
||||||
|
request_payload,
|
||||||
|
started_at_micros,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let provider_client = settings.client_for_model(GPT_IMAGE_2_5_EDIT_MODEL);
|
||||||
|
let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_EDIT_MODEL);
|
||||||
|
let result = create_image_edit_with_references(
|
||||||
|
provider_client.http_client(),
|
||||||
|
&provider_settings,
|
||||||
prompt,
|
prompt,
|
||||||
negative_prompt,
|
negative_prompt,
|
||||||
size,
|
size,
|
||||||
@@ -327,7 +361,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 +379,26 @@ 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(
|
// 中文注释:先做本地参考图校验,避免 provider 客户端缺失时在校验前 panic。
|
||||||
http_client,
|
if reference_images.is_empty() {
|
||||||
&settings.provider_settings(),
|
return map_platform_image_result(
|
||||||
|
settings,
|
||||||
|
Err(missing_reference_images_error(
|
||||||
|
ImageProvider::Tiantoken,
|
||||||
|
failure_context,
|
||||||
|
)),
|
||||||
|
"image_edit_with_references",
|
||||||
|
failure_context,
|
||||||
|
request_payload,
|
||||||
|
started_at_micros,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let provider_client = settings.client_for_model(model);
|
||||||
|
let provider_settings = settings.provider_settings_for_model(model);
|
||||||
|
let result = create_image_edit_with_references_and_model(
|
||||||
|
provider_client.http_client(),
|
||||||
|
&provider_settings,
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
negative_prompt,
|
negative_prompt,
|
||||||
@@ -376,7 +427,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 +437,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 +478,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 +648,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 +681,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 +703,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 +722,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 +734,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 +759,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 +771,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 +796,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 +846,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 +864,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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user