diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 25c807788..b45dc02ce 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -102,7 +102,7 @@ Use OpenAPI as the final authority; these common values are a routing aid: - Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it. - External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. -- Image `model`: `gpt-image-2`, `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` is a legacy value resolved only when submitting a new task; the retired `gpt-image-2-c` is no longer accepted and is handled as an unsupported value. - Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`. - Image `imageSize`: `0.5K`, `1K`, `2K`. - Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`. diff --git a/.codex/skills/gpt-image-2-apimart/SKILL.md b/.codex/skills/gpt-image-2-apimart/SKILL.md index f350d98a5..d2947e866 100644 --- a/.codex/skills/gpt-image-2-apimart/SKILL.md +++ b/.codex/skills/gpt-image-2-apimart/SKILL.md @@ -1,11 +1,17 @@ --- name: gpt-image-2-apimart -description: Generate or inspect project image assets through this repository's 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 Tiantoken GPT Image 2.5 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY image-generation configuration without exposing secrets. The directory name is historical. --- -# gpt-image-2 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. Provider routing is owned by `server-rs`; when this skill talks to the provider directly it must send the concrete provider model and the matching provider credentials, because the provider side only accepts concrete models: + +- generation (no reference images): `gpt-image-2.5-flare-c` through Tiantoken +- edits (any reference image): `gpt-image-2.5-sunburst-c` through Tiantoken +- nanobanana (`gemini-3.1-flash-image-preview`) stays on VectorEngine + +The business model name `gpt-image-2.5` is resolved to a concrete key by `server-rs` at the task boundary and is never sent to a provider directly. This client must not perform a cross-model fallback; the only retry the bundled scripts do is a same-model retry on transient upstream statuses (`408` / `429` / `5xx`, 1s/2s backoff, at most 3 attempts), which never changes the model. The folder still contains `apimart` in its name for compatibility with existing local plugin references. ## Workflow @@ -24,15 +30,15 @@ Use this skill for project-local image asset generation that must match the repo ``` 5. Save final project assets under `public/` or another explicitly requested workspace path. -6. Never print `VECTOR_ENGINE_API_KEY`. Report only whether configuration exists. +6. Never print `TIANTOKEN_API_KEY`. Report only whether configuration exists. ## Request Contract The repository image path uses: ```text -POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations -Authorization: Bearer {VECTOR_ENGINE_API_KEY} +POST {TIANTOKEN_BASE_URL}/v1/images/generations +Authorization: Bearer {TIANTOKEN_API_KEY} Content-Type: application/json ``` @@ -40,7 +46,7 @@ Default body: ```json { - "model": "gpt-image-2", + "model": "gpt-image-2.5-flare-c", "prompt": "", "n": 1, "size": "1024x1024" @@ -50,22 +56,22 @@ Default body: For visual references, use the edit endpoint instead of the create endpoint: ```text -POST {VECTOR_ENGINE_BASE_URL}/v1/images/edits -Authorization: Bearer {VECTOR_ENGINE_API_KEY} +POST {TIANTOKEN_BASE_URL}/v1/images/edits +Authorization: Bearer {TIANTOKEN_API_KEY} Content-Type: multipart/form-data ``` Multipart fields: ```text -model=gpt-image-2 +model=gpt-image-2.5-sunburst-c prompt= n=1 size=1024x1024 image=@reference.png ``` -In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths 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` with `gpt-image-2.5-flare-c`; calls with any reference image use `POST /v1/images/edits` with `gpt-image-2.5-sunburst-c` and pass references as one or more `image` form parts. Server-side calls still submit the business model `gpt-image-2.5` and let `server-rs` resolve the concrete key; provider routing and retry policy remain server-owned, and pricing follows the same reference-image rule (generation with a reference image is charged at the edit tier). Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part. Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints. @@ -75,12 +81,14 @@ Load environment values from process env first, then `.env.secrets.local`, `.env Required for live generation: -- `VECTOR_ENGINE_BASE_URL` -- `VECTOR_ENGINE_API_KEY` +- `TIANTOKEN_BASE_URL` +- `TIANTOKEN_API_KEY` Optional: -- `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` +- `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` + +`VECTOR_ENGINE_*` values no longer serve GPT Image 2.5: those credentials belong to VectorEngine, which only serves nanobanana. Do not fall back from `TIANTOKEN_*` to `VECTOR_ENGINE_*`. If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat. diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs index 457374380..da0310e08 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs @@ -9,8 +9,9 @@ const skillRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(skillRoot, '..', '..', '..'); const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations'); const defaultTimeoutMs = 1000000; -const preferredImageModel = 'gpt-image-2'; -const fallbackImageModel = 'gpt-image-2-c'; +// GPT Image 2.5 生成任务只接受 concrete provider model;业务模型名 `gpt-image-2.5` +// 由 server-rs 在任务边界解析,脚本直连 provider 时必须自己给出 concrete key。 +const imageModel = 'gpt-image-2.5-flare-c'; const prompts = [ { @@ -101,18 +102,18 @@ function resolveEnv() { ...process.env, }; return { - baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') + baseUrl: String(loaded.TIANTOKEN_BASE_URL || '') .trim() .replace(/\/+$/u, ''), - apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), + apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(), timeoutMs: Number.parseInt( - String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), 10, ), }; } -function buildVectorEngineImagesGenerationUrl(baseUrl) { +function buildTiantokenImagesGenerationUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`; @@ -222,6 +223,15 @@ function inferExtensionFromBytes(bytes) { return null; } +// Tiantoken 偶发 408/429/5xx 时重试同一个 concrete model;跨模型回退仍由 +// server-rs 负责,脚本不做任何模型替换。 +const transientRetryMaxAttempts = 3; +const transientRetryBaseDelayMs = 1000; + +function isTransientProviderStatus(status) { + return status === 408 || status === 429 || status >= 500; +} + async function fetchJson(url, options, timeoutMs) { const abortController = new AbortController(); const timer = setTimeout(() => abortController.abort(), timeoutMs); @@ -233,22 +243,21 @@ async function fetchJson(url, options, timeoutMs) { const text = await response.text(); if (!response.ok) { const error = new Error( - `VectorEngine ${response.status}: ${text.slice(0, 600)}`, + `Tiantoken ${response.status}: ${text.slice(0, 600)}`, ); - error.vectorEngineStatus = response.status; - error.vectorEngineBody = text; + error.providerStatus = response.status; throw error; } try { return JSON.parse(text); } catch (error) { - error.vectorEngineResponseParse = true; - error.vectorEngineBody = text; - throw error; + throw new Error( + `Tiantoken 返回无法解析的响应体:${error.message};响应片段:${text.slice(0, 600)}`, + ); } } catch (error) { if (error?.name === 'AbortError') { - throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`); } throw error; } finally { @@ -256,81 +265,55 @@ async function fetchJson(url, options, timeoutMs) { } } -function shouldFallbackImageModel(error) { - const raw = - `${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase(); - if (error?.vectorEngineResponseParse) { - return !containsContentRejection(raw); +async function fetchJsonWithRetry(url, options, timeoutMs, requestId) { + for (let attempt = 1; ; attempt += 1) { + try { + return await fetchJson(url, options, timeoutMs); + } catch (error) { + if ( + attempt >= transientRetryMaxAttempts || + !isTransientProviderStatus(error?.providerStatus) + ) { + throw error; + } + const delayMs = transientRetryBaseDelayMs * 2 ** (attempt - 1); + console.warn( + `Tiantoken ${error.providerStatus} on ${requestId}, retrying the same model in ${delayMs}ms (attempt ${attempt + 1}/${transientRetryMaxAttempts})`, + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } } - const status = Number(error?.vectorEngineStatus || 0); - if (status === 408 || status >= 500) { - return true; - } - if (status === 429) { - return !containsContentRejection(raw); - } - const mentionsImageModel = - raw.includes('model') || - raw.includes('模型') || - raw.includes(preferredImageModel) || - raw.includes(fallbackImageModel); - return ( - [400, 404, 422].includes(status) && - mentionsImageModel && - /(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test( - raw, - ) - ); -} - -function containsContentRejection(raw) { - return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test( - raw, - ); } async function requestImagePayload(env, entry) { - for (const model of [preferredImageModel, fallbackImageModel]) { - const requestBody = { - model, - prompt: buildPrompt(entry), - n: 1, - size: '1024x1024', - }; - try { - const payload = await fetchJson( - buildVectorEngineImagesGenerationUrl(env.baseUrl), - { - method: 'POST', - headers: { - Authorization: `Bearer ${env.apiKey}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }, - env.timeoutMs, - ); - const base64Image = decodeStrictBase64Image( - extractBase64Images(payload)[0], - ); - if (extractImageUrls(payload)[0] || base64Image) { - return payload; - } - const error = new Error(`VectorEngine returned no image for ${entry.id}`); - error.vectorEngineResponseParse = true; - error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); - throw error; - } catch (error) { - if (model !== preferredImageModel || !shouldFallbackImageModel(error)) { - throw error; - } - console.warn( - `VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`, - ); - } + const model = imageModel; + const requestBody = { + model, + prompt: buildPrompt(entry), + n: 1, + size: '1024x1024', + }; + const payload = await fetchJsonWithRetry( + buildTiantokenImagesGenerationUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + env.timeoutMs, + entry.id, + ); + const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]); + if (extractImageUrls(payload)[0] || base64Image) { + return payload; } - throw new Error(`VectorEngine returned no image for ${entry.id}`); + throw new Error( + `Tiantoken returned no image for ${entry.id}:${JSON.stringify(payload).slice(0, 600)}`, + ); } async function downloadUrl(url, timeoutMs) { @@ -373,7 +356,7 @@ async function generateOne(env, entry, outDir) { const bytes = decodeStrictBase64Image(b64Images[0]); if (!bytes) { throw new Error( - `VectorEngine returned invalid base64 image for ${entry.id}`, + `Tiantoken returned invalid base64 image for ${entry.id}`, ); } image = { @@ -381,7 +364,7 @@ async function generateOne(env, entry, outDir) { extension: inferExtensionFromBytes(bytes), }; } else { - throw new Error(`VectorEngine returned no image for ${entry.id}`); + throw new Error(`Tiantoken returned no image for ${entry.id}`); } mkdirSync(outDir, { recursive: true }); @@ -408,9 +391,8 @@ if (dryRun) { requests: selectedPrompts.map((entry) => ({ id: entry.id, title: entry.title, - fallbackModel: fallbackImageModel, body: { - model: preferredImageModel, + model: imageModel, prompt: buildPrompt(entry), n: 1, size: '1024x1024', @@ -429,7 +411,7 @@ if (!env.baseUrl || !env.apiKey) { console.error( JSON.stringify({ ok: false, - error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', + error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey), }), diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs index bb1354921..e37683cd8 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs @@ -18,8 +18,9 @@ const defaultOutDir = path.join( 'puzzle-creation-templates', ); const defaultTimeoutMs = 1000000; -const preferredImageModel = 'gpt-image-2'; -const fallbackImageModel = 'gpt-image-2-c'; +// GPT Image 2.5 生成任务只接受 concrete provider model;业务模型名 `gpt-image-2.5` +// 由 server-rs 在任务边界解析,脚本直连 provider 时必须自己给出 concrete key。 +const imageModel = 'gpt-image-2.5-flare-c'; const args = new Map(); for (let index = 2; index < process.argv.length; index += 1) { @@ -71,18 +72,18 @@ function resolveEnv() { ...process.env, }; return { - baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') + baseUrl: String(loaded.TIANTOKEN_BASE_URL || '') .trim() .replace(/\/+$/u, ''), - apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), + apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(), timeoutMs: Number.parseInt( - String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), 10, ), }; } -function buildVectorEngineImagesGenerationUrl(baseUrl) { +function buildTiantokenImagesGenerationUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`; @@ -192,6 +193,15 @@ function inferExtensionFromBytes(bytes) { return null; } +// Tiantoken 偶发 408/429/5xx 时重试同一个 concrete model;跨模型回退仍由 +// server-rs 负责,脚本不做任何模型替换。 +const transientRetryMaxAttempts = 3; +const transientRetryBaseDelayMs = 1000; + +function isTransientProviderStatus(status) { + return status === 408 || status === 429 || status >= 500; +} + async function fetchJson(url, options, timeoutMs) { const abortController = new AbortController(); const timer = setTimeout(() => abortController.abort(), timeoutMs); @@ -203,22 +213,21 @@ async function fetchJson(url, options, timeoutMs) { const text = await response.text(); if (!response.ok) { const error = new Error( - `VectorEngine ${response.status}: ${text.slice(0, 600)}`, + `Tiantoken ${response.status}: ${text.slice(0, 600)}`, ); - error.vectorEngineStatus = response.status; - error.vectorEngineBody = text; + error.providerStatus = response.status; throw error; } try { return JSON.parse(text); } catch (error) { - error.vectorEngineResponseParse = true; - error.vectorEngineBody = text; - throw error; + throw new Error( + `Tiantoken 返回无法解析的响应体:${error.message};响应片段:${text.slice(0, 600)}`, + ); } } catch (error) { if (error?.name === 'AbortError') { - throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`); } throw error; } finally { @@ -226,83 +235,55 @@ async function fetchJson(url, options, timeoutMs) { } } -function shouldFallbackImageModel(error) { - const raw = - `${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase(); - if (error?.vectorEngineResponseParse) { - return !containsContentRejection(raw); +async function fetchJsonWithRetry(url, options, timeoutMs, requestId) { + for (let attempt = 1; ; attempt += 1) { + try { + return await fetchJson(url, options, timeoutMs); + } catch (error) { + if ( + attempt >= transientRetryMaxAttempts || + !isTransientProviderStatus(error?.providerStatus) + ) { + throw error; + } + const delayMs = transientRetryBaseDelayMs * 2 ** (attempt - 1); + console.warn( + `Tiantoken ${error.providerStatus} on ${requestId}, retrying the same model in ${delayMs}ms (attempt ${attempt + 1}/${transientRetryMaxAttempts})`, + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } } - const status = Number(error?.vectorEngineStatus || 0); - if (status === 408 || status >= 500) { - return true; - } - if (status === 429) { - return !containsContentRejection(raw); - } - const mentionsImageModel = - raw.includes('model') || - raw.includes('模型') || - raw.includes(preferredImageModel) || - raw.includes(fallbackImageModel); - return ( - [400, 404, 422].includes(status) && - mentionsImageModel && - /(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test( - raw, - ) - ); -} - -function containsContentRejection(raw) { - return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test( - raw, - ); } async function requestImagePayload(env, template) { - for (const model of [preferredImageModel, fallbackImageModel]) { - const requestBody = { - model, - prompt: buildPrompt(template), - n: 1, - size: '1024x1024', - }; - try { - const payload = await fetchJson( - buildVectorEngineImagesGenerationUrl(env.baseUrl), - { - method: 'POST', - headers: { - Authorization: `Bearer ${env.apiKey}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }, - env.timeoutMs, - ); - const base64Image = decodeStrictBase64Image( - extractBase64Images(payload)[0], - ); - if (extractImageUrls(payload)[0] || base64Image) { - return payload; - } - const error = new Error( - `VectorEngine returned no image for ${template.id}`, - ); - error.vectorEngineResponseParse = true; - error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); - throw error; - } catch (error) { - if (model !== preferredImageModel || !shouldFallbackImageModel(error)) { - throw error; - } - console.warn( - `VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`, - ); - } + const model = imageModel; + const requestBody = { + model, + prompt: buildPrompt(template), + n: 1, + size: '1024x1024', + }; + const payload = await fetchJsonWithRetry( + buildTiantokenImagesGenerationUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + env.timeoutMs, + template.id, + ); + const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]); + if (extractImageUrls(payload)[0] || base64Image) { + return payload; } - throw new Error(`VectorEngine returned no image for ${template.id}`); + throw new Error( + `Tiantoken returned no image for ${template.id}:${JSON.stringify(payload).slice(0, 600)}`, + ); } async function downloadUrl(url, timeoutMs) { @@ -345,7 +326,7 @@ async function generateOne(env, template, outDir) { const bytes = decodeStrictBase64Image(b64Images[0]); if (!bytes) { throw new Error( - `VectorEngine returned invalid base64 image for ${template.id}`, + `Tiantoken returned invalid base64 image for ${template.id}`, ); } image = { @@ -353,7 +334,7 @@ async function generateOne(env, template, outDir) { extension: inferExtensionFromBytes(bytes), }; } else { - throw new Error(`VectorEngine returned no image for ${template.id}`); + throw new Error(`Tiantoken returned no image for ${template.id}`); } mkdirSync(outDir, { recursive: true }); @@ -384,9 +365,8 @@ if (dryRun) { requests: selectedTemplates.map((template) => ({ id: template.id, title: template.title, - fallbackModel: fallbackImageModel, body: { - model: preferredImageModel, + model: imageModel, prompt: buildPrompt(template), n: 1, size: '1024x1024', @@ -405,7 +385,7 @@ if (!env.baseUrl || !env.apiKey) { console.error( JSON.stringify({ ok: false, - error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', + error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey), }), diff --git a/CONTEXT.md b/CONTEXT.md index 724d95de3..1dcebffe4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -44,6 +44,35 @@ _Avoid_: 把同一资源的全局元数据和某一次摆放坐标混在同一 由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。 _Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果 +**图片模型历史值与使用端解析**: +图片资源中已持久化的 `gpt-image-2` 是历史业务事实,读回时保持原值;新任务使用业务模型值 `gpt-image-2.5`。当用户基于历史资源再次发起生成或编辑任务时,服务端只在新任务的使用端把历史值解析为当前业务模型,不改写历史资源。provider route 属于服务端执行与审计边界,前端不接收、不持久化、不展示,也不据此分支。 +_Avoid_: 读取数据库时改写历史模型值、把 provider route 暴露为前端模型选项或公开 DTO + +**图片 provider 显式路由**: +api-server 在任务入口按业务语义显式选择具体 provider model name(生成或编辑),并把同一具体名传给图片平台适配器和后台定价解析;图片平台适配器不从参考图数量或前端字段猜测任务。具体 provider model name 只存在于服务端调用、定价配置和审计边界。 +后台管理 Web/API 是明确例外,可以查看和编辑两个具体定价 key;主站普通前端与公开定价 API 不接收这些 key。 +_Avoid_: 让图片适配器隐式猜路由、让主站前端携带 provider model name + +**业务模型**: +面向任务与产品契约的稳定模型值;当前 GPT 图片新任务的业务模型是 `gpt-image-2.5`。业务模型不等同于 provider 的具体计费/请求 model,也不暴露 provider 凭证或 endpoint。 +_Avoid_: 把 provider concrete model 当作前端业务选项、用业务模型值直接推断 provider 凭证 + +**具体模型**: +服务端发送请求和定价使用的 concrete model name。GPT Image 2.5 生成与编辑分别是 `gpt-image-2.5-flare-c` 和 `gpt-image-2.5-sunburst-c`;nanobanana 仍使用 `gemini-3.1-flash-image-preview`。具体模型只在服务端执行、定价和审计边界出现。 +_Avoid_: 把具体模型写入普通前端 DTO、让未知字符串自动选择 provider + +**provider client**: +按具体模型选出的外部图片 provider 连接配置,包含 provider identity、base URL 和 API key;VectorEngine 与 Tiantoken client 共享图片协议执行器,不复制请求/响应业务逻辑。两套 required client 在 api-server 启动时构造。 +_Avoid_: 在首次请求时才创建 client、在 provider client 中复制尺寸/重试/审计逻辑、跨 provider credential fallback + +**历史模型值**: +已持久化的 `gpt-image-2` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。已退役的 `gpt-image-2-c` 已从代码整体删除,只有数据库里的历史审计字符串原样保留,任何入口传入该值都按不支持的值处理。 +_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端、为已删除的 `gpt-image-2-c` 重新加回常量或解析分支 + +**GPT Image 2.5 新生成展示名**: +`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。 +_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文 + **系列素材图集生成**: 一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。 _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index ce3da42c4..e7c37bf18 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -703,11 +703,11 @@ describe('快速编辑浮层的下拉弹层', () => { fireEvent.click( screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), ); - expect(screen.getByRole('button', { name: 'gpt-image-2' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'GPT Image 2.5' })).toBeTruthy(); // 点面板内、弹层外的提示词区域:只收弹层,不动面板本身。 fireEvent.click(screen.getByLabelText('快速编辑提示词')); - expect(screen.queryByRole('button', { name: 'gpt-image-2' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'GPT Image 2.5' })).toBeNull(); expect( screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), ).toBeTruthy(); @@ -739,10 +739,10 @@ describe('快速编辑浮层的下拉弹层', () => { fireEvent.click( screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), ); - expect(screen.getByRole('button', { name: 'gpt-image-2' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'GPT Image 2.5' })).toBeTruthy(); fireEvent.keyDown(document, { key: 'Escape' }); - expect(screen.queryByRole('button', { name: 'gpt-image-2' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'GPT Image 2.5' })).toBeNull(); }); }); diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index c97ad913c..f67678de4 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -89,9 +89,11 @@ GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE=/etc/genarrative/secrets/l GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE=/etc/genarrative/secrets/llm-router-admin.token TIANTOKEN_BASE_URL=https://api.tiantoken.com TIANTOKEN_API_KEY= +# 两个图片 provider 的超时互相独立:TIANTOKEN_* 只作用于 Tiantoken,VECTOR_ENGINE_* 只作用于 VectorEngine。 TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS=1000000 VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn VECTOR_ENGINE_API_KEY= +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000 VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000 ELEVENLABS_BASE_URL=https://api.elevenlabs.io ELEVENLABS_API_KEY= diff --git a/docs/README.md b/docs/README.md index d1af5b3c0..ba4ddc4e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -80,6 +80,7 @@ - [画板音乐生成入口](./【编辑器】画板音乐生成入口设计-2026-06-18.md):BGM/SFX 共享视图、独立业务规则和当前发布门禁。 - [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md) - [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md) +- [GPT Image 2.5 模型路由与历史值兼容](./adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md) - [编辑器模型定价配置](./【编辑器】模型定价配置管理方案-2026-06-22.md) ## 后端、运维与测试 diff --git a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md new file mode 100644 index 000000000..88d789cad --- /dev/null +++ b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md @@ -0,0 +1,25 @@ +# GPT Image 2.5 模型路由与历史值兼容 + +状态:accepted + +新任务使用业务模型值 `gpt-image-2.5`,api-server 按任务显式选择具体 model:无参考图的生成使用 `gpt-image-2.5-flare-c`,编辑以及带参考图的生成使用 `gpt-image-2.5-sunburst-c`(参考图会让执行端必须走 edits multipart,因此执行与计价都按编辑 concrete model)。这两个 GPT Image 2.5 model 必须通过启动时构造的 Tiantoken client 发送;Tiantoken client 只读取显式配置的 `TIANTOKEN_BASE_URL`(部署值由环境设置为 `https://api.tiantoken.com`)和独立 `TIANTOKEN_API_KEY`,缺失即阻止 api-server 启动,不得回退到 VectorEngine 或其 API key。 + +图片协议执行逻辑保持 provider-neutral:请求 body、multipart、尺寸约束、重试、响应解码和审计由共享 image executor 承担;VectorEngine 与 Tiantoken client 只提供相同协议所需的 base URL、API key 和 provider identity。platform-image 根据 concrete model 做严格白名单路由:`gpt-image-2.5-flare-c` 与 `gpt-image-2.5-sunburst-c` 走 Tiantoken,`gemini-3.1-flash-image-preview`(nanobanana)走 VectorEngine;未知 model 直接拒绝。已持久化的 `gpt-image-2` 只在新任务提交边界按兼容规则解析为当前 GPT Image 2.5 任务,不改写历史资源,也不进入旧 VectorEngine 图片路由。 + +provider 白名单同时拒绝非 provider model:业务模型名 `gpt-image-2.5` 必须先由 api-server 在任务边界解析成 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c`;`gpt-image-2` 继续作为历史可读值接受。两者都不进入 provider 路由。 + +`gpt-image-2-c` 已整体删除:它自兜底移除后只剩历史审计字符串,从未进入业务模型、持久化模型字段或前端契约,因此不再保留任何常量、解析分支、尺寸语义或 provider 路由。数据库里既有的审计字符串保持原样,不参与新任务。 + +普通主站前端只接触业务模型和新生成展示名 `GPT Image 2.5`,admin Web/API 可以查看和编辑两个具体定价 key;生成类任务带参考图时后端必须走 edits multipart,因此 dispatch、审计与计价统一按编辑 concrete model `gpt-image-2.5-sunburst-c`(包含图标素材图集的固定参考图与 UI 素材提取的来源图),不存在「生成档计价 + 编辑档执行」的分裂。新代码不再跨模型或跨 provider fallback,已退役的 `gpt-image-2-c` 审计记录原样保留在数据库,代码不再引用该值。 + +主站前端只把原本明确使用 GPT Image 2 的专用新任务改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为保持不变。画布参数或编辑布局读到 `gpt-image-2` 时,在使用端解析为 `gpt-image-2.5` 并触发参数迁移警告,原始资源、审计、metadata 和历史 fixture 不回写。AGC 现有资源生成界面与请求默认保持不变,不因本决策新增模型字段、选择器或尺寸行为。 + +## Consequences + +- 定价配置的活动 key 是两个具体 provider model;持久化 override 与 SpacetimeDB record 只保存被显式设置过的模型,缺失条目一律继承编译内置默认(`server-rs/crates/api-server/config/editor-generation-pricing.default.json`),不再为每个新模型维护受控 backfill。旧单 key 配置因此把 GPT Image 2.5 档位落在默认值上,要改这两个具体 key 的价格必须显式写出。 +- 定价档位跟随任务最终 concrete model:带参考图的生成任务与编辑任务同价,admin 单独调整 `gpt-image-2.5-sunburst-c` 时生成带参考图的任务必须同步生效。 +- 新任务的同模型重试固定使用 api-server dispatch 的具体 model,不切换到另一个 model。 +- 两套 provider client 在 api-server 启动阶段同时构造;任一 required provider 配置缺失,启动失败而不是延迟到首次图片请求。 +- 两套 provider client 的缺失配置报错点名对应环境变量(`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`、`TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`),构造成功后用启动日志记录已启用的 provider。 +- provider routing 只依据 concrete model 的白名单,业务名与已退役的 `gpt-image-2-c` 一律拒绝(`gpt-image-2-c` 已从代码中整体删除,传入即按未知 model 处理);provider client 不复制共享协议执行逻辑。 +- 公开资源、`generationInputs` 和普通前端契约不包含具体 provider key;admin 定价管理是明确例外。 diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index c120926f0..3da686869 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3017,7 +3017,7 @@ }, "model": { "type": "string", - "description": "支持 gpt-image-2、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 不再接受,命中按不支持的值处理。kind=ui-design 与 kind=publication-material 固定使用 gpt-image-2.5,传入的 model 会被忽略;其余 kind 未传时默认 gpt-image-2.5。" }, "aspectRatio": { "type": "string", @@ -3033,7 +3033,7 @@ "type": "array", "items": { "type": "string", - "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 gpt-image-2 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。" + "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 GPT Image 2.5 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。" }, "maxItems": 9 }, @@ -3170,7 +3170,7 @@ }, "model": { "type": "string", - "description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。" + "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 仅作为历史值兼容解析;已退役的 gpt-image-2-c 不再接受,命中按不支持的值处理。" }, "aspectRatio": { "type": "string", @@ -3184,7 +3184,7 @@ "type": "array", "items": { "type": "string", - "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" + "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" }, "maxItems": 8 }, @@ -3373,7 +3373,7 @@ "type": "array", "items": { "type": "string", - "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" + "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" }, "maxItems": 8 }, @@ -3488,13 +3488,13 @@ "model": { "type": "string", "default": "gemini-3.1-flash-image-preview", - "description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时默认使用 nanobanana。" + "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-banana;gpt-image-2 仅作为历史值兼容解析;已退役的 gpt-image-2-c 不再接受,命中按不支持的值处理。未传时默认使用 nanobanana(gemini-3.1-flash-image-preview);传 gpt-image-2.5 时按 GPT Image 2.5 编辑任务执行。" }, "referenceImageSrcs": { "type": "array", "items": { "type": "string", - "description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。" + "description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。" }, "maxItems": 5 }, diff --git a/docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md b/docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md new file mode 100644 index 000000000..a196d49f1 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】GPT Image 2.5 provider边界重构-2026-09-18.md @@ -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、主站前端同步四个局部提交边界回滚;不执行数据库历史迁移,也不回滚历史资源值。 diff --git a/docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md b/docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md new file mode 100644 index 000000000..4e5677743 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md @@ -0,0 +1,53 @@ +# 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,以及带参考图的生成任务(图标素材图集的固定参考图、UI 素材提取的来源图同在内) | +| `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。 +7. 生成任务的 concrete model 由参考图决定:同一判据同时用于 dispatch、审计与计价,带参考图的生成任务按编辑档计价。 + +## 非目标 + +- 不复制两套完整图片 client。 +- 不新增 GPT Image 2 现役 VectorEngine 路由。 +- 不修改历史数据库记录或旧审计字符串。 +- 不把 provider client 选择下沉给普通前端。 + +## 验收证据 + +- provider routing 单元测试覆盖 flare/sunburst/nanobanana/legacy/unknown。 +- 启动配置测试证明两套 client 独立读取环境变量,缺失任一配置即失败且无 VectorEngine/Tiantoken 回退。 +- 请求审计测试证明 provider 与 concrete model 正确记录。 +- platform-image 与 Agent 定向测试通过。 +- api-server 类型/编译检查、前端类型检查、编码/文档/diff 门禁通过。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5913f4cfb..c486e831d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,53 @@ # 决策记录 +## 2026-09-22 模型定价改为「编译内置默认 + 持久化覆盖」叠加,删除受控 backfill + +- 背景:`editor-generation-pricing.default.json`(`include_str!` 编译内置)是默认模型定价;磁盘 override 与 SpacetimeDB record 以前是「整份替换」,旧配置缺新模型 key 时 `validate()` 直接失败。为了让升级前只含单个 `gpt-image-2` key 的配置能启动,代码维护了 `backfill_legacy_gpt_image_2_5_pricing`、`backfill_legacy_sfx_pricing` 两个受控 backfill 加 record 路径上的内联 SFX 补齐,并且每新增一个模型都要再写一次同类兼容代码。 +- 决策:两条加载路径统一改为「以编译内置默认 JSON 为基线,叠加持久化配置里显式给出的模型条目」(`overlay_editor_generation_pricing`):缺失模型继承默认值,写坏的条目仍由 `validate()` 拦截。删除两个 backfill 函数与 record 路径的内联补齐;磁盘 override 与 record 只保存被显式设置过的模型,admin 保存时仍写回完整模型集合。 +- 行为差异:旧配置里自定义过、但没有写出 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 的 `gpt-image-2` 价格不再自动传播到 GPT Image 2.5 档位,新任务按内置默认档(1K 3 / 2K 5 泥点)计价。仓库内置默认里三者本来就相同,因此只影响「自定义过 `gpt-image-2` 价格且此后从未再保存定价」的部署。 +- 迁移建议:只读检查 SpacetimeDB `editor_generation_pricing_config` 是否缺少两个 `gpt-image-2.5-*-c` 模型行(或与默认档位不一致);确认后在后台保存一次定价即可把继承值落库,或显式写入期望档位。 +- 影响范围:`server-rs/crates/api-server/src/editor_generation_config.rs`、`server-rs/crates/api-server/src/state.rs` 及其单测;ADR `docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md` 同步更新。 +- 验证:`cargo test -p api-server editor_generation_pricing` 17 项通过(含新的 `editor_generation_pricing_legacy_override_inherits_missing_models_from_default`、`editor_generation_pricing_override_still_rejects_invalid_model_entries`,以及改名为 `..._inherits_legacy_*` 的两条 record 用例)。 + +## 2026-09-22 删除失效的图片模型兜底审计链路 + +- 背景:跨模型兜底 `gpt-image-2-c` 删除后,`platform-image` 已没有任何地方写入 `recovered_failure_audits`:executor 只创建空 Vec、成功路径空 append、失败路径原样返回,`PlatformImageError::FallbackFailed` 不可能被构造,`GeneratedImages.recovered_failure_audits` 和错误上的 `recovered_failure_audits()` 恒为空,api-server 成功运行摘要的 `recoveredFailureCount` 恒为 `0`。这条链路既是死代码,也会让排障误以为仍存在兜底审计。 +- 决策:整体删除——`PlatformImageError::FallbackFailed`、`recovered_failure_audits()`、`with_recovered_failure_audits`、`into_final_error`、`GeneratedImages.recovered_failure_audits`、executor 的 `finish_image_model_fallback_error` 包装与空 Vec 累积,以及 api-server 成功运行摘要里的 `recoveredFailureCount` 和两处 recovered audit 落库循环。终态错误 audit(`error.audit()` → `external_api_call_failure`)语义保持不变。 +- 边界:不改重试策略(同模型 408/429/5xx 重试仍只写 provider 日志)、不改 `external_api_call_failure` 的终态记录语义、不改扣费与 HTTP 错误映射。本文件 2026-07-21「VectorEngine 图片首选 gpt-image-2 并以 gpt-image-2-c 兜底」条目里的 `recoveredFailureCount` 观测字段自本次起不再存在;数据库历史审计字符串按原样保留。 +- 影响范围:`server-rs/crates/platform-image/src/image_provider/runtime/{error.rs,util.rs,types.rs,executor.rs,image_source.rs}`、`server-rs/crates/platform-image/tests/image_provider.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 验证:`cargo test -p platform-image`、`cargo check -p api-server`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + +## 2026-09-22 图片 provider 单次 attempt 超时解耦 + +- 背景:`config.rs` 把 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS`(回退 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`)统一写进 `vector_engine_image_request_timeout_ms`,`state.rs` 又用这一个字段同时构造 Tiantoken 与 VectorEngine 两个图片 client,`OpenAiImageSettings` 也取同一个值;结果是两个 provider 无法各自设置单次 attempt 超时,配置 Tiantoken 变量会顺带改写 VectorEngine 的 deadline(反之亦然)。 +- 决策:`AppConfig` 新增 `tiantoken_image_request_timeout_ms`(默认 `1000000`,env 只读 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS`);`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只写 `vector_engine_image_request_timeout_ms`(默认同样 `1000000`)。Tiantoken 图片 client 与 `OpenAiImageSettings.request_timeout_ms` 改用新字段,VectorEngine client 保持原字段,两边都不再从对方的环境变量取值。 +- 边界:单次 attempt 语义不变(仍受 worker 绝对 job deadline 约束),默认值不变,因此现网只配置 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS=1000000` 的行为与之前一致;`deploy/env/api-server.env.example` 同步补上 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000`,让两个 provider 的取值都显式可见。不修改 nanobanana / Suno 链路、扣费、审计或重试策略。 +- 影响范围:`server-rs/crates/api-server/src/{config.rs,state.rs,openai_image_generation.rs}`、`deploy/env/api-server.env.example`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 验证:`cargo test -p api-server config::tests::from_env_reads_non_public_models_and_urls`(新增 Tiantoken / VectorEngine 双向不覆盖断言)、`cargo check -p api-server`。 + +## 2026-09-21 生成带参考图统一按编辑 concrete model 执行与计价 + +- 背景:GPT Image 2.5 迁移后,生成端在带参考图时会改走 `/v1/images/edits` 并提交 `gpt-image-2.5-sunburst-c`,但计价仍按 `gpt-image-2.5-flare-c` 生成档,dispatch 判断也分散在三个入口各自的 `if 带参考图` 表达式里;结果是「生成档扣费 + 编辑档执行/审计」分裂,admin 单独调价任一档位都会错价,ADR 里「普通生成即使因参考图使用 edits multipart,仍按生成 concrete model」与代码事实不符。 +- 决策:保留「按是否带参考图决定 concrete model」这一现有行为,把三处文档改到与代码一致;同时新增 `editor_image_generation_concrete_model(model, has_reference_images)` 作为生成类任务选择 concrete model 的唯一入口,dispatch 与计价共用同一判据。`image_generation_mud_points` 增加 `has_reference_images`,命中编辑 concrete model 时改走编辑档 `image_edit_model_mud_points`;图标素材图集(固定参考图)、UI 素材提取(来源图)、图标规范带 `referenceId`、Agent 生成类工具带 `referenceImageIds` 全部同规则。`EditImageTool` 计价从生成档改为编辑档,与直连快速编辑、raw edit 对齐。 +- 边界:nanobanana 生成与编辑共用一个 concrete model,参考图不改变档位;不引入跨模型 fallback;历史持久化值与审计字符串不改写。 +- 同批修复:`raw_image_edit` 发送前复用共享 `ensure_provider_matches_model` 白名单校验;`platform-image` 兜底 task id 前缀改为跟随实际 provider(`tiantoken-edit` / `tiantoken-nanobanana` 不再带 `vector-engine` 前缀);`resolve_image_provider` 增加 `gpt-image-2 → Tiantoken` 断言把历史值意图钉死;删除 `openai_image_generation.rs` 中仅测试使用的 `create_openai_image_edit` / `create_openai_image_edit_with_references`(测试改用 `..._and_model` 变体,保留空参考图本地校验覆盖);`normalize_editor_generation_options` 的具体 provider key 改用常量;External v1 OpenAPI 的 `model` 描述改为与实现一致(`kind=ui-design` / `publication-material` 固定 `gpt-image-2.5`,UI 素材提取未传默认 nanobanana)。 +- 影响范围:`api-server`(`editor_generation_config`、`editor_project`、`editor_project_icon`、`character_visual_assets`、`editor_agent/tool`、`openai_image_generation`)、`platform-image`(`raw_image_edit`、`runtime/executor`、集成测试)、ADR、里程碑文档、External v1 OpenAPI 描述、`.codex/skills/gpt-image-2-apimart` 脚本与 SKILL.md(改用 `TIANTOKEN_*` 凭据 + concrete model)。 +- 验证:`cargo test -p api-server editor_generation_config`、`cargo test -p api-server editor_agent::tool`、`cargo test -p api-server`(1123 passed / 1 failed,失败项为 mock LLM 连接超时,单测隔离重跑通过);`cargo test -p platform-image`(22 + 64 + 14 passed);`npx vitest run` 全量 382 files / 4619 passed(6 个测试文件的旧 `gpt-image-2` 断言与 mock 请求体读取竞态同步修正);`npm run typecheck`、`npm run check:encoding`、`git diff --check` 通过。 +- 关联文档:[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`](../plans/【里程碑】GPT%20Image%202.5%20provider边界重构-2026-09-18.md)。 + +## 2026-09-21 图片 provider 白名单收紧,并整体删除 gpt-image-2-c + +- 背景:GPT Image 2.5 迁移后,`resolve_image_provider` 仍把业务模型名 `gpt-image-2.5` 与 `gpt-image-2-c` 一起判为 Tiantoken,等于把「非 provider model」留在 provider 边界白名单里。`gpt-image-2-c` 自 2026-07-21 起只是首选模型失败时的兜底 provider model(兜底移除后仅剩历史审计字符串),从未进入业务模型、持久化 `model` 字段或前端契约——api-server 持久化的模型一律取自 `generation_options.model`(业务值),前端在 2.5 迁移前只写 `gpt-image-2`。 +- 决策:`resolve_image_provider` 白名单只接受现役具体 provider model,业务名 `gpt-image-2.5` 命中即拒绝,必须先在任务边界解析成具体 key;`gpt-image-2` 继续作为历史可读值接受。`gpt-image-2-c` 不再只做「拒绝」,而是整体删除:常量、re-export、`is_gpt_image_2_family_model` 尺寸语义、`auditable_image_model` 审计标签、api-server 的三处提交边界解析、前端常量与历史恢复分支全部移除;数据库既有审计字符串原样保留,代码不再引用该值。 +- 影响范围:`platform-image`(constants / lib / image_provider mod / protocol request / runtime executor / 集成测试)、`api-server`(`openai_image_generation`、`editor_project`、`editor_generation_config`、`editor_agent/tool`)、主站前端 `ImageCanvasGenerationModel` 与其测试、External v1 OpenAPI 描述、ADR 与本文档。前端与 External v1 传入 `gpt-image-2-c` 现在按「不支持的值」处理(走既有未知值分支),不再解析为 GPT Image 2.5。 +- 验证:`cargo test -p platform-image` 全绿(含新增 `resolve_image_provider_rejects_business_model_and_accepts_concrete_models`);`cargo test -p api-server` 1115 项;`npx vitest run src/components/image-editor/ImageCanvasGenerationModel.test.ts` 34 项通过;`check:encoding`、`check:doc-index` 通过。 + +## 2026-09-21 图片 provider 启动期缺配置的报错点名环境变量 + +- 背景:两个图片 provider client 都在 `AppState::new` 构造,缺 base URL / API key 即阻止 api-server 启动;旧报错只写「tiantoken 图片 provider 缺少 BASE_URL 配置」,没有点名变量名,而 `TIANTOKEN_*` 已不再从 `VECTOR_ENGINE_*` 回退,运维迁移时不易定位。 +- 决策:保持「不回退、缺失即启动失败」,但报错文案补上具体变量名(`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 或 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`);client 构造成功后用 `info` 记录 provider、base_url、超时与对应 env 变量名,便于确认实际生效的 provider 配置。 +- 验证:`cargo check -p api-server` 通过;`cargo test -p api-server` 1115 项通过(0 失败)。 + ## 2026-09-21 合并 origin/master:Supervisor 永久退役,策划 V1V2 退役落到当前两条产品路径 - 背景:`refactor/split-direct-project`(DirectProject 独立聊天容器)与 `origin/master`(#355 退役策划 Agent V1/V2)在 2026-09-18 之后各走一条线:本分支删掉 Supervisor 前端链路、把立项策划收敛到 `view/project-development/planning/`,master 删掉整套策划 V1/V2(前端会话 / 审批卡 / 适配器 / 类型与 Rust `planning_*_v2` 命令、`planning_gdd_model.rs`、`planning_policy_v2.rs`、`planning_session_v2.rs`)只保留 Design Agent。两边都在删 Supervisor,冲突集中在 `App.tsx`、聊天视图(`PlanningChatView`、`DirectProjectTurn`、`ToolCallGroup`)、Direct composer / 引用输入区、`styles.css`、Rust direct user item 与 appSurface 用例。 @@ -9201,6 +9249,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%。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-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,dispatch、审计与计价统一按编辑 concrete model;同模型重试不跨模型 fallback。 +- **历史兼容**:已持久化 `gpt-image-2` 读回原值不改写;基于旧资源发起新任务时,在提交边界解析为 `gpt-image-2.5`,新任务/新产物按新业务值和当前 task price 处理。旧 `gpt-image-2-c` 仅保留历史审计,不再作为 fallback 或业务模型。 +- **可见性**:普通主站前端和公开定价 API 不接收具体 provider key;新生成 UI label 为 `GPT Image 2.5`,历史资源/旧编辑上下文不扩散该 label。admin Web/API 是明确例外,可查看和编辑两个具体定价 key。旧单 key 定价配置允许受控 backfill,并加 compatibility TODO。【已被 2026-09-22「模型定价改为『编译内置默认 + 持久化覆盖』叠加,删除受控 backfill」取代:缺失模型条目改为继承编译内置默认值,两个 backfill 函数已删除,旧配置里只自定义过 `gpt-image-2` 的价格不再传播到 2.5 档位。】 +- **关联 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 抠图接入本地资源编辑恢复闭环 - 背景:`agc_remove_background` 原先只提交 `/api/editor/images/background-removals` 并返回 `queued`,没有轮询远端任务、下载完成媒体或写入本地 manifest;BgFilter 已成功处理但 Agent 因此永远只能看到受理回执。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b229b2883..7e5e9bae6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -7,6 +7,14 @@ - 处理:客户端画布图标生成只 trim 描述并以单项 `iconDescriptions` 原样提交,保留内部换行;面板与原生入口按 `200` 个 Unicode 码点校验并拒绝空白或超限输入,不静默截断、不机械拆条。服务端共用的提示词流程负责规范图、背景与排布要求;其它图片生成的 `32000` 字符上限保持不变。 - 验证:检查实际请求体与 trim 后的原文一致,并覆盖 `200/201` 码点、补充平面字符、换行和空白输入;不能只断言“请求长度未超限”。完整合同见 [画板图标素材生成入口设计](../../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。 +## 测试内 mock HTTP server 必须按 Content-Length 读满请求体再断言 + +- 现象:`platform-image` 的 `nanobanana_generate_content_posts_native_body_and_reads_inline_data` 在本机(Windows)稳定红、在 CI 上存在偶发红,断言 `request_text.contains("\"imageSize\":\"512\"")` 失败,看起来像实现没有发送请求体。 +- 原因:该 mock 用例自带一版「读到第一个 `\r\n\r\n` 就 break」的读循环,而 `\r\n\r\n` 只代表请求头结束;reqwest 写完头再写 body,TCP 分片下 body 常常还没到。断言实际只看到头和半截 body,是测试脚手架的竞态,与平台 / 网络栈无关,被测代码本身没有问题。 +- 处理:mock server 一律复用同文件已有的 `read_http_request`,先解析 `Content-Length`,读满 `header_end + content_length` 再断言;同文件的 `image_edit_retries_send_timeout_once_and_succeeds` 与 `image_provider_deadline_clips_stalled_attempt_and_prevents_retry` 早已使用该 helper。 +- 验证:把断言期望值临时改成错误值,用例必须失败(证明读到了真实 body);改回后期望连续重跑 8 次全绿,并跑 `cargo test -p platform-image` 全量(22 + 64 + 14 passed)。 +- 关联:`server-rs/crates/platform-image/tests/image_provider.rs`。 + ## 2026-09-21 不同渠道的包体在同一台设备安装会互相顶掉 - **现象**:在一台已经装了某个渠道 AGC 客户端的设备上安装另一个渠道的安装包,装完后旧客户端直接消失(安装目录被覆盖、卸载项被接管),更新端点、平台服务器与本地登录态一起换成新渠道的;两个渠道的客户端无法共存。 @@ -2503,7 +2511,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。 - 原因:2026-05-21 后 GPT-image-2 图片生成按 VectorEngine 创建/编辑接口分流;2026-07-05 后创意 Agent 文本链路也改为 VectorEngine Chat Completions `gpt-5.4-mini`,APIMart 不再作为当前创意 Agent 来源。 -- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,业务 / 计费与 provider 首发模型均为 `gpt-image-2`,仅在符合条件的 provider 失败后切到兜底模型 `gpt-image-2-c`。 +- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,新任务业务模型为 `gpt-image-2.5`,provider 具体模型为 `gpt-image-2.5-flare-c`(不带参考图的生成)/ `gpt-image-2.5-sunburst-c`(编辑与带参考图的生成),带参考图的生成按编辑档计价,只在同一具体模型内重试。历史兜底模型 `gpt-image-2-c` 已从代码整体删除,不再存在任何 fallback 路径,请求体里出现该值即视为异常。 - 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。 - 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。 diff --git a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md index ef702e821..a228c4592 100644 --- a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md +++ b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md @@ -90,7 +90,7 @@ provider 响应只提取并透传 `data[].b64_json` 字符串,不在服务端 ## 代码拆分 - `server-rs/crates/api-server/src/raw_image.rs`:独立路由 handler、multipart 字段解析、请求/响应 DTO、PNG 输入校验、预检查和 raw billing 编排。 -- `server-rs/crates/platform-image/src/vector_engine/raw_edit.rs`:raw 编辑选项、严格尺寸校验、独立 provider 请求映射和 `b64_json` 响应透传;由 api-server 按现有图片 API 传统构造并传入共享的 VectorEngine `reqwest::Client`,不在每个 raw 调用内部重复构造 client。每次请求仍用 `effective_request_timeout_ms` 通过 request builder 设置剩余 deadline;不修改统一 transport builder 的连接池策略。 +- `server-rs/crates/platform-image/src/raw_image_edit/mod.rs`:raw 编辑选项、严格尺寸校验、独立 provider 请求映射和 `b64_json` 响应透传;由 api-server 按现有图片 API 传统构造并传入启动时构造的 Tiantoken `reqwest::Client`,不在每个 raw 调用内部重复构造 client。每次请求仍用 `effective_request_timeout_ms` 通过 request builder 设置剩余 deadline;不修改统一 transport builder 的连接池策略。 raw-edit 的图片输入使用独立的 `RawImageEditImage`(`bytes::Bytes`),由 reqwest `Part::stream(Body::from(Bytes))` 直接接管 multipart 请求体,避免整图和掩码在 `Part::bytes` 的 `Cow<[u8]>` 转换中再次复制。既有 `ReferenceImage`、`DownloadedImage` 及 curl/编辑器链路继续保持 `Vec` 契约,不因 raw-edit 引入全局字节类型迁移。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 032a40f3d..72132b167 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -184,7 +184,7 @@ npm run check:server-rs-ddd 2. Adapter 输入应显式包含 provider、prompt、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source job、metadata 和可选透明背景后处理。 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 -5. 图片 provider 协议不再放在玩法模块里实现。产品、计费、DTO、持久化和 VectorEngine 创建 / 编辑首选请求统一使用 `gpt-image-2`;只有符合回退条件时,provider 边界才切到兜底模型 `gpt-image-2-c`。URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 +5. 图片 provider 协议不再放在玩法模块里实现。产品、计费、DTO、持久化和 GPT Image 2.5 创建 / 编辑请求统一使用业务模型 `gpt-image-2.5`,api-server 在提交边界按是否带参考图分派 `gpt-image-2.5-flare-c`(无参考图的生成)/ `gpt-image-2.5-sunburst-c`(编辑与带参考图的生成),dispatch、审计与计价共用同一 concrete model;已持久化的 `gpt-image-2` 只按兼容规则读取,不改写历史值;已退役的 `gpt-image-2-c` 已从代码整体删除,传入即按不支持的值处理。URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/image_provider/`;其中 `runtime/executor.rs` 负责共享 provider-neutral 执行编排,`transport/` 负责 HTTP client 与 curl 传输,`protocol/` 负责请求体、路径和响应 JSON 字段。raw image edit 的 multipart 与严格尺寸校验位于独立的 `server-rs/crates/platform-image/src/raw_image_edit/`,发送前复用共享 `ensure_provider_matches_model` 白名单校验,避免把 Tiantoken 的模型发到 VectorEngine 端点。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。generated 私有对象上传时必须由 OSS 对象头承载浏览器 / CDN 缓存策略,默认写入 `Cache-Control: public, max-age=31536000, immutable`,不得改成 api-server 本地磁盘静态资源兜底。 7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 @@ -274,7 +274,7 @@ npm run check:server-rs-ddd - 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 - 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,并由客户端 GET-only 对账;本次请求不得附加 `resultPersistenceStarted`。`AlreadyApplied` 仅在 early guard 与最终 procedure 并发相遇时作为底层幂等兜底,复用既有 commit 且不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 - 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 -- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent 文本链路使用 Tiantoken,使用 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 Tiantoken base URL 规范化到 `/v1`。VectorEngine 只保留给 Suno 音乐任务;后续排障时优先确认 Tiantoken `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 +- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent 文本链路使用 Tiantoken,启动时由 `AppConfig::from_env` 读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 落到 `AppConfig.tiantoken_base_url` / `AppConfig.tiantoken_api_key`(只读自己的环境变量,不回退 VectorEngine 凭据),再由 `AppState` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 Tiantoken base URL 规范化到 `/v1`。VectorEngine 只保留给 Suno 音乐任务;后续排障时优先确认 Tiantoken `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`,通用 `/api/llm/chat/completions` 代理可使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时由 Tiantoken 凭据承接。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent 客户端来源。 - 当前 provider 路由:`TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 承载原 VectorEngine 的文本和图片能力;旧版 Vidu 音效代码已移除;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐及 Suno 音效任务;新编辑器 SFX V2 继续独立使用 ElevenLabs。 @@ -318,7 +318,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 错误边界固定如下:`StreamUnavailable` 只表示流式响应已给出 `tool_use` / `tool_calls` 完成原因但没有聚合出任何工具 slot,供调用方回退非流式,它不承担截断语义;`EmptyResponse` 表示最终文本和工具调用都为空,纯工具响应合法;`Deserialize` 覆盖 JSON / SSE / UTF-8 解析失败、缺少 `choices[0]`、流式工具身份缺失、流式工具槽位身份冲突、流式参数不完整,以及上述工具流未收尾截断。Anthropic 仍不支持 `web_search`、图片内容和纯 system 消息,必须至少有一条非 system 文本消息。 -- 图片生成:VectorEngine 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;逻辑 SKU 与 provider 首选模型均固定为 `gpt-image-2`,只在明确模型不可用、408 / 非拒绝类 429 / 5xx、响应解析失败或非拒绝类缺图时切换兜底模型 `gpt-image-2-c`。401 / 403、普通参数或安全拒绝、本地配置 / 参考图错误、无法确认上游是否已受理的发送错误、request budget 耗尽和生成成功后的图片下载失败不得切模型。一次业务请求总发送上限仍为 5 次;切换兜底模型会消耗后续 attempt,不允许两个模型各重试 5 次。`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id、结果摘要和 recovered failure 数量;首选模型失败但兜底模型成功时,首选失败仍落 `external_api_call_failure`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`fallback_from_model`、`fallback_to_model`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 +- 图片生成:VectorEngine 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;逻辑 SKU 固定为业务模型 `gpt-image-2.5`,provider 具体模型为 `gpt-image-2.5-flare-c`(无参考图的生成)/ `gpt-image-2.5-sunburst-c`(编辑与带参考图的生成),计价档位与执行档位必须一致;历史兜底模型 `gpt-image-2-c` 已从代码整体删除,只在同一具体模型内重试,不做跨模型 fallback。401 / 403、普通参数或安全拒绝、本地配置 / 参考图错误、无法确认上游是否已受理的发送错误、request budget 耗尽和生成成功后的图片下载失败不得切模型。一次业务请求总发送上限仍为 5 次;切换兜底模型会消耗后续 attempt,不允许两个模型各重试 5 次。`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id、结果摘要和 recovered failure 数量;失败 attempt 仍落 `external_api_call_failure`,后续 attempt 成功时在运行摘要里计入 recovered failure。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 - 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 60f267a61..70bf43105 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -238,13 +238,13 @@ spacetime sql "SELECT * FROM runtime_setting LIMIT 1" --server http:/ 本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.8.3`,CLI / standalone commit 固定核对为 `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。若版本或 commit 错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;其它版本可执行 `spacetime version install && spacetime version use `,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.8.3 + 8e410d28...` 同时匹配;旧版本或旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。 -本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine 配置仅保留给 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` 与 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是两条互相独立、默认都为 `1000000` 的单次 attempt 上限:前者只作用于 Tiantoken 图片 client(GPT Image 2.5 生成 / 编辑),后者只作用于 VectorEngine 图片 client(nanobanana 等),任一 provider 的超时设置都不会改写另一个;配置加载层允许显式值低于默认值,不再在读取环境变量时强制抬高。新生成任务使用业务模型 `gpt-image-2.5`,api-server 显式分派 `gpt-image-2.5-flare-c`(不带参考图的 generate)或 `gpt-image-2.5-sunburst-c`(edit 与带参考图的 generate,计价同步走编辑档);已持久化的 `gpt-image-2` 只在提交边界按兼容规则解析,不改写历史值,也不回退到旧 GPT Image 2 路由;已退役的 `gpt-image-2-c` 已从代码整体删除,传入即按不支持的值处理。图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 编辑器 ElevenLabs 音效生成只从服务端读取 `ELEVENLABS_BASE_URL`、`ELEVENLABS_API_KEY` 和 `ELEVENLABS_REQUEST_TIMEOUT_MS`,timeout 默认 `180000ms`;base URL 或 Key 缺失时失败关闭,不回退 Vidu。生产 API 与 external-generation worker 通过共享 API env 取得同一配置,模板见 `deploy/env/api-server.env.example`;Key 不得进入 Web/Vite 环境、命令参数、日志、fixture 或仓库。普通测试只使用 loopback mock,禁止把真实付费请求作为 T3 自动验收。 SFX V2 发布必须使用维护窗:先关闭 SFX 入队,再对显式目标执行只读 `spacetime sql --server --format json "SELECT job_id, status, request_payload_json FROM external_generation_job WHERE job_kind = 'editor_sound_effect_generation' AND (status = 'pending' OR status = 'running')"`;结果非零时保持旧 Worker drain,不得删除任务或让新 Worker 解析旧 Vidu payload。禁止依赖默认 server,禁止使用 `--root-dir`。清零后先部署共享 env 已对齐的 api-server / external-generation worker,检查 `/healthz` 和 Worker 启动,再部署 Web 并小流量开放 SFX。灰度对账 job 完成数、退款数、ElevenLabs POST 数、完成资源数和孤儿资源;翻译失败仍调用 provider、单 job provider POST 大于一次、成功退款或失败未退款均应立即停止放量。回滚先停止入队并收口 V2 pending / running job,不自动切回 Vidu,不执行 SpacetimeDB schema 或数据回滚。完整清单见 `docs/【实施记录】SFX生成优化V2.0T6测试与发布门禁-2026-08-07.md`。 -VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会在一次业务请求总上限 5 次内处理;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。首个 provider attempt 使用 `gpt-image-2`;明确模型不可用、408 / 非拒绝类 429 / 5xx、响应解析失败或非拒绝类缺图时,下一 attempt 直接切兜底模型 `gpt-image-2-c`,之后只在剩余次数内重试兜底模型。发送 / 连接错误无法确认上游是否已受理,只重试同一首选模型,不切模型;认证、普通参数、安全拒绝、图片下载和 budget 错误同样不切。worker 从 job 开始的同一时钟起点计算绝对 deadline,常规保留最后 `60` 秒给审计、OSS 和终态写回;job 预算小于 `120` 秒时保留一半。VectorEngine 单次 attempt timeout 取配置值和剩余 provider 预算的较小值;退避或模型切换后已没有下一次 attempt 的预算时立即停止。该 deadline 覆盖参考图、provider 请求 / 响应和响应图片下载的整次 provider future,但只在 worker 进程内通过 `RequestContext` 传递;普通 HTTP / `inline` 没有该 deadline,继续保持原有 timeout 和重试行为。日志中 `VectorEngine 首选图片模型失败,切换兼容模型` 会携带 `fallback_from_model` / `fallback_to_model`;即使回退成功,首选模型错误仍写入 `external_api_call_failure`,成功运行摘要的 `recoveredFailureCount` 同时递增。排查生产失败时应同时统计 fallback / retry 日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。这项收口不修改 lease 续租 / fencing、迟到写回仲裁、attempt 耗尽与原子退款语义。 +VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会在一次业务请求总上限 5 次内处理;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。每次 provider attempt 都按任务确定的具体模型发送(`gpt-image-2.5-flare-c` 生成 / `gpt-image-2.5-sunburst-c` 编辑 / `gemini-3.1-flash-image-preview` nanobanana);明确模型不可用、408 / 非拒绝类 429 / 5xx、响应解析失败或非拒绝类缺图时只在同一具体模型内重试,不切模型,历史兜底模型 `gpt-image-2-c` 已从代码整体删除。发送 / 连接错误无法确认上游是否已受理,同样只重试同一模型;认证、普通参数、安全拒绝、图片下载和 budget 错误不重试。worker 从 job 开始的同一时钟起点计算绝对 deadline,常规保留最后 `60` 秒给审计、OSS 和终态写回;job 预算小于 `120` 秒时保留一半。VectorEngine 单次 attempt timeout 取配置值和剩余 provider 预算的较小值;退避后已没有下一次 attempt 的预算时立即停止。该 deadline 覆盖参考图、provider 请求 / 响应和响应图片下载的整次 provider future,但只在 worker 进程内通过 `RequestContext` 传递;普通 HTTP / `inline` 没有该 deadline,继续保持原有 timeout 和重试行为。重试过程中的失败 attempt 只写 provider 日志;`external_api_call_failure` 只记录终态的 provider 错误 audit,成功运行摘要不再包含已删除的 `recoveredFailureCount`。排查生产失败时应同时统计 retry 日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。这项收口不修改 lease 续租 / fencing、迟到写回仲裁、attempt 耗尽与原子退款语义。 图片编辑器生成属于持久队列长任务:提交接口返回 job 后,前端通过 `/api/runtime/external-generation/jobs/{jobId}` 与编辑器项目资源状态收敛。生产排查小程序或 WebView `Failed to fetch` 时,若 Nginx access log 为 `499`、`upstream_status=-`,先按提交请求的 `request_id`、job id、worker 日志和 `external_api_call_failure` 对齐真实任务,不把客户端断开直接判定为 provider 失败。 diff --git a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md index 671196db6..0da8f98c3 100644 --- a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md +++ b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md @@ -63,7 +63,7 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝 所有会调用外部生成 provider 的编辑器生成请求都必须由后端计算价格,前端请求不提交价格字段;同步执行按当前运行时配置进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。外部生成队列在入队时把价格写入 `external_generation_job.price_mud_points`,worker 必须用该冻结价格完成扣费、退款、响应和资产成本持久化,配置更新不得改变已入队任务金额。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。背景色决策(gpt-5-mini)本身也是一次上游调用,同样必须在预扣泥点之后发起:预扣前只做颜色无关的算价 / 校验(动画用默认色占位算价),决策放进 billable 闭包,余额不足则决策不跑、决策失败走失败退款。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`。 -SFX V2 上线前已经存在的 SpacetimeDB 定价快照或旧本地 override 可能只有 `audio1.0`。读取这类历史快照时,`api-server` 只允许从当前受控默认配置补入缺失的 `eleven_text_to_sound_v2` 条目,再执行完整配置校验,使旧快照可继续读取;其它必需模型缺失仍失败。该兼容不修改 schema,也不在读取时写数据库或 override;下一次后台保存完整定价矩阵时自然持久化新键。发布前仍应确认运行时配置中的新键和价格已经批准。 +SFX V2 上线前已经存在的 SpacetimeDB 定价快照或旧本地 override 可能只有 `audio1.0`。读取这类历史快照时,`api-server` 以编译内置默认(`server-rs/crates/api-server/config/editor-generation-pricing.default.json`)为基线、叠加快照里显式给出的模型条目,缺失条目(含 `eleven_text_to_sound_v2` 与两个 `gpt-image-2.5-*-c` 具体 key)一律继承默认值,再执行完整配置校验;不再为单个模型维护受控 backfill,写坏的条目仍由校验拦截。该兼容不修改 schema,也不在读取时写数据库或 override;下一次后台保存完整定价矩阵时自然持久化新键。发布前仍应确认运行时配置中的新键和价格已经批准,注意旧快照里只自定义过历史 `gpt-image-2` 价格时,新任务按默认档位计价。 ## 运行时身份首次授权 diff --git a/server-rs/crates/api-server/config/editor-generation-pricing.default.json b/server-rs/crates/api-server/config/editor-generation-pricing.default.json index 18d18f1b7..09a923eac 100644 --- a/server-rs/crates/api-server/config/editor-generation-pricing.default.json +++ b/server-rs/crates/api-server/config/editor-generation-pricing.default.json @@ -15,6 +15,20 @@ "2K": 5 } }, + "gpt-image-2.5-flare-c": { + "unit": "perGeneration", + "prices": { + "1K": 3, + "2K": 5 + } + }, + "gpt-image-2.5-sunburst-c": { + "unit": "perGeneration", + "prices": { + "1K": 3, + "2K": 5 + } + }, "seedance2.0-fast": { "unit": "perSecond", "prices": { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 0af004df1..d1eb1dd78 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -5901,14 +5901,29 @@ mod tests { payload["models"]["gemini-3.1-flash-image-preview"]["prices"]["1K"], Value::Number(12.into()) ); + // 公开投影只暴露业务模型名 gpt-image-2.5(取生成档价格), + // 不外泄具体 provider key,也不再暴露历史 gpt-image-2。 assert_eq!( - payload["models"]["gpt-image-2"]["prices"]["1K"], + payload["models"]["gpt-image-2.5"]["prices"]["1K"], Value::Number(3.into()) ); assert_eq!( - payload["models"]["gpt-image-2"]["prices"]["2K"], + payload["models"]["gpt-image-2.5"]["prices"]["2K"], Value::Number(5.into()) ); + let public_models = payload["models"] + .as_object() + .expect("公开定价必须暴露 models 对象"); + for hidden_model in [ + "gpt-image-2", + "gpt-image-2.5-flare-c", + "gpt-image-2.5-sunburst-c", + ] { + assert!( + !public_models.contains_key(hidden_model), + "公开定价不应暴露 {hidden_model}" + ); + } assert_eq!( payload["models"]["audio1.0"]["price"], Value::Number(5.into()) @@ -5954,10 +5969,14 @@ mod tests { "unit": "perGeneration", "prices": { "0.5K": 9, "1K": 18, "2K": 36 } }, - "gpt-image-2": { + "gpt-image-2.5-flare-c": { "unit": "perGeneration", "prices": { "1K": 31, "2K": 62 } }, + "gpt-image-2.5-sunburst-c": { + "unit": "perGeneration", + "prices": { "1K": 41, "2K": 82 } + }, "seedance2.0-fast": { "unit": "perSecond", "prices": { "480p": 11, "720p": 22, "1080p": 44 } @@ -5997,6 +6016,7 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let public_response = app + .clone() .oneshot( Request::builder() .uri("/api/editor/generation-pricing") @@ -6014,10 +6034,24 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("public pricing payload should be json"); + // 公开投影按业务模型名暴露生成档价格;具体 provider key 不下发普通前端。 assert_eq!( - payload["models"]["gpt-image-2"]["prices"]["2K"], + payload["models"]["gpt-image-2.5"]["prices"]["2K"], Value::Number(62.into()) ); + let public_models = payload["models"] + .as_object() + .expect("公开定价必须暴露 models 对象"); + for hidden_model in [ + "gpt-image-2", + "gpt-image-2.5-flare-c", + "gpt-image-2.5-sunburst-c", + ] { + assert!( + !public_models.contains_key(hidden_model), + "公开定价不应暴露 {hidden_model}" + ); + } assert_eq!( payload["models"]["audio1.0"]["unit"], Value::String("perGeneration".to_string()) @@ -6030,6 +6064,35 @@ mod tests { payload["models"]["seedance2.0"]["prices"]["720p"], Value::Number(26.into()) ); + + // admin 定价接口是 concrete provider key 的明确例外,读回的是保存时的原始 key。 + let admin_response = app + .oneshot( + Request::builder() + .uri("/admin/api/editor-generation-pricing") + .header("authorization", format!("Bearer {admin_token}")) + .body(Body::empty()) + .expect("admin pricing request should build"), + ) + .await + .expect("admin pricing request should succeed"); + assert_eq!(admin_response.status(), StatusCode::OK); + let admin_body = admin_response + .into_body() + .collect() + .await + .expect("admin pricing body should collect") + .to_bytes(); + let admin_payload: Value = + serde_json::from_slice(&admin_body).expect("admin pricing payload should be json"); + assert_eq!( + admin_payload["models"]["gpt-image-2.5-flare-c"]["prices"]["2K"], + Value::Number(62.into()) + ); + assert_eq!( + admin_payload["models"]["gpt-image-2.5-sunburst-c"]["prices"]["2K"], + Value::Number(82.into()) + ); } /// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。 diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index cea47141f..b266e3326 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -34,10 +34,11 @@ use crate::{ build_character_visual_negative_prompt, build_character_visual_prompt, build_fallback_moderation_safe_character_visual_prompt, }, + editor_generation_config::editor_image_generation_concrete_model, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiImageSettings, - build_openai_image_http_client, create_openai_image_generation, + DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, OpenAiImageSettings, + build_openai_image_http_client, create_openai_image_generation_with_model, require_openai_image_settings, }, platform_errors::map_oss_error, @@ -45,7 +46,7 @@ use crate::{ state::AppState, }; -const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_MODEL; +const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_5_BUSINESS_NAME; const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual"; const CHARACTER_VISUAL_ENTITY_KIND: &str = "character"; const CHARACTER_VISUAL_SLOT: &str = "primary_visual"; @@ -777,13 +778,13 @@ fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJob } fn resolve_character_visual_model(value: &str) -> String { - // 中文注释:旧前端和历史草稿可能仍传 wan2.7-image-pro;RPG 主图当前统一归一到 gpt-image-2。 + // 中文注释:旧前端和历史草稿可能仍传旧模型;只在新任务提交边界归一到当前业务模型。 let trimmed = value.trim(); if !trimmed.is_empty() && trimmed != CHARACTER_VISUAL_MODEL { tracing::warn!( requested_model = trimmed, effective_model = CHARACTER_VISUAL_MODEL, - "角色主形象图片模型已归一到 gpt-image-2" + "角色主形象图片模型已归一到当前业务模型" ); } CHARACTER_VISUAL_MODEL.to_string() @@ -940,15 +941,19 @@ async fn create_character_visual_generation( async fn create_character_visual_generation_once( http_client: &reqwest::Client, settings: &OpenAiImageSettings, - _model: &str, + model: &str, prompt: &str, size: &str, candidate_count: u32, reference_images: &[String], ) -> Result { - let generated = create_openai_image_generation( + // 中文注释:参考图存在时后端会走编辑端点,因此必须提交编辑 concrete model 而不是生成模型。 + let provider_model = + editor_image_generation_concrete_model(Some(model), !reference_images.is_empty()); + let generated = create_openai_image_generation_with_model( http_client, settings, + provider_model, prompt, Some(build_character_visual_negative_prompt().as_str()), size, @@ -1921,12 +1926,12 @@ mod tests { } #[test] - fn legacy_character_visual_model_normalizes_to_gpt_image_2() { + fn legacy_character_visual_model_normalizes_to_gpt_image_2_5() { assert_eq!( resolve_character_visual_model("wan2.7-image-pro"), - "gpt-image-2" + "gpt-image-2.5" ); - assert_eq!(resolve_character_visual_model(""), "gpt-image-2"); + assert_eq!(resolve_character_visual_model(""), "gpt-image-2.5"); } #[test] diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 6bf7edc92..90bfca4d2 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -18,6 +18,7 @@ const DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS: u64 = 600; const DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS: u64 = 900; const DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS: u64 = 1_800; pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000; +pub(crate) const DEFAULT_TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000; pub(crate) const DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS: u64 = 180_000; const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter"; const DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS: u64 = 5_000; @@ -219,6 +220,18 @@ pub struct AppConfig { pub vector_engine_api_key: Option, pub vector_engine_image_request_timeout_ms: u64, pub vector_engine_audio_request_timeout_ms: u64, + /// Tiantoken 是 GPT Image 2.5 图片链路和创意 Agent 文本链路的 provider。 + /// + /// 只从 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 读取,刻意不回退 VectorEngine 凭据; + /// 启动时落到 `AppConfig` 字段,使 `AppState` 构造与测试构造复用同一条路径。 + pub tiantoken_base_url: String, + pub tiantoken_api_key: Option, + /// Tiantoken 图片链路(GPT Image 2.5 生成 / 编辑)的单次 attempt 超时。 + /// + /// 与 `vector_engine_image_request_timeout_ms` 相互独立,只由 + /// `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 配置,避免一个 provider 的超时设置 + /// 顺带改写另一个 provider 的 deadline。 + pub tiantoken_image_request_timeout_ms: u64, pub elevenlabs_base_url: String, pub elevenlabs_api_key: Option, pub elevenlabs_request_timeout_ms: u64, @@ -533,6 +546,9 @@ impl Default for AppConfig { vector_engine_api_key: None, vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, vector_engine_audio_request_timeout_ms: 180_000, + tiantoken_base_url: String::new(), + tiantoken_api_key: None, + tiantoken_image_request_timeout_ms: DEFAULT_TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS, elevenlabs_base_url: String::new(), elevenlabs_api_key: None, elevenlabs_request_timeout_ms: DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS, @@ -1330,12 +1346,25 @@ impl AppConfig { config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]); - if let Some(tiantoken_image_request_timeout_ms) = read_first_positive_u64_env(&[ - "TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS", - "VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", - ]) { + if let Some(vector_engine_image_request_timeout_ms) = + read_first_positive_u64_env(&["VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"]) + { // 单次 attempt 上限允许按环境收短;worker 调用还会受整次任务的绝对 deadline 约束。 - config.vector_engine_image_request_timeout_ms = tiantoken_image_request_timeout_ms; + config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms; + } + + // Tiantoken 只读取自己的环境变量,不从 VectorEngine 配置回退;启动时冻结进 AppConfig, + // 供 AppState 构造编辑器 LLM / 图片 client 时复用同一条配置路径。 + config.tiantoken_base_url = + read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]).unwrap_or_default(); + config.tiantoken_api_key = read_first_non_empty_env(&["TIANTOKEN_API_KEY"]); + + if let Some(tiantoken_image_request_timeout_ms) = + read_first_positive_u64_env(&["TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS"]) + { + // Tiantoken 图片链路独立计时:`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 不再改写 + // VectorEngine 的图片超时,两个 provider 各用自己的配置与默认值。 + config.tiantoken_image_request_timeout_ms = tiantoken_image_request_timeout_ms; } if let Some(vector_engine_audio_request_timeout_ms) = @@ -1481,20 +1510,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 { - 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 { keys.iter().find_map(|key| { env::var(key).ok().and_then(|value| { @@ -1800,7 +1815,6 @@ mod tests { DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS, DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode, LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role, - tiantoken_api_key, tiantoken_base_url, }; use std::{ fs, @@ -2008,37 +2022,52 @@ mod tests { } #[test] - fn tiantoken_provider_prefers_new_env_names_over_legacy_vector_engine_config() { + fn tiantoken_credentials_never_fall_back_to_vector_engine_env() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); - let mut config = AppConfig::default(); - config.vector_engine_base_url = "https://vector.example.invalid".to_string(); - config.vector_engine_api_key = Some("legacy-vector-key".to_string()); unsafe { std::env::set_var("TIANTOKEN_BASE_URL", " https://api.tiantoken.example/ "); std::env::set_var("TIANTOKEN_API_KEY", " tiantoken-key "); + std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.example.invalid"); + std::env::set_var("VECTOR_ENGINE_API_KEY", "legacy-vector-key"); } + let config = AppConfig::from_env(); + + assert_eq!(config.tiantoken_base_url, "https://api.tiantoken.example/"); + assert_eq!(config.tiantoken_api_key.as_deref(), Some("tiantoken-key")); assert_eq!( - tiantoken_base_url(&config), - "https://api.tiantoken.example/" + config.vector_engine_base_url, + "https://vector.example.invalid" + ); + assert_eq!( + config.vector_engine_api_key.as_deref(), + Some("legacy-vector-key") ); - assert_eq!(tiantoken_api_key(&config).as_deref(), Some("tiantoken-key")); unsafe { std::env::remove_var("TIANTOKEN_BASE_URL"); std::env::remove_var("TIANTOKEN_API_KEY"); } + let config = AppConfig::from_env(); + + assert_eq!(config.tiantoken_base_url, ""); + assert_eq!(config.tiantoken_api_key, None); assert_eq!( - tiantoken_base_url(&config), + config.vector_engine_base_url, "https://vector.example.invalid" ); assert_eq!( - tiantoken_api_key(&config).as_deref(), + config.vector_engine_api_key.as_deref(), Some("legacy-vector-key") ); + + unsafe { + std::env::remove_var("VECTOR_ENGINE_BASE_URL"); + std::env::remove_var("VECTOR_ENGINE_API_KEY"); + } } #[test] @@ -2274,6 +2303,7 @@ mod tests { std::env::remove_var("VECTOR_ENGINE_BASE_URL"); std::env::remove_var("VECTOR_ENGINE_API_KEY"); std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"); + std::env::remove_var("TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS"); std::env::remove_var("HYPER3D_BASE_URL"); std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL"); @@ -2292,6 +2322,7 @@ mod tests { std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.internal.example"); std::env::set_var("VECTOR_ENGINE_API_KEY", "vector-engine-key"); std::env::set_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", "210000"); + std::env::set_var("TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS", "180000"); std::env::set_var("HYPER3D_BASE_URL", "https://model.internal.example/api/v2"); std::env::set_var("DASHSCOPE_SCENE_IMAGE_MODEL", "scene-model"); std::env::set_var("DASHSCOPE_REFERENCE_IMAGE_MODEL", "reference-model"); @@ -2318,6 +2349,8 @@ mod tests { "https://vector.internal.example" ); assert_eq!(config.vector_engine_image_request_timeout_ms, 210_000); + // 两个 provider 的图片超时互不覆盖:Tiantoken 用 TIANTOKEN_*,VectorEngine 用 VECTOR_ENGINE_*。 + assert_eq!(config.tiantoken_image_request_timeout_ms, 180_000); assert_eq!( config.hyper3d_base_url, "https://model.internal.example/api/v2" @@ -2343,6 +2376,7 @@ mod tests { std::env::remove_var("VECTOR_ENGINE_BASE_URL"); std::env::remove_var("VECTOR_ENGINE_API_KEY"); std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"); + std::env::remove_var("TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS"); std::env::remove_var("HYPER3D_BASE_URL"); std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL"); diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 53d051abc..4255b0b62 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::{Display, Formatter}; use platform_editor_agent::agent::asset::ImageId; @@ -28,7 +29,7 @@ use platform_editor_agent::agent::tools::generate_video::{ }; use platform_editor_agent::framework::error::PromptError; use platform_editor_agent::framework::tool::{Tool, ToolDyn, null_tool_args_as_missing}; -use platform_image::GPT_IMAGE_2_MODEL; +use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_MODEL}; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Value, json}; @@ -224,6 +225,47 @@ fn parse_internal( }) } +/// 把持久化的历史 GPT Image 2 模型别名(`gpt-image-2`)改写为当前 +/// 业务模型 `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) -> Cow<'_, Value> { + let Some(model) = value.get("model").and_then(Value::as_str) else { + return Cow::Borrowed(value); + }; + if model.trim() != GPT_IMAGE_2_MODEL { + return Cow::Borrowed(value); + } + 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()), + ); + } + Cow::Owned(normalized) +} + +/// 图片类工具的 `validate_args` 入口:先归一历史模型别名,再走强类型反序列化。 +fn parse_invalid_image_args( + tool_name: &str, + value: &Value, +) -> Result { + let normalized = normalize_legacy_image_model_args(value); + parse_invalid_args(tool_name, &normalized) +} + +/// 图片类工具的 `pricing` 入口:即使调用方直接传入原始 args,也要先归一历史模型别名。 +fn parse_internal_image_args( + label: &str, + value: &Value, +) -> Result { + let normalized = normalize_legacy_image_model_args(value); + parse_internal(label, &normalized) +} + /// SFX V2 的显式 `duration: null` 表示自动时长,必须原样保留,不能被通用的 /// “顶层 null 当缺省”兼容层恢复成手动 5 秒。但其余字段仍要走该兼容层: /// `model` 是无 Option 的 String,LLM 传 `model: null` 时若不剥离会直接反序列化失败。 @@ -305,13 +347,14 @@ fn editor_agent_image_mud_points( kind: Option<&str>, model: &str, image_size: Option<&str>, + has_reference_images: bool, ) -> u32 { - pricing.image_generation_mud_points(kind, Some(model), image_size) + pricing.image_generation_mud_points(kind, Some(model), image_size, has_reference_images) } impl EditorAgentTool for GenerateImageTool { fn validate_args(&self, args: &Value) -> Result { - let args: GenerateImageToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateImageToolArgs = parse_invalid_image_args(Self::NAME, args)?; GenerateImageTool::validate_args(self, &args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; serialize_normalized_args(Self::NAME, &args) @@ -322,12 +365,13 @@ impl EditorAgentTool for GenerateImageTool { pricing: &EditorGenerationPricingConfig, args: &Value, ) -> Result { - let args: GenerateImageToolArgs = parse_internal("generate image args", args)?; + let args: GenerateImageToolArgs = parse_internal_image_args("generate image args", args)?; Ok(editor_agent_image_mud_points( pricing, None, args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -337,7 +381,8 @@ impl EditorAgentTool for GenerateImageTool { pricing: &EditorGenerationPricingConfig, ) -> Result { let price_mud_points = self.pricing(pricing, args)?; - let args: GenerateImageToolArgs = parse_internal("generate image display args", args)?; + let args: GenerateImageToolArgs = + parse_internal_image_args("generate image display args", args)?; let mut display_args = EditorAgentToolCallDisplayArgs::default(); push_image_generation_display_args( &mut display_args, @@ -358,7 +403,7 @@ impl EditorAgentTool for GenerateImageTool { context: &EditorAgentPrepareJobContext<'_>, ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; - let args: GenerateImageToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateImageToolArgs = parse_invalid_image_args(Self::NAME, args)?; let title = args.prompt.clone(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; let payload = EditorImageGenerationRequest { @@ -397,7 +442,7 @@ impl EditorAgentTool for GenerateImageTool { args: &Value, result: &Value, ) -> Result { - let args: GenerateImageToolArgs = parse_internal("generate image args", args)?; + let args: GenerateImageToolArgs = parse_internal_image_args("generate image args", args)?; let result: EditorImageGenerationResult = parse_internal("generate image result", result)?; Ok(GenerateImageTool::format_execute_message( self, &args, &result, @@ -418,7 +463,7 @@ impl EditorAgentTool for GenerateImageTool { impl EditorAgentTool for GenerateCharacterTool { fn validate_args(&self, args: &Value) -> Result { - let args: GenerateCharacterToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateCharacterToolArgs = parse_invalid_image_args(Self::NAME, args)?; GenerateCharacterTool::validate_args(self, &args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; serialize_normalized_args(Self::NAME, &args) @@ -429,12 +474,14 @@ impl EditorAgentTool for GenerateCharacterTool { pricing: &EditorGenerationPricingConfig, args: &Value, ) -> Result { - let args: GenerateCharacterToolArgs = parse_internal("generate character args", args)?; + let args: GenerateCharacterToolArgs = + parse_internal_image_args("generate character args", args)?; Ok(editor_agent_image_mud_points( pricing, Some("character"), args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -445,7 +492,7 @@ impl EditorAgentTool for GenerateCharacterTool { ) -> Result { let price_mud_points = self.pricing(pricing, args)?; let args: GenerateCharacterToolArgs = - parse_internal("generate character display args", args)?; + parse_internal_image_args("generate character display args", args)?; let mut display_args = EditorAgentToolCallDisplayArgs::default(); push_image_generation_display_args( &mut display_args, @@ -466,7 +513,7 @@ impl EditorAgentTool for GenerateCharacterTool { context: &EditorAgentPrepareJobContext<'_>, ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; - let args: GenerateCharacterToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateCharacterToolArgs = parse_invalid_image_args(Self::NAME, args)?; let title = args.prompt.clone(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; let payload = EditorImageGenerationRequest { @@ -505,7 +552,8 @@ impl EditorAgentTool for GenerateCharacterTool { args: &Value, result: &Value, ) -> Result { - let args: GenerateCharacterToolArgs = parse_internal("generate character args", args)?; + let args: GenerateCharacterToolArgs = + parse_internal_image_args("generate character args", args)?; let result: EditorImageGenerationResult = parse_internal("generate character result", result)?; Ok(GenerateCharacterTool::format_execute_message( @@ -528,7 +576,7 @@ impl EditorAgentTool for GenerateCharacterTool { impl EditorAgentTool for GenerateUiDesignTool { fn validate_args(&self, args: &Value) -> Result { - let args: GenerateUiDesignToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateUiDesignToolArgs = parse_invalid_image_args(Self::NAME, args)?; GenerateUiDesignTool::validate_args(self, &args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; serialize_normalized_args(Self::NAME, &args) @@ -539,12 +587,14 @@ impl EditorAgentTool for GenerateUiDesignTool { pricing: &EditorGenerationPricingConfig, args: &Value, ) -> Result { - let args: GenerateUiDesignToolArgs = parse_internal("generate UI design args", args)?; + let args: GenerateUiDesignToolArgs = + parse_internal_image_args("generate UI design args", args)?; Ok(editor_agent_image_mud_points( pricing, Some("ui-design"), args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -555,7 +605,7 @@ impl EditorAgentTool for GenerateUiDesignTool { ) -> Result { let price_mud_points = self.pricing(pricing, args)?; let args: GenerateUiDesignToolArgs = - parse_internal("generate UI design display args", args)?; + parse_internal_image_args("generate UI design display args", args)?; let mut display_args = EditorAgentToolCallDisplayArgs::default(); push_image_generation_display_args( &mut display_args, @@ -576,7 +626,7 @@ impl EditorAgentTool for GenerateUiDesignTool { context: &EditorAgentPrepareJobContext<'_>, ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; - let args: GenerateUiDesignToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateUiDesignToolArgs = parse_invalid_image_args(Self::NAME, args)?; let title = args.prompt.clone(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; let payload = EditorImageGenerationRequest { @@ -615,7 +665,8 @@ impl EditorAgentTool for GenerateUiDesignTool { args: &Value, result: &Value, ) -> Result { - let args: GenerateUiDesignToolArgs = parse_internal("generate UI design args", args)?; + let args: GenerateUiDesignToolArgs = + parse_internal_image_args("generate UI design args", args)?; let result: EditorImageGenerationResult = parse_internal("generate UI design result", result)?; Ok(GenerateUiDesignTool::format_execute_message( @@ -638,7 +689,7 @@ impl EditorAgentTool for GenerateUiDesignTool { impl EditorAgentTool for EditImageTool { fn validate_args(&self, args: &Value) -> Result { - let args: EditImageToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: EditImageToolArgs = parse_invalid_image_args(Self::NAME, args)?; if let Some(error) = EditImageTool::validate_args(self, &args) { return Err(EditorAgentToolError::invalid_args(error.to_string())); } @@ -650,13 +701,10 @@ impl EditorAgentTool for EditImageTool { pricing: &EditorGenerationPricingConfig, args: &Value, ) -> Result { - let _: EditImageToolArgs = parse_internal("edit image args", args)?; - Ok(editor_agent_image_mud_points( - pricing, - Some("quick-edit"), - GPT_IMAGE_2_MODEL, - Some("1K"), - )) + let args: EditImageToolArgs = parse_internal_image_args("edit image args", args)?; + // 中文注释:修改图片按编辑 concrete model 执行,计价必须走编辑档, + // 不能沿用生成档(admin 分开调价时生成档价格即错价)。 + Ok(pricing.image_edit_model_mud_points(Some(args.model.as_str()), Some("1K"))) } fn build_display_args( @@ -665,7 +713,7 @@ impl EditorAgentTool for EditImageTool { pricing: &EditorGenerationPricingConfig, ) -> Result { let price_mud_points = self.pricing(pricing, args)?; - let args: EditImageToolArgs = parse_internal("edit image display args", args)?; + let args: EditImageToolArgs = parse_internal_image_args("edit image display args", args)?; let mut display_args = EditorAgentToolCallDisplayArgs::default(); push_string_display_arg(&mut display_args, "prompt", "修改要求", args.prompt); push_string_display_arg(&mut display_args, "model", "模型", args.model); @@ -694,7 +742,7 @@ impl EditorAgentTool for EditImageTool { context: &EditorAgentPrepareJobContext<'_>, ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; - let args: EditImageToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: EditImageToolArgs = parse_invalid_image_args(Self::NAME, args)?; let source_reference_id = self .context .image_metadata(&args.object_image_id) @@ -739,7 +787,7 @@ impl EditorAgentTool for EditImageTool { args: &Value, result: &Value, ) -> Result { - let args: EditImageToolArgs = parse_internal("edit image args", args)?; + let args: EditImageToolArgs = parse_internal_image_args("edit image args", args)?; let result: EditorImageEditResult = parse_internal("edit image result", result)?; Ok(EditImageTool::format_execute_message(self, args, result)) } @@ -785,7 +833,7 @@ fn resolve_icon_spec_reference_id<'a>( impl EditorAgentTool for GenerateIconSpritesheetTool { fn validate_args(&self, args: &Value) -> Result { - let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateIconSpritesheetToolArgs = parse_invalid_image_args(Self::NAME, args)?; let args = GenerateIconSpritesheetTool::normalize_args(self, args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?; @@ -798,12 +846,13 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { args: &Value, ) -> Result { let args: GenerateIconSpritesheetToolArgs = - parse_internal("generate icon spritesheet args", args)?; + parse_internal_image_args("generate icon spritesheet args", args)?; Ok(editor_agent_image_mud_points( pricing, Some("icon"), args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -814,7 +863,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { ) -> Result { let price_mud_points = self.pricing(pricing, args)?; let args: GenerateIconSpritesheetToolArgs = - parse_internal("generate icon spritesheet display args", args)?; + parse_internal_image_args("generate icon spritesheet display args", args)?; let mut display_args = EditorAgentToolCallDisplayArgs::default(); push_string_display_arg( &mut display_args, @@ -855,7 +904,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { context: &EditorAgentPrepareJobContext<'_>, ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; - let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?; + let args: GenerateIconSpritesheetToolArgs = parse_invalid_image_args(Self::NAME, args)?; let reference_id = resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?.to_string(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; @@ -903,7 +952,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { result: &Value, ) -> Result { let args: GenerateIconSpritesheetToolArgs = - parse_internal("generate icon spritesheet args", args)?; + parse_internal_image_args("generate icon spritesheet args", args)?; let result: EditorIconSpritesheetResult = parse_internal("generate icon spritesheet result", result)?; Ok(GenerateIconSpritesheetTool::format_execute_message( @@ -1250,7 +1299,10 @@ mod tests { use spacetime_client::{EditorCanvasRecord, EditorCanvasViewportRecord}; use super::*; - use crate::editor_generation_config::load_editor_generation_pricing_from_paths; + use crate::editor_generation_config::{ + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + load_editor_generation_pricing_from_paths, + }; fn pricing() -> EditorGenerationPricingConfig { load_editor_generation_pricing_from_paths(None).expect("default editor pricing should load") @@ -1350,7 +1402,7 @@ mod tests { "unknown": "drop-me" })) .expect("typed UI validation should pass"); - assert_eq!(normalized["model"], GPT_IMAGE_2_MODEL); + assert_eq!(normalized["model"], GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(normalized["aspect_ratio"], "1:1"); assert_eq!(normalized["image_size"], "1K"); assert!(normalized.get("unknown").is_none()); @@ -1495,6 +1547,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_MODEL, + "image_size": "2K" + })) + .expect("legacy generation alias should validate"); + assert_eq!(normalized["model"], GPT_IMAGE_2_5_BUSINESS_NAME); + } + #[test] fn dyn_pricing_keeps_all_existing_tool_formulas() { let pricing = pricing(); @@ -1512,6 +1589,7 @@ mod tests { ), ( GenerateUiDesignTool::NAME, + // 历史持久化的 `gpt-image-2` 会先归一为 `gpt-image-2.5`,所以计价仍走当前档位。 json!({ "prompt": "界面", "model": platform_image::GPT_IMAGE_2_MODEL, "image_size": "2K" }), 5, ), @@ -1552,11 +1630,67 @@ mod tests { } #[test] - fn dyn_pricing_uses_the_supplied_runtime_snapshot() { + fn dyn_pricing_routes_edit_tools_and_referenced_generation_to_the_edit_tier() { let mut pricing = pricing(); pricing .models - .get_mut(GPT_IMAGE_2_MODEL) + .get_mut(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) + .expect("edit tier pricing should exist") + .prices + .insert("1K".to_string(), 41); + pricing + .models + .get_mut(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + .expect("generation tier pricing should exist") + .prices + .insert("1K".to_string(), 7); + + let context = context_with_image("image-1"); + let edit = + editor_agent_tool(EditImageTool::NAME, &context).expect("edit tool should resolve"); + let edit_args = edit + .validate_args(&json!({ "object_image_id": "image-1", "prompt": "修改" })) + .expect("edit args should validate"); + assert_eq!(edit.pricing(&pricing, &edit_args).expect("pricing"), 41); + + let generate = editor_agent_tool(GenerateImageTool::NAME, &context) + .expect("image tool should resolve"); + let without_reference = generate + .validate_args(&json!({ + "prompt": "生成图片", + "model": GPT_IMAGE_2_5_BUSINESS_NAME, + "image_size": "1K" + })) + .expect("generation args should validate"); + assert_eq!( + generate + .pricing(&pricing, &without_reference) + .expect("pricing"), + 7 + ); + let with_reference = generate + .validate_args(&json!({ + "prompt": "生成图片", + "model": GPT_IMAGE_2_5_BUSINESS_NAME, + "image_size": "1K", + "reference_image_ids": ["image-1"] + })) + .expect("referenced generation args should validate"); + assert_eq!( + generate + .pricing(&pricing, &with_reference) + .expect("pricing"), + 41 + ); + } + + #[test] + fn dyn_pricing_uses_the_supplied_runtime_snapshot() { + let mut pricing = pricing(); + // 历史 `gpt-image-2` 输入会归一为 `gpt-image-2.5-flare-c`,所以要改这个档位的价格。 + pricing + .models + .get_mut(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) .expect("gpt image pricing should exist") .prices .insert("2K".to_string(), 37); diff --git a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs index 1af1817e0..5c5609d3d 100644 --- a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs +++ b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs @@ -1480,8 +1480,8 @@ mod tests { fn editor_llm_test_config(base_url: String) -> AppConfig { AppConfig { - vector_engine_base_url: base_url, - vector_engine_api_key: Some("test-vector-engine-key".to_string()), + tiantoken_base_url: base_url, + tiantoken_api_key: Some("test-tiantoken-key".to_string()), llm_max_retries: 0, ..AppConfig::default() } diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 11fdaa818..9679a80d7 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -17,6 +17,9 @@ pub(crate) const EDITOR_GENERATION_PRICING_DEFAULT_JSON: &str = include_str!("../config/editor-generation-pricing.default.json"); const EDITOR_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; +pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS: &str = "gpt-image-2.5"; +pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION: &str = "gpt-image-2.5-flare-c"; +pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT: &str = "gpt-image-2.5-sunburst-c"; const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2"; const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana"; @@ -94,6 +97,25 @@ pub(crate) enum EditorGenerationPricingError { } impl EditorGenerationPricingConfig { + /// Public main-site projection: provider-specific GPT Image keys remain an + /// admin/server concern and are represented by the business model name. + pub(crate) fn public_projection(&self) -> Self { + let mut models = self.models.clone(); + if let Some(generation) = models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + .cloned() + { + models.insert( + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS.to_string(), + generation, + ); + } + models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION); + models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT); + models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2); + Self { models } + } + pub(crate) fn image_model_mud_points( &self, model: Option<&str>, @@ -114,15 +136,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 { if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() { return price_mud_points; } - let normalized_model = normalize_non_empty_model(model, EDITOR_IMAGE_MODEL_GPT_IMAGE_2); + // 中文注释:生成规范缺省模型与 normalize_editor_generation_options 保持一致, + // 未显式传模型时按 GPT Image 2.5 生成档计价,不要落到 nanobanana 档。 + let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) { + None => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + Some(_) => normalize_editor_image_model(model), + }; read_tier_price( &self.models, normalized_model, - EDITOR_IMAGE_MODEL_GPT_IMAGE_2, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, SPEC_IMAGE_PRICE_SIZE, SPEC_IMAGE_PRICE_SIZE, ) @@ -133,10 +180,18 @@ impl EditorGenerationPricingConfig { kind: Option<&str>, model: Option<&str>, image_size: Option<&str>, + has_reference_images: bool, ) -> u32 { if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() { return price_mud_points; } + // 中文注释:生成任务带参考图时执行端改走编辑 concrete model,计价必须同步切到编辑档, + // 否则会出现「生成档扣费 + 编辑档执行/审计」的分裂。 + if editor_image_generation_concrete_model(model, has_reference_images) + == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT + { + return self.image_edit_model_mud_points(model, image_size); + } match kind.map(str::trim) { Some("spec") => self.spec_model_mud_points(model), Some("character" | "icon" | "ui-design" | "publication-material" | "scene") => { @@ -221,7 +276,13 @@ impl EditorGenerationPricingConfig { )?; validate_required_tier_prices( &self.models, - EDITOR_IMAGE_MODEL_GPT_IMAGE_2, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + EditorGenerationPricingUnit::PerGeneration, + REQUIRED_GPT_IMAGE_SIZES, + )?; + validate_required_tier_prices( + &self.models, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EditorGenerationPricingUnit::PerGeneration, REQUIRED_GPT_IMAGE_SIZES, )?; @@ -322,46 +383,34 @@ fn load_editor_generation_pricing_from_candidates( if let Some(path) = selected_override_path { let override_json = fs::read_to_string(path).map_err(EditorGenerationPricingError::Io)?; let source = path.to_string_lossy(); - let mut override_config = + let override_config = serde_json::from_str::(override_json.as_str()) .map_err(EditorGenerationPricingError::Json)?; - backfill_legacy_sfx_pricing(&mut override_config, &config, source.as_ref())?; - override_config.validate().map_err(|error| match error { + config = overlay_editor_generation_pricing(&config, override_config); + config.validate().map_err(|error| match error { EditorGenerationPricingError::Invalid(message) => { EditorGenerationPricingError::Invalid(format!("{source}: {message}")) } other => other, })?; - config = override_config; } config.validate()?; Ok(config) } -fn backfill_legacy_sfx_pricing( - config: &mut EditorGenerationPricingConfig, - fallback: &EditorGenerationPricingConfig, - source: &str, -) -> Result<(), EditorGenerationPricingError> { - if config - .models - .contains_key(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) - { - return Ok(()); - } - let pricing = fallback - .models - .get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) - .cloned() - .ok_or_else(|| { - EditorGenerationPricingError::Invalid(format!( - "{source}: 受控默认配置缺少模型 {EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS}" - )) - })?; - config - .models - .insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing); - Ok(()) +/// 以编译内置默认定价为基线,叠加持久化配置里显式给出的模型条目。 +/// +/// 磁盘 override 与 SpacetimeDB record 只需要保存「被显式设置过的模型」: +/// 缺失条目一律继承编译内置默认值。这样新增模型不再需要为每个历史配置 +/// 手写一次受控 backfill,旧配置缺 key 也不会再阻止 api-server 启动; +/// 校验仍然保留,用于拦截持久化配置里写坏的模型条目。 +pub(crate) fn overlay_editor_generation_pricing( + default_config: &EditorGenerationPricingConfig, + override_config: EditorGenerationPricingConfig, +) -> EditorGenerationPricingConfig { + let mut models = default_config.models.clone(); + models.extend(override_config.models); + EditorGenerationPricingConfig { models } } pub(crate) fn parse_editor_generation_pricing_json( @@ -394,8 +443,14 @@ pub(crate) fn editor_image_generation_mud_points( kind: Option<&str>, model: Option<&str>, image_size: Option<&str>, + has_reference_images: bool, ) -> u32 { - default_runtime_pricing().image_generation_mud_points(kind, model, image_size) + default_runtime_pricing().image_generation_mud_points( + kind, + model, + image_size, + has_reference_images, + ) } #[cfg(test)] @@ -437,7 +492,12 @@ fn default_runtime_pricing() -> EditorGenerationPricingConfig { fn normalize_editor_image_model(model: Option<&str>) -> &'static str { match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2, + Some( + EDITOR_IMAGE_MODEL_GPT_IMAGE_2 + | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS + | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + ) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, Some(EDITOR_IMAGE_MODEL_NANOBANANA2) | Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS) | Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => EDITOR_IMAGE_MODEL_NANOBANANA2, @@ -445,6 +505,39 @@ fn normalize_editor_image_model(model: Option<&str>) -> &'static str { } } +fn normalize_editor_image_edit_model(model: Option<&str>) -> &'static str { + match model.map(str::trim).filter(|value| !value.is_empty()) { + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, + Some( + EDITOR_IMAGE_MODEL_GPT_IMAGE_2 + | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS + | EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + ) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, + _ => normalize_editor_image_model(model), + } +} + +/// 生成类图片任务最终发送给 provider 的 concrete model。 +/// +/// 中文注释:GPT Image 2.5 生成任务只要带参考图,执行端就必须改走 edits multipart, +/// 因此执行、审计与计价统一使用编辑 concrete model `gpt-image-2.5-sunburst-c`; +/// nanobanana 的生成与编辑共用同一个 concrete model,不受参考图影响。 +/// 这里是生成类任务选择 concrete model 的唯一入口,dispatch 与计价都不得再各写一份判断。 +pub(crate) fn editor_image_generation_concrete_model( + model: Option<&str>, + has_reference_images: bool, +) -> &'static str { + match model.map(str::trim).filter(|value| !value.is_empty()) { + Some( + EDITOR_IMAGE_MODEL_NANOBANANA2 + | EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS + | EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS, + ) => EDITOR_IMAGE_MODEL_NANOBANANA2, + _ if has_reference_images => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, + _ => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + } +} + fn normalize_editor_generation_image_price_size( model: &str, image_size: Option<&str>, @@ -695,7 +788,8 @@ mod tests { config.image_generation_mud_points( Some("character"), Some("gpt-image-2"), - Some("2K") + Some("2K"), + false ), queued_price ); @@ -774,14 +868,21 @@ mod tests { } #[test] - fn editor_generation_pricing_legacy_override_backfills_new_sfx_model() { - let temp_dir = unique_temp_dir("genarrative-pricing-legacy-sfx-test"); + fn editor_generation_pricing_legacy_override_inherits_missing_models_from_default() { + let temp_dir = unique_temp_dir("genarrative-pricing-legacy-inherit-test"); std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); let override_path = temp_dir.join("editor-generation-pricing.override.json"); + let default_config = default_runtime_pricing(); + // 中文注释:模拟升级前的 override:没有 GPT Image 2.5 的两个具体 key, + // 也没有新加入的 ElevenLabs 音效模型;显式设置过的是历史 Vidu 音效定价。 let mut legacy_config = default_runtime_pricing(); - legacy_config - .models - .remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS); + for model in [ + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, + EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS, + ] { + legacy_config.models.remove(model); + } legacy_config .models .get_mut(EDITOR_SOUND_EFFECT_MODEL_VIDU) @@ -794,11 +895,27 @@ mod tests { .expect("legacy override should write"); let loaded = load_editor_generation_pricing_from_paths(Some(&override_path)) - .expect("legacy override should backfill the new SFX model"); + .expect("legacy override should inherit missing models from the default"); assert_eq!( - loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)), - 5 + loaded + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION), + default_config + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + ); + assert_eq!( + loaded.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT), + default_config + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) + ); + assert_eq!( + loaded.models.get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS), + default_config + .models + .get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) ); assert_eq!( loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_VIDU)), @@ -808,17 +925,20 @@ mod tests { } #[test] - fn editor_generation_pricing_legacy_override_still_rejects_other_missing_models() { - let temp_dir = unique_temp_dir("genarrative-pricing-legacy-required-model-test"); + fn editor_generation_pricing_override_still_rejects_invalid_model_entries() { + let temp_dir = unique_temp_dir("genarrative-pricing-override-invalid-test"); std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); let override_path = temp_dir.join("editor-generation-pricing.override.json"); let mut legacy_config = default_runtime_pricing(); - legacy_config - .models - .remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS); + // 中文注释:缺模型可以继承默认值,但显式写坏的模型条目仍必须被拒绝。 legacy_config .models .remove(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO); + legacy_config + .models + .get_mut(EDITOR_SOUND_EFFECT_MODEL_VIDU) + .expect("legacy sound effect pricing should exist") + .price = Some(0); std::fs::write( &override_path, serde_json::to_string(&legacy_config).expect("legacy config should serialize"), @@ -826,12 +946,11 @@ mod tests { .expect("legacy override should write"); let error = load_editor_generation_pricing_from_paths(Some(&override_path)) - .expect_err("only the new SFX model may be backfilled"); + .expect_err("invalid override entry should be rejected"); assert!( - error - .to_string() - .contains(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO) + error.to_string().contains(EDITOR_SOUND_EFFECT_MODEL_VIDU), + "unexpected error: {error}" ); std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove"); } @@ -842,7 +961,8 @@ mod tests { editor_image_generation_mud_points( Some("image"), Some("gemini-3.1-flash-image-preview"), - Some("0.5K") + Some("0.5K"), + false ), 8 ); @@ -850,7 +970,8 @@ mod tests { editor_image_generation_mud_points( Some("character"), Some("gemini-3.1-flash-image-preview"), - Some("1K") + Some("1K"), + false ), 12 ); @@ -858,24 +979,124 @@ mod tests { editor_image_generation_mud_points( Some("scene"), Some("gemini-3.1-flash-image-preview"), - Some("1K") + Some("1K"), + false ), 12 ); assert_eq!( - editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("1K")), + editor_image_generation_mud_points( + Some("ui-design"), + Some("gpt-image-2"), + Some("1K"), + false + ), 3 ); assert_eq!( - editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("2K")), + editor_image_generation_mud_points( + Some("ui-design"), + Some("gpt-image-2"), + Some("2K"), + false + ), 5 ); assert_eq!( - editor_image_generation_mud_points(Some("spec"), Some("gpt-image-2"), Some("2K")), + editor_image_generation_mud_points( + Some("spec"), + Some("gpt-image-2"), + Some("2K"), + false + ), 5 ); } + #[test] + fn editor_image_generation_with_references_prices_at_the_edit_tier() { + let mut config = default_runtime_pricing(); + for (model, price_1k, price_2k) in [ + (EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, 7, 9), + (EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, 41, 43), + ] { + let prices = &mut config + .models + .get_mut(model) + .expect("gpt image pricing should exist") + .prices; + prices.insert("1K".to_string(), price_1k); + prices.insert("2K".to_string(), price_2k); + } + + assert_eq!( + editor_image_generation_concrete_model( + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + false + ), + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION + ); + assert_eq!( + editor_image_generation_concrete_model( + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + true + ), + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT + ); + assert_eq!( + editor_image_generation_concrete_model(Some(EDITOR_IMAGE_MODEL_NANOBANANA2), true), + EDITOR_IMAGE_MODEL_NANOBANANA2 + ); + + // GPT Image 2.5 生成带参考图按编辑 concrete model 执行,计价必须同步切到编辑档。 + assert_eq!( + config.image_generation_mud_points( + Some("character"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("1K"), + true + ), + 41 + ); + assert_eq!( + config.image_generation_mud_points( + Some("character"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("1K"), + false + ), + 7 + ); + assert_eq!( + config.image_generation_mud_points( + Some("spec"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("2K"), + true + ), + 43 + ); + assert_eq!( + config.image_generation_mud_points( + Some("spec"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("2K"), + false + ), + 9 + ); + // nanobanana 生成与编辑共用一个 concrete model,参考图不改变计价档位。 + assert_eq!( + config.image_generation_mud_points( + None, + Some(EDITOR_IMAGE_MODEL_NANOBANANA2), + Some("1K"), + true + ), + 12 + ); + } + #[test] fn editor_video_and_character_animation_share_model_resolution_rates() { assert_eq!( @@ -915,6 +1136,9 @@ mod tests { let temp_dir = unique_temp_dir("genarrative-pricing-test"); std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); let override_path = temp_dir.join("editor-generation-pricing.override.json"); + // 中文注释:override 只需要保存要覆盖的模型;要改 GPT Image 定价必须显式写出 + // 两个具体 provider key,缺 key 的旧配置继承编译内置默认值(见 + // editor_generation_pricing_legacy_override_inherits_missing_models_from_default)。 std::fs::write( &override_path, r#"{ @@ -927,6 +1151,14 @@ mod tests { "unit": "perGeneration", "prices": { "1K": 31, "2K": 62 } }, + "gpt-image-2.5-flare-c": { + "unit": "perGeneration", + "prices": { "1K": 31, "2K": 62 } + }, + "gpt-image-2.5-sunburst-c": { + "unit": "perGeneration", + "prices": { "1K": 31, "2K": 62 } + }, "seedance2.0-fast": { "unit": "perSecond", "prices": { "480p": 11, "720p": 22, "1080p": 44 } @@ -997,10 +1229,14 @@ mod tests { "unit": "perGeneration", "prices": { "0.5K": 8, "1K": 12, "2K": 24 } }, - "gpt-image-2": { + "gpt-image-2.5-flare-c": { "unit": "perGeneration", "prices": { "1K": 20 } }, + "gpt-image-2.5-sunburst-c": { + "unit": "perGeneration", + "prices": { "1K": 3, "2K": 5 } + }, "seedance2.0-fast": { "unit": "perSecond", "prices": { "480p": 10, "720p": 20, "1080p": 40 } @@ -1034,6 +1270,10 @@ mod tests { ) .expect_err("missing 2K price should fail"); - assert!(error.to_string().contains("gpt-image-2 缺少 2K")); + assert!( + error + .to_string() + .contains("models.gpt-image-2.5-flare-c 缺少 2K 的泥点配置") + ); } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index cd4e3542e..90ea6fcb9 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -77,6 +77,7 @@ use crate::{ with_editor_generation_durable_billing_boundary, }, auth::{AuthenticatedAccessToken, optional_access_token_from_headers}, + editor_generation_config::editor_image_generation_concrete_model, editor_generation_queue::{ EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, @@ -113,8 +114,9 @@ use crate::{ }, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, OpenAiImageSettings, - OpenAiReferenceImage, build_openai_image_http_client, + DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, + GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, + OpenAiImageSettings, OpenAiReferenceImage, build_openai_image_http_client, create_openai_image_edit_with_references_and_model, create_openai_image_generation_with_model, create_openai_nanobanana_generate_content, require_openai_image_settings, @@ -377,6 +379,9 @@ pub(crate) struct EditorImagePromptBuildOutput { pub(crate) struct EditorImageProviderRequest<'a> { pub(crate) model: &'a str, + /// Concrete provider/pricing route selected by api-server. The business + /// model remains `model`; this value must never be exposed to normal UI. + pub(crate) provider_model: &'a str, pub(crate) prompt: &'a str, pub(crate) negative_prompt: Option<&'a str>, pub(crate) size: &'a str, @@ -395,7 +400,7 @@ pub(crate) async fn request_editor_generated_images( create_openai_nanobanana_generate_content( http_client, settings, - request.model, + request.provider_model, request.prompt, request.negative_prompt, request.aspect_ratio, @@ -408,7 +413,7 @@ pub(crate) async fn request_editor_generated_images( create_openai_image_generation_with_model( http_client, settings, - request.model, + request.provider_model, request.prompt, request.negative_prompt, request.size, @@ -421,7 +426,7 @@ pub(crate) async fn request_editor_generated_images( create_openai_image_edit_with_references_and_model( http_client, settings, - request.model, + request.provider_model, request.prompt, request.negative_prompt, request.size, @@ -2016,7 +2021,10 @@ pub async fn get_editor_generation_pricing( "message": error.to_string(), })) })?; - Ok(json_success_body(Some(&request_context), pricing)) + Ok(json_success_body( + Some(&request_context), + pricing.public_projection(), + )) } pub async fn list_editor_projects( @@ -2867,7 +2875,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( matches!(normalized_kind, Some("publication-material")); let generation_options = normalize_editor_generation_options( if is_ui_design_generation || is_publication_material_generation { - Some(GPT_IMAGE_2_MODEL) + Some(GPT_IMAGE_2_5_BUSINESS_NAME) } else { payload.model.as_deref() }, @@ -2913,6 +2921,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( normalized_kind, Some(generation_options.model), Some(generation_options.image_size), + editor_generation_has_reference_images(payload.reference_image_srcs.as_deref()), ), ); let source_entity_id = editor_generation_source_entity_id( @@ -2957,7 +2966,7 @@ pub(crate) async fn validate_editor_image_generation_parameters_for_owner( matches!(normalized_kind, Some("publication-material")); let generation_options = normalize_editor_generation_options( if is_ui_design_generation || is_publication_material_generation { - Some(GPT_IMAGE_2_MODEL) + Some(GPT_IMAGE_2_5_BUSINESS_NAME) } else { payload.model.as_deref() }, @@ -3004,6 +3013,7 @@ pub(crate) async fn validate_editor_image_generation_parameters_for_owner( normalized_kind, Some(generation_options.model), Some(generation_options.image_size), + editor_generation_has_reference_images(payload.reference_image_srcs.as_deref()), ); Ok(()) } @@ -3090,7 +3100,7 @@ where matches!(normalized_kind, Some("publication-material")); let generation_options = normalize_editor_generation_options( if is_ui_design_generation || is_publication_material_generation { - Some(GPT_IMAGE_2_MODEL) + Some(GPT_IMAGE_2_5_BUSINESS_NAME) } else { payload.model.as_deref() }, @@ -3215,6 +3225,7 @@ where normalized_kind, Some(generation_options.model), Some(generation_options.image_size), + !reference_images.is_empty(), ) }; let prompt_generation_inputs = payload.generation_inputs.take(); @@ -3284,6 +3295,10 @@ where &settings, EditorImageProviderRequest { model: generation_options.model, + provider_model: editor_image_generation_concrete_model( + Some(generation_options.model), + !reference_images.is_empty(), + ), prompt: submitted_prompt.as_str(), negative_prompt, size: provider_request_size.as_ref(), @@ -3935,7 +3950,7 @@ async fn resolve_editor_image_edit_price( "message": error.to_string(), })) })? - .image_generation_mud_points(Some("quick-edit"), Some(model), Some(price_size)); + .image_edit_model_mud_points(Some(model), Some(price_size)); Ok(expected_price_mud_points) } @@ -4078,15 +4093,23 @@ pub(crate) fn normalize_editor_generation_options( image_size: Option<&str>, ) -> EditorGenerationOptions { let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(GPT_IMAGE_2_MODEL) => GPT_IMAGE_2_MODEL, + // TODO: compatibility for persisted/client legacy `gpt-image-2`; do not + // rewrite the stored record, only use the current business value when a + // new task is submitted. + Some( + GPT_IMAGE_2_MODEL + | GPT_IMAGE_2_5_BUSINESS_NAME + | GPT_IMAGE_2_5_GENERATION_MODEL + | GPT_IMAGE_2_5_EDIT_MODEL, + ) => GPT_IMAGE_2_5_BUSINESS_NAME, Some( EDITOR_IMAGE_MODEL_NANOBANANA2 | EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS | EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS, ) => EDITOR_IMAGE_MODEL_NANOBANANA2, - // 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2; + // 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范统一归一到 gpt-image-2.5; // 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。 - None => GPT_IMAGE_2_MODEL, + None => GPT_IMAGE_2_5_BUSINESS_NAME, _ => EDITOR_IMAGE_MODEL_NANOBANANA2, }; let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio); @@ -6116,7 +6139,7 @@ pub(crate) async fn edit_editor_image_for_owner_with_source_snapshot( create_openai_image_edit_with_references_and_model( &http_client, &settings, - generation_options.model, + GPT_IMAGE_2_5_EDIT_MODEL, prompt.as_str(), Some("文字、水印、边框、按钮、UI 控件、变形主体"), provider_size.as_str(), @@ -8563,7 +8586,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( create_openai_image_edit_with_references_and_model( &http_client, &settings, - generation_options.model, + GPT_IMAGE_2_5_EDIT_MODEL, prompt.as_str(), None, generation_options.provider_size.as_str(), @@ -12060,7 +12083,8 @@ async fn resolve_editor_ui_design_asset_extraction_price( "message": error.to_string(), })) })? - .image_generation_mud_points(Some("icon"), model, image_size); + // 中文注释:UI 素材提取始终以 UI 设计图作为来源图,GPT Image 2.5 固定走编辑 concrete model。 + .image_generation_mud_points(Some("icon"), model, image_size, true); Ok(expected_price_mud_points) } @@ -13256,6 +13280,14 @@ pub(crate) fn normalize_editor_reference_image_sources(sources: Option<&[String] .collect() } +/// 生成类任务是否带参考图。 +/// +/// 中文注释:dispatch、审计与计价必须共用同一个判据,否则又会出现 +/// 「生成档计价 + 编辑档执行」的分裂。 +pub(crate) fn editor_generation_has_reference_images(sources: Option<&[String]>) -> bool { + !normalize_editor_reference_image_sources(sources).is_empty() +} + pub(crate) fn ensure_editor_reference_image_source_limit( sources: Option<&[String]>, limit: usize, @@ -17905,7 +17937,7 @@ mod tests { #[test] fn editor_generation_dimensions_follow_model_options() { let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K")); - assert_eq!(default_generation.model, GPT_IMAGE_2_MODEL); + assert_eq!(default_generation.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(default_generation.size, "1024x1024"); let nanobanana = normalize_editor_generation_options( @@ -17935,7 +17967,7 @@ mod tests { assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9"); let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K")); - assert_eq!(gpt.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(gpt.size, "683x1024"); assert_eq!(gpt.provider_size, "688x1024"); assert_eq!(gpt.aspect_ratio, "2:3"); @@ -17950,21 +17982,21 @@ mod tests { let gpt_cover = normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K")); - assert_eq!(gpt_cover.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt_cover.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(gpt_cover.size, "1024x768"); assert_eq!(gpt_cover.provider_size, "1024x768"); assert_eq!(gpt_cover.aspect_ratio, "4:3"); let gpt_landscape_2k = normalize_editor_generation_options(Some("gpt-image-2"), Some("16:9"), Some("2K")); - assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(gpt_landscape_2k.size, "2048x1152"); assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9"); assert_eq!(gpt_landscape_2k.image_size, "2K"); let gpt_portrait_2k = normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K")); - assert_eq!(gpt_portrait_2k.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt_portrait_2k.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(gpt_portrait_2k.size, "1152x2048"); assert_eq!(gpt_portrait_2k.aspect_ratio, "9:16"); assert_eq!(gpt_portrait_2k.image_size, "2K"); @@ -18167,7 +18199,7 @@ mod tests { } #[test] - fn publication_material_generation_is_locked_to_gpt_image_2() { + fn publication_material_generation_is_locked_to_gpt_image_2_5() { let source = concat!( include_str!("editor_project_icon.rs"), include_str!("editor_project.rs") @@ -18179,7 +18211,7 @@ mod tests { "let effective_price_mud_points", &[ "is_publication_material_generation", - "Some(GPT_IMAGE_2_MODEL)", + "Some(GPT_IMAGE_2_5_BUSINESS_NAME)", ], ); } @@ -20036,6 +20068,7 @@ mod tests { Some("icon"), None, Some("1K"), + true, ), spritesheet_resource: None, spritesheet_asset: None, @@ -20077,7 +20110,7 @@ mod tests { ) .expect("UI extraction dimensions should pass"); - assert_eq!(generation_options.model, GPT_IMAGE_2_MODEL); + assert_eq!(generation_options.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(generation_options.size, "2048x2048"); assert_eq!( resolve_editor_ui_design_asset_extraction_price( diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index c109dab63..1e252094e 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -42,6 +42,7 @@ use crate::{ execute_billable_asset_operation_with_cost, with_editor_generation_durable_billing_boundary, }, auth::AuthenticatedAccessToken, + editor_generation_config::editor_image_generation_concrete_model, editor_generation_queue::{ EDITOR_ICON_SPEC_GENERATION_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EditorGenerationQueuedResponse, editor_generation_queue_state, @@ -57,14 +58,17 @@ use crate::{ normalize_generated_image_asset_mime, }, http_error::AppError, - openai_image_generation::{build_openai_image_http_client, require_openai_image_settings}, + openai_image_generation::{ + GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, build_openai_image_http_client, + require_openai_image_settings, + }, platform_errors::{is_retryable_llm_error, map_llm_error, map_oss_error}, prompt::icon_spec::{PROMPT_MAX_LEN, build_extra_param_prompt, get_gen_icon_spec_prompt}, request_context::RequestContext, state::AppState, }; -const ICON_SPEC_MODEL: &str = "gpt-image-2"; +const ICON_SPEC_MODEL: &str = "gpt-image-2.5"; const ICON_SPEC_ASPECT_RATIO: &str = "16:9"; const ICON_SPEC_IMAGE_SIZE: &str = "2K"; const ICON_SPEC_SIZE: &str = "2048x1152"; @@ -243,7 +247,8 @@ pub(crate) async fn resolve_editor_icon_spritesheet_price( "message": error.to_string(), })) })? - .image_generation_mud_points(Some("icon"), model, image_size)) + // 中文注释:图标素材图集始终以图标规范图为参考图,GPT Image 2.5 固定走编辑 concrete model。 + .image_generation_mud_points(Some("icon"), model, image_size, true)) } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -657,7 +662,7 @@ pub(crate) async fn enqueue_icon_spec_generation_for_owner( payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; validate_icon_spec_reference_for_owner(state, caller, &payload).await?; - let price_mud_points = resolve_icon_spec_price(state).await?; + let price_mud_points = resolve_icon_spec_price(state, payload.reference_id.is_some()).await?; enqueue_editor_generation_job_for_caller( state, request_context, @@ -737,7 +742,10 @@ async fn validate_icon_spec_reference_for_owner( Ok(()) } -async fn resolve_icon_spec_price(state: &AppState) -> Result { +async fn resolve_icon_spec_price( + state: &AppState, + has_reference_images: bool, +) -> Result { Ok(state .editor_generation_pricing() .await @@ -750,6 +758,7 @@ async fn resolve_icon_spec_price(state: &AppState) -> Result { Some("spec"), Some(ICON_SPEC_MODEL), Some(ICON_SPEC_IMAGE_SIZE), + has_reference_images, )) } @@ -1771,6 +1780,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( &settings, EditorImageProviderRequest { model: generation_options.model, + provider_model: editor_image_generation_concrete_model( + Some(generation_options.model), + !reference_images.is_empty(), + ), prompt: prompt.as_str(), negative_prompt: None, size, diff --git a/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs b/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs index 83b5d96c6..2fdf03b94 100644 --- a/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs +++ b/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs @@ -574,8 +574,8 @@ mod tests { fn editor_llm_test_config(base_url: String, max_retries: u32) -> AppConfig { AppConfig { - vector_engine_base_url: base_url, - vector_engine_api_key: Some("test-vector-engine-key".to_string()), + tiantoken_base_url: base_url, + tiantoken_api_key: Some("test-tiantoken-key".to_string()), llm_max_retries: max_retries, ..AppConfig::default() } diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 2cf90c425..b63a20b8b 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -1,17 +1,13 @@ use axum::http::StatusCode; use platform_image::{ - DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage, - VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, - create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, - create_vector_engine_image_edit_with_references_and_model, - create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, - create_vector_engine_nanobanana_generate_content, + DownloadedImage, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings, + NANOBANANA_2_MODEL, PlatformImageError, PlatformImageStatusHint, ReferenceImage, + VECTOR_ENGINE_PROVIDER, build_image_http_client, create_image_edit_with_references_and_model, + create_image_generation_with_model, create_nanobanana_generate_content, + missing_reference_images_error, resolve_image_provider, }; #[cfg(test)] -use platform_image::{ - build_vector_engine_image_request_body, vector_engine_images_edit_url, - vector_engine_images_generation_url, -}; +use platform_image::{build_image_request_body, images_edit_url, images_generation_url}; use serde_json::{Value, json}; use std::time::Instant; use time::OffsetDateTime; @@ -27,9 +23,10 @@ use crate::{ tracking::record_external_generation_run_after_success, }; -pub(crate) use platform_image::GPT_IMAGE_2_MODEL; -#[cfg(test)] -use platform_image::VECTOR_ENGINE_GPT_IMAGE_2_MODEL; +pub(crate) use platform_image::{ + GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, + GPT_IMAGE_2_MODEL, +}; pub(crate) type OpenAiGeneratedImages = GeneratedImages; pub(crate) type DownloadedOpenAiImage = DownloadedImage; @@ -45,6 +42,8 @@ pub(crate) struct OpenAiImageSettings { pub external_api_audit_user_id: Option, pub external_api_audit_profile_id: Option, pub external_api_audit_request_id: Option, + pub tiantoken_client: Option, + pub vector_engine_client: Option, } impl std::fmt::Debug for OpenAiImageSettings { @@ -71,6 +70,11 @@ impl std::fmt::Debug for OpenAiImageSettings { "external_api_audit_request_id", &self.external_api_audit_request_id, ) + .field("tiantoken_client_enabled", &self.tiantoken_client.is_some()) + .field( + "vector_engine_client_enabled", + &self.vector_engine_client.is_some(), + ) .finish() } } @@ -104,65 +108,29 @@ pub(crate) fn require_openai_image_settings( Ok(OpenAiImageSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), - request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1), + request_timeout_ms: state.config.tiantoken_image_request_timeout_ms.max(1), request_deadline: None, external_api_audit_state: Some(state.clone()), external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: Some(state.tiantoken_image_client().clone()), + vector_engine_client: Some(state.vector_engine_image_client().clone()), }) } pub(crate) fn build_openai_image_http_client( settings: &OpenAiImageSettings, ) -> Result { - build_vector_engine_image_http_client(&settings.provider_settings()) - .map_err(map_platform_image_error) -} - -pub(crate) async fn create_openai_image_generation( - http_client: &reqwest::Client, - settings: &OpenAiImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - candidate_count: u32, - reference_images: &[String], - failure_context: &str, -) -> Result { - let started_at_micros = current_utc_micros(); - let request_payload = json!({ - "size": size, - "candidateCount": candidate_count, - "promptChars": prompt.chars().count(), - "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), - "referenceImageCount": reference_images.len(), - }); - let result = create_vector_engine_image_generation( - http_client, - &settings.provider_settings(), - prompt, - negative_prompt, - size, - candidate_count, - reference_images, - failure_context, - ) - .await; - map_platform_image_result( - settings, - result, - "image_generation", - failure_context, - request_payload, - started_at_micros, - ) - .await + if let Some(client) = settings.tiantoken_client.as_ref() { + return Ok(client.http_client().clone()); + } + build_image_http_client(&settings.provider_settings()).map_err(map_platform_image_error) } #[allow(clippy::too_many_arguments)] pub(crate) async fn create_openai_image_generation_with_model( - http_client: &reqwest::Client, + _http_client: &reqwest::Client, settings: &OpenAiImageSettings, model: &str, prompt: &str, @@ -181,9 +149,11 @@ pub(crate) async fn create_openai_image_generation_with_model( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": reference_images.len(), }); - let result = create_vector_engine_image_generation_with_model( - http_client, - &settings.provider_settings(), + let provider_client = settings.client_for_model(model); + let provider_settings = settings.provider_settings_for_model(model); + let result = create_image_generation_with_model( + provider_client.http_client(), + &provider_settings, model, prompt, negative_prompt, @@ -206,7 +176,7 @@ pub(crate) async fn create_openai_image_generation_with_model( #[allow(clippy::too_many_arguments)] pub(crate) async fn create_openai_nanobanana_generate_content( - http_client: &reqwest::Client, + _http_client: &reqwest::Client, settings: &OpenAiImageSettings, model: &str, prompt: &str, @@ -225,9 +195,11 @@ pub(crate) async fn create_openai_nanobanana_generate_content( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": reference_images.len(), }); - let result = create_vector_engine_nanobanana_generate_content( - http_client, - &settings.provider_settings(), + let provider_client = settings.client_for_model(model); + let provider_settings = settings.provider_settings_for_model(model); + let result = create_nanobanana_generate_content( + provider_client.http_client(), + &provider_settings, model, prompt, negative_prompt, @@ -248,86 +220,9 @@ pub(crate) async fn create_openai_nanobanana_generate_content( .await } -pub(crate) async fn create_openai_image_edit( - http_client: &reqwest::Client, - settings: &OpenAiImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - reference_image: &OpenAiReferenceImage, - failure_context: &str, -) -> Result { - let started_at_micros = current_utc_micros(); - let request_payload = json!({ - "size": size, - "promptChars": prompt.chars().count(), - "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), - "referenceImageCount": 1, - }); - let result = create_vector_engine_image_edit( - http_client, - &settings.provider_settings(), - prompt, - negative_prompt, - size, - reference_image, - failure_context, - ) - .await; - map_platform_image_result( - settings, - result, - "image_edit", - failure_context, - request_payload, - started_at_micros, - ) - .await -} - -pub(crate) async fn create_openai_image_edit_with_references( - http_client: &reqwest::Client, - settings: &OpenAiImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - candidate_count: u32, - reference_images: &[OpenAiReferenceImage], - failure_context: &str, -) -> Result { - let started_at_micros = current_utc_micros(); - let request_payload = json!({ - "size": size, - "candidateCount": candidate_count, - "promptChars": prompt.chars().count(), - "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), - "referenceImageCount": reference_images.len(), - }); - let result = create_vector_engine_image_edit_with_references( - http_client, - &settings.provider_settings(), - prompt, - negative_prompt, - size, - candidate_count, - reference_images, - failure_context, - ) - .await; - map_platform_image_result( - settings, - result, - "image_edit_with_references", - failure_context, - request_payload, - started_at_micros, - ) - .await -} - #[allow(clippy::too_many_arguments)] pub(crate) async fn create_openai_image_edit_with_references_and_model( - http_client: &reqwest::Client, + _http_client: &reqwest::Client, settings: &OpenAiImageSettings, model: &str, prompt: &str, @@ -345,9 +240,30 @@ pub(crate) async fn create_openai_image_edit_with_references_and_model( "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), "referenceImageCount": reference_images.len(), }); - let result = create_vector_engine_image_edit_with_references_and_model( - http_client, - &settings.provider_settings(), + // 中文注释:先做本地参考图校验,避免 provider 客户端缺失时在校验前 panic。 + // provider 归属与下方非空参考图路径保持一致,按模型解析而不是固定 Tiantoken。 + if reference_images.is_empty() { + let provider = match resolve_image_provider(model) { + Ok(provider) => provider, + // 这里只为一个本地校验错误选归属标签;未知模型无法归属 provider 时 + // 显式回落到 Tiantoken,不再用 unwrap_or 静默吞掉解析失败。 + Err(_) => ImageProvider::Tiantoken, + }; + return map_platform_image_result( + settings, + Err(missing_reference_images_error(provider, 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, prompt, negative_prompt, @@ -376,7 +292,7 @@ pub(crate) fn build_openai_image_request_body( candidate_count: u32, reference_images: &[String], ) -> Value { - build_vector_engine_image_request_body( + build_image_request_body( prompt, negative_prompt, size, @@ -386,6 +302,24 @@ pub(crate) fn build_openai_image_request_body( } impl OpenAiImageSettings { + fn client_for_model(&self, model: &str) -> &ImageProviderClient { + if model == NANOBANANA_2_MODEL { + self.vector_engine_client + .as_ref() + .expect("vector engine image client is initialized at startup") + } else { + self.tiantoken_client + .as_ref() + .expect("tiantoken image client is initialized at startup") + } + } + + fn provider_settings_for_model(&self, model: &str) -> ImageProviderSettings { + let mut settings = self.client_for_model(model).settings().clone(); + settings.request_deadline = self.request_deadline; + settings + } + pub(crate) fn with_external_api_audit_actor( mut self, user_id: Option, @@ -409,8 +343,9 @@ impl OpenAiImageSettings { self } - pub(crate) fn provider_settings(&self) -> VectorEngineImageSettings { - VectorEngineImageSettings { + pub(crate) fn provider_settings(&self) -> ImageProviderSettings { + ImageProviderSettings { + provider: ImageProvider::Tiantoken, base_url: self.base_url.clone(), api_key: self.api_key.clone(), request_timeout_ms: self.request_timeout_ms.max(1), @@ -429,9 +364,6 @@ async fn map_platform_image_result( ) -> Result { match result { Ok(value) => { - for audit in &value.recovered_failure_audits { - record_openai_image_failure_audit_if_configured(settings, audit).await; - } if let Some(state) = settings.external_api_audit_state.as_ref() { record_external_generation_run_after_success( state, @@ -446,7 +378,6 @@ async fn map_platform_image_result( Some(json!({ "imageCount": value.images.len(), "actualPromptChars": value.actual_prompt.as_ref().map(|prompt| prompt.chars().count()), - "recoveredFailureCount": value.recovered_failure_audits.len(), })), ) .await; @@ -454,9 +385,6 @@ async fn map_platform_image_result( Ok(value) } Err(error) => { - for audit in error.recovered_failure_audits() { - record_openai_image_failure_audit_if_configured(settings, audit).await; - } if let Some(state) = settings.external_api_audit_state.as_ref() { record_external_generation_run_after_success( state, @@ -510,7 +438,6 @@ pub(crate) fn build_openai_image_failure_audit_draft( } pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError { - let error = error.into_final_error(); let status = match error.status_hint() { PlatformImageStatusHint::BadRequest => StatusCode::BAD_REQUEST, PlatformImageStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE, @@ -555,9 +482,6 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError { details["rawExcerpt"] = json!(raw_excerpt); } PlatformImageError::MissingImage { .. } => {} - PlatformImageError::FallbackFailed { .. } => { - unreachable!("fallback wrapper should be removed before HTTP error mapping") - } } if let Some(audit) = error.audit() { @@ -578,13 +502,13 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError { } #[cfg(test)] -fn vector_engine_images_generation_url_for_test(settings: &OpenAiImageSettings) -> String { - vector_engine_images_generation_url(&settings.provider_settings()) +fn images_generation_url_for_test(settings: &OpenAiImageSettings) -> String { + images_generation_url(&settings.provider_settings()) } #[cfg(test)] -fn vector_engine_images_edit_url_for_test(settings: &OpenAiImageSettings) -> String { - vector_engine_images_edit_url(&settings.provider_settings()) +fn images_edit_url_for_test(settings: &OpenAiImageSettings) -> String { + images_edit_url(&settings.provider_settings()) } #[cfg(test)] @@ -611,6 +535,8 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, } .with_external_api_audit_context(&request_context, None, None); @@ -631,7 +557,7 @@ mod tests { &["data:image/png;base64,abcd".to_string()], ); - assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); assert_eq!(body["size"], "1536x1024"); assert_eq!(body["n"], 2); assert!(body.get("official_fallback").is_none()); @@ -650,6 +576,8 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), @@ -660,14 +588,16 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, }; assert_eq!( - vector_engine_images_generation_url_for_test(&root_settings), + images_generation_url_for_test(&root_settings), "https://vector.example/v1/images/generations" ); assert_eq!( - vector_engine_images_generation_url_for_test(&v1_settings), + images_generation_url_for_test(&v1_settings), "https://vector.example/v1/images/generations" ); } @@ -683,6 +613,8 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), @@ -693,14 +625,16 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, }; assert_eq!( - vector_engine_images_edit_url_for_test(&root_settings), + images_edit_url_for_test(&root_settings), "https://vector.example/v1/images/edits" ); assert_eq!( - vector_engine_images_edit_url_for_test(&v1_settings), + images_edit_url_for_test(&v1_settings), "https://vector.example/v1/images/edits" ); } @@ -716,12 +650,15 @@ mod tests { external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, + tiantoken_client: None, + vector_engine_client: None, }; let http_client = reqwest::Client::new(); - let result = create_openai_image_edit_with_references( + let result = create_openai_image_edit_with_references_and_model( &http_client, &settings, + GPT_IMAGE_2_5_EDIT_MODEL, "提示词", None, "1:1", @@ -764,7 +701,7 @@ mod tests { latency_ms: Some(321), prompt_chars: Some(42), reference_image_count: Some(1), - image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + image_model: Some(GPT_IMAGE_2_MODEL), }; let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft( &build_external_api_failure_draft_from_platform_image_audit(&audit), @@ -782,10 +719,7 @@ mod tests { assert_eq!(tracking.metadata["retryable"], true); assert_eq!(tracking.metadata["promptChars"], 42); assert_eq!(tracking.metadata["referenceImageCount"], 1); - assert_eq!( - tracking.metadata["imageModel"], - VECTOR_ENGINE_GPT_IMAGE_2_MODEL - ); + assert_eq!(tracking.metadata["imageModel"], GPT_IMAGE_2_MODEL); } } diff --git a/server-rs/crates/api-server/src/raw_image.rs b/server-rs/crates/api-server/src/raw_image.rs index 0c243e0d1..03926e13a 100644 --- a/server-rs/crates/api-server/src/raw_image.rs +++ b/server-rs/crates/api-server/src/raw_image.rs @@ -6,8 +6,8 @@ use axum::{ use bytes::Bytes; use image::{ImageDecoder, ImageFormat, ImageReader}; use platform_image::{ - GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, - RawImageEditImage, RawImageEditOptions, create_vector_engine_raw_image_edit, + GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, GPT_IMAGE_2_5_EDIT_MODEL, RAW_IMAGE_MAX_EDGE, + RAW_IMAGE_MAX_PIXELS, RawImageEditImage, RawImageEditOptions, create_raw_image_edit, validate_raw_image_edit_dimensions, }; use serde::Serialize; @@ -148,7 +148,7 @@ pub(crate) async fn edit_raw_image( }); let started_at_micros = (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64; let operation = async move { - let generated = match create_vector_engine_raw_image_edit( + let generated = match create_raw_image_edit( &http_client, &provider_settings, prepared.prompt.as_str(), @@ -572,7 +572,7 @@ async fn raw_image_edit_price(state: &AppState, width: u32, height: u32) -> Resu .editor_generation_pricing() .await .map(|pricing| { - pricing.image_generation_mud_points(Some("quick-edit"), Some("gpt-image-2"), Some(tier)) + pricing.image_edit_model_mud_points(Some(GPT_IMAGE_2_5_EDIT_MODEL), Some(tier)) }) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index bf96f65c3..825439adb 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -23,6 +23,9 @@ use platform_auth::{ RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token, }; +use platform_image::{ + ImageProvider, ImageProviderClient, ImageProviderSettings, build_image_provider_client, +}; use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider, OpenAiChatTokenBudgetField}; use platform_matting::{MattingClient, MattingConfig}; use platform_oss::template_library::TemplateLibraryStore; @@ -40,10 +43,11 @@ use tokio::sync::{Mutex as AsyncMutex, Semaphore, broadcast}; use tracing::{info, warn}; use crate::config::AppConfig; +#[cfg(test)] +use crate::editor_generation_config::EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS; use crate::editor_generation_config::{ - EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS, EditorGenerationModelPricing, - EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore, - EditorGenerationPricingUnit, + EditorGenerationModelPricing, EditorGenerationPricingConfig, EditorGenerationPricingError, + EditorGenerationPricingStore, EditorGenerationPricingUnit, }; use crate::tracking_outbox::TrackingOutbox; use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox}; @@ -318,6 +322,8 @@ pub struct AppStateInner { /// 非 Suno 的文本、图片和旧版音频生成 provider 配置。 tiantoken_base_url: String, tiantoken_api_key: Option, + vector_engine_image_client: ImageProviderClient, + tiantoken_image_client: ImageProviderClient, matting_client: Option, bgfilter_provider_http_client: reqwest::Client, bgfilter_worker_http_client: reqwest::Client, @@ -454,7 +460,7 @@ fn editor_generation_pricing_to_records( fn editor_generation_pricing_from_record( record: EditorGenerationPricingConfigRecord, - legacy_fallback: &EditorGenerationPricingConfig, + base_config: &EditorGenerationPricingConfig, ) -> Result { let mut models = BTreeMap::new(); for pricing in record.models { @@ -494,19 +500,13 @@ fn editor_generation_pricing_from_record( ))); } } - if !models.contains_key(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) { - let pricing = legacy_fallback - .models - .get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) - .cloned() - .ok_or_else(|| { - EditorGenerationPricingError::Invalid(format!( - "本地模型定价配置缺少模型 {EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS}" - )) - })?; - models.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing); - } - let config = EditorGenerationPricingConfig { models }; + // 中文注释:升级前写入的 record 只保存当时存在的模型(例如单个历史 + // gpt-image-2 key)。缺失条目统一继承本地已校验配置,不再为每个新模型 + // 维护受控 backfill;写坏的条目仍由 validate() 拦截。 + let config = crate::editor_generation_config::overlay_editor_generation_pricing( + base_config, + EditorGenerationPricingConfig { models }, + ); config.validate()?; Ok(config) } @@ -621,8 +621,20 @@ impl AppState { config.editor_generation_pricing_override_path.clone(), ) .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; - let tiantoken_base_url = crate::config::tiantoken_base_url(&config); - let tiantoken_api_key = crate::config::tiantoken_api_key(&config); + let tiantoken_base_url = config.tiantoken_base_url.clone(); + let tiantoken_api_key = config.tiantoken_api_key.clone(); + let vector_engine_image_client = build_required_image_provider_client( + ImageProvider::VectorEngine, + config.vector_engine_base_url.clone(), + config.vector_engine_api_key.clone(), + config.vector_engine_image_request_timeout_ms, + )?; + let tiantoken_image_client = build_required_image_provider_client( + ImageProvider::Tiantoken, + tiantoken_base_url.clone(), + tiantoken_api_key.clone(), + config.tiantoken_image_request_timeout_ms, + )?; let llm_client = build_llm_client(&config)?; let vector_engine_llm_client = build_vector_engine_llm_client( &config, @@ -718,6 +730,8 @@ impl AppState { vector_engine_llm_client, tiantoken_base_url, tiantoken_api_key, + vector_engine_image_client, + tiantoken_image_client, matting_client, bgfilter_provider_http_client, bgfilter_worker_http_client, @@ -1667,6 +1681,14 @@ impl AppState { self.tiantoken_api_key.as_deref() } + pub(crate) fn vector_engine_image_client(&self) -> &ImageProviderClient { + &self.vector_engine_image_client + } + + pub(crate) fn tiantoken_image_client(&self) -> &ImageProviderClient { + &self.tiantoken_image_client + } + pub fn matting_client(&self) -> Option<&MattingClient> { self.matting_client.as_ref() } @@ -2478,6 +2500,70 @@ fn build_project_snapshot_oss_client( Ok(Some(OssClient::new(oss_config))) } +fn build_required_image_provider_client( + provider: ImageProvider, + base_url: String, + api_key: Option, + request_timeout_ms: u64, +) -> Result { + let (base_url_env, api_key_env) = required_image_provider_env_vars(provider); + #[cfg(test)] + let (base_url, api_key) = ( + if base_url.trim().is_empty() { + "http://127.0.0.1".to_string() + } else { + base_url + }, + api_key.or_else(|| Some("test-key".to_string())), + ); + #[cfg(not(test))] + let (base_url, api_key) = (base_url, api_key); + let base_url = base_url.trim().trim_end_matches('/'); + if base_url.is_empty() { + return Err(AppStateInitError::DependencyUnavailable(format!( + "{} 图片 provider 缺少 BASE_URL 配置:请设置 {}。两个图片 provider 都在启动时构造,缺失即阻止 api-server 启动,不做 provider 回退。", + provider.as_str(), + base_url_env + ))); + } + let api_key = api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppStateInitError::DependencyUnavailable(format!( + "{} 图片 provider 缺少 API_KEY 配置:请设置 {}。两个图片 provider 都在启动时构造,缺失即阻止 api-server 启动,不做 provider 回退。", + provider.as_str(), + api_key_env + )) + })?; + let client = build_image_provider_client(ImageProviderSettings { + provider, + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: request_timeout_ms.max(1), + request_deadline: None, + }) + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; + info!( + provider = provider.as_str(), + base_url = %base_url, + request_timeout_ms = request_timeout_ms.max(1), + env = base_url_env, + api_key_env, + "图片 provider client 已启用" + ); + Ok(client) +} + +/// 图片 provider 在启动期必须齐备的环境变量名,用于给出可直接执行的报错与启动日志。 +fn required_image_provider_env_vars(provider: ImageProvider) -> (&'static str, &'static str) { + match provider { + ImageProvider::VectorEngine => ("VECTOR_ENGINE_BASE_URL", "VECTOR_ENGINE_API_KEY"), + ImageProvider::Tiantoken => ("TIANTOKEN_BASE_URL", "TIANTOKEN_API_KEY"), + } +} + fn build_oss_client(config: &AppConfig) -> Result, AppStateInitError> { let oss_fields = [ ("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()), @@ -2965,7 +3051,7 @@ mod tests { } #[test] - fn editor_generation_pricing_typed_record_backfills_legacy_sfx_model() { + fn editor_generation_pricing_typed_record_inherits_legacy_sfx_model() { let fallback = crate::editor_generation_config::parse_editor_generation_pricing_json( crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON, "test default pricing", @@ -2991,6 +3077,130 @@ mod tests { ); } + #[test] + fn editor_generation_pricing_typed_record_inherits_legacy_gpt_image_keys() { + use crate::editor_generation_config::{ + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + }; + + let fallback = crate::editor_generation_config::parse_editor_generation_pricing_json( + crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON, + "test default pricing", + ) + .expect("default pricing should parse"); + let mut models = + editor_generation_pricing_to_records(&fallback).expect("pricing should map to records"); + // 中文注释:模拟升级前持久化的旧 record:只有单个历史 gpt-image-2 key。 + models.retain(|pricing| { + pricing.model != EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION + && pricing.model != EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT + }); + let record = EditorGenerationPricingConfigRecord { + config_id: "global".to_string(), + models, + updated_by_admin_user_id: Some("admin:test".to_string()), + updated_at: "2026-09-18T00:00:00Z".to_string(), + updated_at_micros: 2, + }; + + let actual = editor_generation_pricing_from_record(record, &fallback).expect( + "legacy single-key record should inherit missing keys instead of being rejected", + ); + + assert_eq!( + actual.models.get("gpt-image-2"), + fallback.models.get("gpt-image-2") + ); + assert_eq!( + actual + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION), + fallback + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + ); + assert_eq!( + actual.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT), + fallback.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) + ); + } + + #[test] + fn editor_generation_pricing_typed_record_keeps_explicit_gpt_image_keys() { + use crate::editor_generation_config::{ + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + }; + + let fallback = crate::editor_generation_config::parse_editor_generation_pricing_json( + crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON, + "test default pricing", + ) + .expect("default pricing should parse"); + let mut models = + editor_generation_pricing_to_records(&fallback).expect("pricing should map to records"); + for pricing in models.iter_mut() { + if pricing.model == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION { + pricing.prices = vec![ + EditorGenerationPricingTierRecord { + key: "1K".to_string(), + price: 71, + }, + EditorGenerationPricingTierRecord { + key: "2K".to_string(), + price: 72, + }, + ]; + } + if pricing.model == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT { + pricing.prices = vec![ + EditorGenerationPricingTierRecord { + key: "1K".to_string(), + price: 81, + }, + EditorGenerationPricingTierRecord { + key: "2K".to_string(), + price: 82, + }, + ]; + } + if pricing.model == "gpt-image-2" { + pricing.prices = vec![ + EditorGenerationPricingTierRecord { + key: "1K".to_string(), + price: 1, + }, + EditorGenerationPricingTierRecord { + key: "2K".to_string(), + price: 2, + }, + ]; + } + } + let record = EditorGenerationPricingConfigRecord { + config_id: "global".to_string(), + models, + updated_by_admin_user_id: Some("admin:test".to_string()), + updated_at: "2026-09-18T00:00:00Z".to_string(), + updated_at_micros: 3, + }; + + let actual = editor_generation_pricing_from_record(record, &fallback) + .expect("explicit GPT Image 2.5 keys should be preserved"); + + let generation = actual + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + .expect("generation key should exist"); + assert_eq!(generation.prices.get("1K"), Some(&71)); + assert_eq!(generation.prices.get("2K"), Some(&72)); + let edit = actual + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) + .expect("edit key should exist"); + assert_eq!(edit.prices.get("1K"), Some(&81)); + assert_eq!(edit.prices.get("2K"), Some(&82)); + } + #[test] fn editor_generation_pricing_upsert_input_uses_runtime_service_bootstrap_secret() { let mut config = AppConfig::default(); @@ -3153,13 +3363,13 @@ mod tests { } #[test] - fn app_state_builds_editor_agent_llm_client_from_vector_engine_settings() { + fn app_state_builds_editor_agent_llm_client_from_tiantoken_settings() { let mut config = AppConfig::default(); config.llm_api_key = None; config.llm_max_retries = 2; config.llm_retry_backoff_ms = 120_000; - config.vector_engine_base_url = "https://api.vectorengine.test".to_string(); - config.vector_engine_api_key = Some("ve-key".to_string()); + config.tiantoken_base_url = "https://api.tiantoken.test".to_string(); + config.tiantoken_api_key = Some("tiantoken-key".to_string()); let state = AppState::new(config).expect("state should build"); let client = state @@ -3172,7 +3382,7 @@ mod tests { ); assert_eq!( client.config().chat_completions_url(), - "https://api.vectorengine.test/v1/chat/completions" + "https://api.tiantoken.test/v1/chat/completions" ); assert!(!client.config().official_fallback()); assert_eq!( diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs index bbec7d800..c4b37ce83 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs @@ -578,8 +578,8 @@ mod tests { fn editor_llm_test_state(base_url: String, max_retries: u32) -> AppState { AppState::new(AppConfig { - vector_engine_base_url: base_url, - vector_engine_api_key: Some("test-vector-engine-key".to_string()), + tiantoken_base_url: base_url, + tiantoken_api_key: Some("test-tiantoken-key".to_string()), llm_max_retries: max_retries, ..AppConfig::default() }) diff --git a/server-rs/crates/platform-editor-agent/prompts/tools/edit-image/model.txt b/server-rs/crates/platform-editor-agent/prompts/tools/edit-image/model.txt index 50e5e103b..689fb6823 100644 --- a/server-rs/crates/platform-editor-agent/prompts/tools/edit-image/model.txt +++ b/server-rs/crates/platform-editor-agent/prompts/tools/edit-image/model.txt @@ -1 +1 @@ -图片编辑固定使用{GPT_IMAGE_2_MODEL} \ No newline at end of file +图片编辑固定使用{GPT_IMAGE_2_5_BUSINESS_NAME} diff --git a/server-rs/crates/platform-editor-agent/prompts/tools/generate-ui-design/model.txt b/server-rs/crates/platform-editor-agent/prompts/tools/generate-ui-design/model.txt index e457b8bf4..8f84896d0 100644 --- a/server-rs/crates/platform-editor-agent/prompts/tools/generate-ui-design/model.txt +++ b/server-rs/crates/platform-editor-agent/prompts/tools/generate-ui-design/model.txt @@ -1 +1 @@ -UI 设计图固定使用 gpt-image-2。 \ No newline at end of file +UI 设计图固定使用 gpt-image-2.5。 diff --git a/server-rs/crates/platform-editor-agent/prompts/tools/image-options/gpt-image-2-size.txt b/server-rs/crates/platform-editor-agent/prompts/tools/image-options/gpt-image-2-size.txt index 0894d060f..8b2475aee 100644 --- a/server-rs/crates/platform-editor-agent/prompts/tools/image-options/gpt-image-2-size.txt +++ b/server-rs/crates/platform-editor-agent/prompts/tools/image-options/gpt-image-2-size.txt @@ -1 +1 @@ -图片尺寸档位。gpt-image-2 仅支持 1K、2K;默认 1K。 \ No newline at end of file +图片尺寸档位。gpt-image-2.5 仅支持 1K、2K;默认 1K。 diff --git a/server-rs/crates/platform-editor-agent/prompts/tools/image-options/image-size.txt b/server-rs/crates/platform-editor-agent/prompts/tools/image-options/image-size.txt index 39301779e..a525b9c45 100644 --- a/server-rs/crates/platform-editor-agent/prompts/tools/image-options/image-size.txt +++ b/server-rs/crates/platform-editor-agent/prompts/tools/image-options/image-size.txt @@ -1 +1 @@ -图片尺寸档位。nanobanana2 支持 0.5K、1K、2K;gpt-image-2 仅支持 1K、2K;默认 1K。 \ No newline at end of file +图片尺寸档位。nanobanana2 支持 0.5K、1K、2K;gpt-image-2.5 仅支持 1K、2K;默认 1K。 diff --git a/server-rs/crates/platform-editor-agent/prompts/tools/image-options/model.txt b/server-rs/crates/platform-editor-agent/prompts/tools/image-options/model.txt index 21a759daf..23f2b93a5 100644 --- a/server-rs/crates/platform-editor-agent/prompts/tools/image-options/model.txt +++ b/server-rs/crates/platform-editor-agent/prompts/tools/image-options/model.txt @@ -1 +1 @@ -生图模型。默认 gemini-3.1-flash-image-preview(user may call it nanobanana2);也可选择 gpt-image-2。 \ No newline at end of file +生图模型。默认 gemini-3.1-flash-image-preview(user may call it nanobanana2);也可选择 gpt-image-2.5。 diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs index 0aec9e3f7..8d1485cfd 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs @@ -2,7 +2,7 @@ use crate::agent::asset::ImageId; use crate::agent::prompt::{PENDING_USER_CONFIRMATION_MESSAGE, edit_image_tool_description}; use crate::agent::tools::context::EditorToolContext; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; -use platform_image::GPT_IMAGE_2_MODEL; +use platform_image::GPT_IMAGE_2_5_BUSINESS_NAME; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::error::Error; @@ -33,7 +33,7 @@ impl Display for EditImageError { EditImageError::InvalidModel(model) => { write!( f, - "{model} is not a valid model name, only {GPT_IMAGE_2_MODEL} is supported for now." + "{model} is not a valid model name, only {GPT_IMAGE_2_5_BUSINESS_NAME} is supported for now." ) } } @@ -52,7 +52,7 @@ pub struct EditImageToolArgs { pub model: String, } fn default_model_name() -> String { - GPT_IMAGE_2_MODEL.to_string() + GPT_IMAGE_2_5_BUSINESS_NAME.to_string() } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -90,9 +90,9 @@ impl Tool for EditImageTool { // TODO need to introduce size param, but that needs more metadata such as original image size, skip in this version "model": { "type": "string", - "enum": [GPT_IMAGE_2_MODEL], - "default": GPT_IMAGE_2_MODEL, - "description": format!(include_str!("../../../prompts/tools/edit-image/model.txt"), GPT_IMAGE_2_MODEL = GPT_IMAGE_2_MODEL) + "enum": [GPT_IMAGE_2_5_BUSINESS_NAME], + "default": GPT_IMAGE_2_5_BUSINESS_NAME, + "description": format!(include_str!("../../../prompts/tools/edit-image/model.txt"), GPT_IMAGE_2_5_BUSINESS_NAME = GPT_IMAGE_2_5_BUSINESS_NAME) } }, "required": ["object_image_id", "prompt"], @@ -155,7 +155,7 @@ pub struct EditorImageEditResult { impl EditImageTool { /// Validate the semantic correctness of the arguments. pub fn validate_args(&self, args: &EditImageToolArgs) -> Option { - if args.model != GPT_IMAGE_2_MODEL { + if args.model != GPT_IMAGE_2_5_BUSINESS_NAME { return Some(EditImageError::InvalidModel(args.model.clone())); } if args.prompt.trim().is_empty() { @@ -210,7 +210,7 @@ mod tests { "sourceType": "generated", "prompt": "修改图片", "actualPrompt": "修改后的图片", - "model": "gpt-image-2", + "model": "gpt-image-2.5", "taskId": "task-2", "resource": null, "asset": null, diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs index 0f9167212..118d54c6a 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs @@ -10,7 +10,7 @@ use crate::agent::tools::image_generation_options::{ validate_image_generation_options, }; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; -use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; +use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::error::Error; @@ -39,7 +39,7 @@ impl Display for GenerateIconSpritesheetError { match self { Self::InvalidModel(model) => write!( f, - "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}" + "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_5_BUSINESS_NAME}" ), Self::InvalidAspectRatio(aspect_ratio) => { write!(f, "invalid aspect ratio: {aspect_ratio}") diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs index af532107a..377549c84 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs @@ -8,7 +8,7 @@ use crate::agent::tools::image_generation_options::{ validate_image_generation_options, }; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; -use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; +use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::error::Error; @@ -33,11 +33,11 @@ impl Display for GenerateImageError { match self { Self::InvalidModel(model) => write!( f, - "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}" + "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_5_BUSINESS_NAME}" ), Self::UnsupportedUiDesignModel(model) => write!( f, - "{model} is not supported for UI design generation; required model: {GPT_IMAGE_2_MODEL}" + "{model} is not supported for UI design generation; required model: {GPT_IMAGE_2_5_BUSINESS_NAME}" ), Self::InvalidAspectRatio(aspect_ratio) => { write!(f, "invalid aspect ratio: {aspect_ratio}") @@ -222,7 +222,7 @@ mod tests { "sourceType": "generated", "prompt": "生成一张图片", "actualPrompt": "生成一张清晰图片", - "model": "gpt-image-2", + "model": "gpt-image-2.5", "taskId": "task-1", "resource": null, "asset": null, diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs index 97b0928d3..63180e026 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs @@ -12,7 +12,7 @@ use crate::agent::tools::image_generation_options::{ }; use crate::framework::tool::ToolFailureKind; use crate::framework::tool::{Tool, ToolFailure}; -use platform_image::GPT_IMAGE_2_MODEL; +use platform_image::GPT_IMAGE_2_5_BUSINESS_NAME; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -34,7 +34,7 @@ pub struct GenerateUiDesignToolArgs { } fn default_ui_design_model() -> String { - GPT_IMAGE_2_MODEL.to_string() + GPT_IMAGE_2_5_BUSINESS_NAME.to_string() } impl Tool for GenerateUiDesignTool { @@ -54,8 +54,8 @@ impl Tool for GenerateUiDesignTool { "prompt": { "type": "string", "description": include_str!("../../../prompts/tools/generate-ui-design/prompt.txt") }, "model": { "type": "string", - "enum": [GPT_IMAGE_2_MODEL], - "default": GPT_IMAGE_2_MODEL, + "enum": [GPT_IMAGE_2_5_BUSINESS_NAME], + "default": GPT_IMAGE_2_5_BUSINESS_NAME, "description": include_str!("../../../prompts/tools/generate-ui-design/model.txt") }, "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": include_str!("../../../prompts/tools/generate-ui-design/reference-image-ids.txt") }, @@ -95,7 +95,7 @@ impl Tool for GenerateUiDesignTool { impl GenerateUiDesignTool { pub fn validate_args(&self, args: &GenerateUiDesignToolArgs) -> Result<(), GenerateImageError> { - if args.model != GPT_IMAGE_2_MODEL { + if args.model != GPT_IMAGE_2_5_BUSINESS_NAME { return Err(GenerateImageError::UnsupportedUiDesignModel( args.model.clone(), )); @@ -165,11 +165,11 @@ mod tests { assert_eq!( parameters["properties"]["model"]["enum"], - json!([GPT_IMAGE_2_MODEL]) + json!([GPT_IMAGE_2_5_BUSINESS_NAME]) ); assert_eq!( parameters["properties"]["model"]["default"], - GPT_IMAGE_2_MODEL + GPT_IMAGE_2_5_BUSINESS_NAME ); assert_eq!( parameters["properties"]["image_size"]["enum"], @@ -184,7 +184,10 @@ mod tests { .as_array() .is_some_and(|required| required.contains(&json!("model"))) ); - assert!(tool.validate_args(&args(GPT_IMAGE_2_MODEL)).is_ok()); + assert!( + tool.validate_args(&args(GPT_IMAGE_2_5_BUSINESS_NAME)) + .is_ok() + ); assert!(matches!( tool.validate_args(&args(NANOBANANA_2_MODEL)), Err(GenerateImageError::UnsupportedUiDesignModel(model)) if model == NANOBANANA_2_MODEL @@ -198,7 +201,7 @@ mod tests { })) .expect("旧版 UI 设计参数应能反序列化"); - assert_eq!(args.model, GPT_IMAGE_2_MODEL); + assert_eq!(args.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert!(tool().validate_args(&args).is_ok()); } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs index dd208f717..6b58578bc 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs @@ -1,4 +1,4 @@ -use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; +use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL}; use serde_json::{Value, json}; use std::error::Error; use std::fmt::Display; @@ -21,7 +21,7 @@ impl Display for ImageGenerationOptionsError { match self { Self::InvalidModel(model) => write!( f, - "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}" + "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_5_BUSINESS_NAME}" ), Self::InvalidAspectRatio(aspect_ratio) => write!( f, @@ -75,7 +75,7 @@ pub fn validate_image_generation_options( pub fn image_model_parameter_schema() -> Value { json!({ "type": "string", - "enum": [NANOBANANA_2_MODEL, GPT_IMAGE_2_MODEL], + "enum": [NANOBANANA_2_MODEL, GPT_IMAGE_2_5_BUSINESS_NAME], "default": NANOBANANA_2_MODEL, "description": include_str!("../../../prompts/tools/image-options/model.txt") }) @@ -111,7 +111,7 @@ pub fn gpt_image_2_size_parameter_schema() -> Value { pub fn image_model_size_constraint_schema() -> Value { json!({ "if": { - "properties": { "model": { "const": GPT_IMAGE_2_MODEL } }, + "properties": { "model": { "const": GPT_IMAGE_2_5_BUSINESS_NAME } }, // model 省略时运行时默认 nanobanana2,仍允许 0.5K。 "required": ["model"] }, @@ -126,7 +126,7 @@ pub fn image_model_size_constraint_schema() -> Value { fn supported_image_sizes(model: &str) -> Option<&'static [&'static str]> { match model { NANOBANANA_2_MODEL => Some(NANOBANANA_2_IMAGE_SIZES), - GPT_IMAGE_2_MODEL => Some(GPT_IMAGE_2_IMAGE_SIZES), + GPT_IMAGE_2_5_BUSINESS_NAME => Some(GPT_IMAGE_2_IMAGE_SIZES), _ => None, } } @@ -150,14 +150,18 @@ mod tests { } for image_size in GPT_IMAGE_2_IMAGE_SIZES { assert!( - validate_image_generation_options(GPT_IMAGE_2_MODEL, aspect_ratio, image_size,) - .is_ok() + validate_image_generation_options( + GPT_IMAGE_2_5_BUSINESS_NAME, + aspect_ratio, + image_size, + ) + .is_ok() ); } } assert!(matches!( - validate_image_generation_options(GPT_IMAGE_2_MODEL, "1:1", "0.5K"), + validate_image_generation_options(GPT_IMAGE_2_5_BUSINESS_NAME, "1:1", "0.5K"), Err(ImageGenerationOptionsError::InvalidImageSize { .. }) )); assert!(matches!( @@ -192,7 +196,7 @@ mod tests { let model_size_constraint = image_model_size_constraint_schema(); assert_eq!( model_size_constraint["if"]["properties"]["model"]["const"], - GPT_IMAGE_2_MODEL + GPT_IMAGE_2_5_BUSINESS_NAME ); assert_eq!(model_size_constraint["if"]["required"], json!(["model"])); assert_eq!( diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs index 69daaf15b..0425cfbdb 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs @@ -26,7 +26,7 @@ mod tests { use super::generate_video::{GenerateVideoTool, GenerateVideoToolArgs}; use crate::framework::tool::Tool; use platform_audio::{ELEVENLABS_SOUND_EFFECT_MODEL, SUNO_DEFAULT_MODEL}; - use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; + use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL}; use serde_json::json; #[test] @@ -69,11 +69,11 @@ mod tests { assert_eq!(image.model, NANOBANANA_2_MODEL); assert_eq!(image.aspect_ratio, "1:1"); assert_eq!(image.image_size, "1K"); - assert_eq!(edit.model, GPT_IMAGE_2_MODEL); + assert_eq!(edit.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(character.model, NANOBANANA_2_MODEL); assert_eq!(character.aspect_ratio, "1:1"); assert_eq!(character.image_size, "1K"); - assert_eq!(ui_design.model, GPT_IMAGE_2_MODEL); + assert_eq!(ui_design.model, GPT_IMAGE_2_5_BUSINESS_NAME); assert_eq!(ui_design.aspect_ratio, "1:1"); assert_eq!(ui_design.image_size, "1K"); assert_eq!(icon.model, NANOBANANA_2_MODEL); @@ -133,14 +133,17 @@ mod tests { }; assert!(tool.validate_args(&args(NANOBANANA_2_MODEL)).is_ok()); - assert!(tool.validate_args(&args(GPT_IMAGE_2_MODEL)).is_ok()); + assert!( + tool.validate_args(&args(GPT_IMAGE_2_5_BUSINESS_NAME)) + .is_ok() + ); assert!(matches!( tool.validate_args(&args("unknown-image-model")), Err(GenerateImageError::InvalidModel(_)) )); assert_eq!( tool.parameters()["properties"]["model"]["enum"], - json!([NANOBANANA_2_MODEL, GPT_IMAGE_2_MODEL]) + json!([NANOBANANA_2_MODEL, GPT_IMAGE_2_5_BUSINESS_NAME]) ); } @@ -166,7 +169,7 @@ mod tests { )); let ui_args = GenerateUiDesignToolArgs { prompt: "生成游戏主界面".to_string(), - model: GPT_IMAGE_2_MODEL.to_string(), + model: GPT_IMAGE_2_5_BUSINESS_NAME.to_string(), reference_image_ids: vec![missing_image.clone()], aspect_ratio: "16:9".to_string(), image_size: "1K".to_string(), @@ -208,7 +211,7 @@ mod tests { assert_eq!( edit["properties"]["model"]["enum"], - json!([GPT_IMAGE_2_MODEL]) + json!([GPT_IMAGE_2_5_BUSINESS_NAME]) ); assert_eq!( video["properties"]["duration_seconds"]["enum"], @@ -229,7 +232,7 @@ mod tests { for schema in [&image, &character, &icon] { assert_eq!( schema["allOf"][0]["if"]["properties"]["model"]["const"], - json!(GPT_IMAGE_2_MODEL) + json!(GPT_IMAGE_2_5_BUSINESS_NAME) ); assert_eq!( schema["allOf"][0]["then"]["properties"]["image_size"]["enum"], diff --git a/server-rs/crates/platform-image/src/vector_engine/constants.rs b/server-rs/crates/platform-image/src/image_provider/constants.rs similarity index 54% rename from server-rs/crates/platform-image/src/vector_engine/constants.rs rename to server-rs/crates/platform-image/src/image_provider/constants.rs index 2ae789387..ea933b8bd 100644 --- a/server-rs/crates/platform-image/src/vector_engine/constants.rs +++ b/server-rs/crates/platform-image/src/image_provider/constants.rs @@ -1,8 +1,13 @@ pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; -pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c"; +/// Current business model exposed to callers for new image tasks. +pub const GPT_IMAGE_2_5_BUSINESS_NAME: &str = "gpt-image-2.5"; +/// Provider/pricing key for new generation tasks. +pub const GPT_IMAGE_2_5_GENERATION_MODEL: &str = "gpt-image-2.5-flare-c"; +/// Provider/pricing key for explicit image-edit tasks. +pub const GPT_IMAGE_2_5_EDIT_MODEL: &str = "gpt-image-2.5-sunburst-c"; pub const NANOBANANA_2_MODEL: &str = "gemini-3.1-flash-image-preview"; -pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; +pub const TIANTOKEN_PROVIDER: &str = "tiantoken"; pub const VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES: usize = 5; pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14; pub const GPT_IMAGE_2_MIN_PIXELS: u64 = 655_360; diff --git a/server-rs/crates/platform-image/src/image_provider/mod.rs b/server-rs/crates/platform-image/src/image_provider/mod.rs new file mode 100644 index 000000000..0e826b196 --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/mod.rs @@ -0,0 +1,31 @@ +pub(crate) mod constants; +pub(crate) mod protocol; +pub(crate) mod runtime; +pub(crate) mod transport; + +#[cfg(test)] +mod tests; + +pub(crate) use protocol::{payload, request, response}; +pub(crate) use runtime::{audit, budget, error, image_source, types, util}; +pub(crate) use transport::curl as curl_transport; + +pub use constants::TIANTOKEN_PROVIDER; +pub use constants::{ + GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, + GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_PROVIDER, +}; +pub use protocol::{ + build_image_request_body, build_image_request_body_with_model, + build_nanobanana_generate_content_request_body, images_edit_url, images_generation_url, + nanobanana_generate_content_url, normalize_image_size_for_model, resolve_image_provider, +}; +pub use runtime::{ + DownloadedImage, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings, + PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage, + create_image_edit, create_image_edit_with_references, + create_image_edit_with_references_and_model, create_image_generation, + create_image_generation_with_model, create_nanobanana_generate_content, download_remote_image, + missing_reference_images_error, +}; +pub use transport::{build_image_http_client, build_image_provider_client}; diff --git a/server-rs/crates/platform-image/src/image_provider/protocol/mod.rs b/server-rs/crates/platform-image/src/image_provider/protocol/mod.rs new file mode 100644 index 000000000..4cd272c84 --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/protocol/mod.rs @@ -0,0 +1,12 @@ +#[path = "payload.rs"] +pub(crate) mod payload; +#[path = "request.rs"] +pub(crate) mod request; +#[path = "response.rs"] +pub(crate) mod response; + +pub use request::{ + build_image_request_body, build_image_request_body_with_model, + build_nanobanana_generate_content_request_body, images_edit_url, images_generation_url, + nanobanana_generate_content_url, normalize_image_size_for_model, resolve_image_provider, +}; diff --git a/server-rs/crates/platform-image/src/vector_engine/payload.rs b/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs similarity index 95% rename from server-rs/crates/platform-image/src/vector_engine/payload.rs rename to server-rs/crates/platform-image/src/image_provider/protocol/payload.rs index 9f5130a62..1fb999319 100644 --- a/server-rs/crates/platform-image/src/vector_engine/payload.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/payload.rs @@ -1,19 +1,20 @@ use serde_json::Value; -use super::{ - constants::VECTOR_ENGINE_PROVIDER, +use crate::image_provider::{ error::PlatformImageError, + types::ImageProvider, util::{ParsedJsonPayload, truncate_raw}, }; pub(super) fn parse_json_payload( raw_text: &str, failure_context: &str, + provider: ImageProvider, ) -> Result { serde_json::from_str::(raw_text) .map(|payload| ParsedJsonPayload { payload }) .map_err(|error| PlatformImageError::ResponseParse { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("{failure_context}:解析响应失败:{error}"), raw_excerpt: truncate_raw(raw_text), audit: None, @@ -71,7 +72,7 @@ pub(super) fn extract_generation_id(payload: &Value) -> Option { .or_else(|| find_first_string_by_key(payload, "request_id")) } -pub(super) fn extract_image_urls(payload: &Value) -> Vec { +pub(crate) fn extract_image_urls(payload: &Value) -> Vec { let mut urls = Vec::new(); collect_strings_by_key(payload, "url", &mut urls); collect_strings_by_key(payload, "image", &mut urls); @@ -85,7 +86,7 @@ pub(super) fn extract_image_urls(payload: &Value) -> Vec { deduped } -pub(super) fn extract_b64_images(payload: &Value) -> Vec { +pub(crate) fn extract_b64_images(payload: &Value) -> Vec { let mut values = Vec::new(); collect_strings_by_key(payload, "b64_json", &mut values); collect_inline_image_data(payload, &mut values); diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs similarity index 73% rename from server-rs/crates/platform-image/src/vector_engine/request.rs rename to server-rs/crates/platform-image/src/image_provider/protocol/request.rs index 0b008d2a2..f46fe299e 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/request.rs @@ -1,22 +1,23 @@ use serde_json::{Map, Value, json}; -use super::{ +use crate::image_provider::{ constants::{ - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE, - GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, GPT_IMAGE_2_MODEL, + GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, + GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE, GPT_IMAGE_2_MAX_PIXELS, + GPT_IMAGE_2_MIN_PIXELS, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, }, - types::{ReferenceImage, VectorEngineImageSettings}, + types::{ImageProvider, ImageProviderSettings, ReferenceImage}, }; -pub fn build_vector_engine_image_request_body( +pub fn build_image_request_body( prompt: &str, negative_prompt: Option<&str>, size: &str, candidate_count: u32, _reference_images: &[String], ) -> Value { - build_vector_engine_image_request_body_with_model( - GPT_IMAGE_2_MODEL, + build_image_request_body_with_model( + GPT_IMAGE_2_5_GENERATION_MODEL, prompt, negative_prompt, size, @@ -25,7 +26,7 @@ pub fn build_vector_engine_image_request_body( ) } -pub fn build_vector_engine_image_request_body_with_model( +pub fn build_image_request_body_with_model( model: &str, prompt: &str, negative_prompt: Option<&str>, @@ -33,7 +34,7 @@ pub fn build_vector_engine_image_request_body_with_model( candidate_count: u32, _reference_images: &[String], ) -> Value { - let model = normalize_vector_engine_image_model(model); + let model = normalize_image_model(model); let body = Map::from_iter([ ("model".to_string(), Value::String(model.to_string())), ( @@ -50,7 +51,7 @@ pub fn build_vector_engine_image_request_body_with_model( Value::Object(body) } -pub fn build_vector_engine_nanobanana_generate_content_request_body( +pub fn build_nanobanana_generate_content_request_body( prompt: &str, negative_prompt: Option<&str>, aspect_ratio: &str, @@ -86,17 +87,62 @@ pub fn build_vector_engine_nanobanana_generate_content_request_body( }) } -pub fn normalize_vector_engine_image_model(model: &str) -> &str { +/// 归一化调用方传入的模型字符串。 +/// +/// 只处理缺省场景:空/空白输入落到当前默认生成模型 `GPT_IMAGE_2_5_GENERATION_MODEL`, +/// 与 `build_image_request_body` 的默认值保持一致;显式传入的模型(含历史 +/// `gpt-image-2` 等旧值)原样保留,交给 `resolve_image_provider` 做路由。 +pub fn normalize_image_model(model: &str) -> &str { match model.trim() { - "" => GPT_IMAGE_2_MODEL, + "" => GPT_IMAGE_2_5_GENERATION_MODEL, value => value, } } +/// nanobanana endpoint 只服务 Gemini 模型:空模型字符串回落到 nanobanana, +/// 而不是 `normalize_image_model` 默认的 GPT 生成模型。 +/// +/// 中文注释:`nanobanana_generate_content_url` 与 `create_nanobanana_generate_content` +/// 必须共用这一判据,否则空模型会先被归一成 GPT 生成模型、再被 provider 校验拒绝, +/// 或用一个 GPT 模型名去拼 Gemini 的 generateContent URL。 +pub(crate) fn normalize_nanobanana_model(model: &str) -> &str { + match model.trim() { + "" => NANOBANANA_2_MODEL, + value => value, + } +} + +/// Resolves a concrete provider model to its owning provider without rewriting the +/// model string. +/// +/// 中文注释:白名单是 provider 边界,不放业务名和已退役模型—— +/// `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 走 Tiantoken, +/// nanobanana 走 VectorEngine。业务模型名 `gpt-image-2.5` +/// (`GPT_IMAGE_2_5_BUSINESS_NAME`) 必须在任务边界解析成具体 key;已删除的 +/// `gpt-image-2-c` 命中即拒绝。`gpt-image-2` 仍按历史可读值接受。 +pub fn resolve_image_provider(model: &str) -> Result { + match normalize_image_model(model) { + GPT_IMAGE_2_MODEL | GPT_IMAGE_2_5_GENERATION_MODEL | GPT_IMAGE_2_5_EDIT_MODEL => { + Ok(ImageProvider::Tiantoken) + } + NANOBANANA_2_MODEL => Ok(ImageProvider::VectorEngine), + _ => Err(format!("不支持的图片模型:{model}")), + } +} + +/// 尺寸钳制族判定:回答「这个模型值按 GPT Image 2 的像素预算处理吗」。 +/// +/// 中文注释:这里刻意包含业务模型名 `gpt-image-2.5`,因为业务边界可能直接用它 +/// 计算尺寸;而 `resolve_image_provider` 回答的是「发给哪个 provider」,所以 +/// 必须先在任务边界把业务名解析成 concrete key、收到业务名即拒绝。两处白名单 +/// 口径不同是有意设计,不要为了“对齐”把业务名放进 provider 白名单。 pub(crate) fn is_gpt_image_2_family_model(model: &str) -> bool { matches!( - normalize_vector_engine_image_model(model), - GPT_IMAGE_2_MODEL | GPT_IMAGE_2_C_MODEL + normalize_image_model(model), + GPT_IMAGE_2_MODEL + | GPT_IMAGE_2_5_BUSINESS_NAME + | GPT_IMAGE_2_5_GENERATION_MODEL + | GPT_IMAGE_2_5_EDIT_MODEL ) } @@ -134,7 +180,7 @@ fn normalize_explicit_pixel_size(value: &str) -> String { fn clamp_gpt_image_2_pixel_size(size: &str) -> String { const MAX_ASPECT_RATIO: f64 = 3.0; - // 中文注释:这里是 VectorEngine 的共享发送边界,只处理 gpt-image-2 的显式像素尺寸。 + // 中文注释:这里是 ImageProvider 的共享发送边界,只处理 gpt-image-2 的显式像素尺寸。 let Some((width, height)) = parse_explicit_pixel_size(size) else { return size.to_string(); }; @@ -232,7 +278,7 @@ fn normalize_nanobanana_aspect_ratio(aspect_ratio: &str) -> &str { fn normalize_nanobanana_image_size(image_size: &str) -> &str { match image_size.trim() { - // 中文注释:nanobanana / Gemini 3.1 的 0.5K 在 VectorEngine 文档中要求传 512。 + // 中文注释:nanobanana / Gemini 3.1 的 0.5K 在 ImageProvider 文档中要求传 512。 "512" | "0.5K" => "512", "2K" => "2K", "4K" => "4K", @@ -240,7 +286,7 @@ fn normalize_nanobanana_image_size(image_size: &str) -> &str { } } -pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String { +pub fn images_generation_url(settings: &ImageProviderSettings) -> String { if settings.base_url.ends_with("/v1") { format!("{}/images/generations", settings.base_url) } else { @@ -248,7 +294,7 @@ pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) } } -pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> String { +pub fn images_edit_url(settings: &ImageProviderSettings) -> String { if settings.base_url.ends_with("/v1") { format!("{}/images/edits", settings.base_url) } else { @@ -256,22 +302,16 @@ pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> St } } -pub fn vector_engine_nanobanana_generate_content_url( - settings: &VectorEngineImageSettings, - model: &str, -) -> String { +pub fn nanobanana_generate_content_url(settings: &ImageProviderSettings, model: &str) -> String { let base_url = settings .base_url .trim_end_matches("/v1") .trim_end_matches('/'); - format!( - "{}/v1beta/models/{}:generateContent", - base_url, - normalize_vector_engine_image_model(model) - ) + let model = normalize_nanobanana_model(model); + format!("{}/v1beta/models/{}:generateContent", base_url, model) } -pub(crate) fn build_vector_engine_image_edit_request_log_params( +pub(crate) fn build_image_edit_request_log_params( model: &str, prompt: &str, negative_prompt: Option<&str>, @@ -279,7 +319,7 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params( candidate_count: u32, reference_images: &[ReferenceImage], ) -> Value { - let model = normalize_vector_engine_image_model(model); + let model = normalize_image_model(model); let prompt = prompt.trim(); let negative_prompt = negative_prompt .map(str::trim) @@ -330,11 +370,11 @@ pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<& #[cfg(test)] mod tests { use super::*; - use crate::vector_engine::types::ReferenceImage; + use crate::image_provider::types::ReferenceImage; #[test] fn edit_request_log_params_include_reference_image_sizes_without_secrets_or_bytes() { - let params = build_vector_engine_image_edit_request_log_params( + let params = build_image_edit_request_log_params( GPT_IMAGE_2_MODEL, " 拼图参考图重绘 ", Some(" 文字,水印 "), @@ -389,7 +429,7 @@ mod tests { "1152x2048", "2048x1152", ] { - let body = build_vector_engine_image_request_body_with_model( + let body = build_image_request_body_with_model( GPT_IMAGE_2_MODEL, "测试", None, diff --git a/server-rs/crates/platform-image/src/vector_engine/response.rs b/server-rs/crates/platform-image/src/image_provider/protocol/response.rs similarity index 85% rename from server-rs/crates/platform-image/src/vector_engine/response.rs rename to server-rs/crates/platform-image/src/image_provider/protocol/response.rs index 438b5b3a6..4d5a10683 100644 --- a/server-rs/crates/platform-image/src/vector_engine/response.rs +++ b/server-rs/crates/platform-image/src/image_provider/protocol/response.rs @@ -1,20 +1,20 @@ -use super::{ +use crate::image_provider::{ audit::build_failure_audit, - constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, image_source::{download_images_from_urls, images_from_base64}, payload::{ extract_b64_images, extract_generation_id, extract_image_urls, find_first_string_by_key, parse_api_error_message, parse_json_payload, }, - types::GeneratedImages, + types::{GeneratedImages, ImageProvider}, util::{current_utc_micros, is_timeout_message, truncate_raw}, }; use std::time::Instant; -pub(crate) async fn handle_vector_engine_response( +pub(crate) async fn handle_image_response( http_client: &reqwest::Client, request_url: &str, + provider: ImageProvider, response_status: u16, response_text: &str, image_model: Option<&'static str>, @@ -26,10 +26,12 @@ pub(crate) async fn handle_vector_engine_response( task_prefix: &str, request_deadline: Option, ) -> Result { + let provider_label = provider.as_str(); if !(200..=299).contains(&response_status) { let message = parse_api_error_message(response_text, failure_context); let raw_excerpt = truncate_raw(response_text); let audit = build_failure_audit( + provider_label, request_url, failure_context, "upstream_status", @@ -46,17 +48,17 @@ pub(crate) async fn handle_vector_engine_response( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, upstream_status = response_status, timeout = is_timeout_message(message.as_str()) || is_timeout_message(raw_excerpt.as_str()), retryable = audit.retryable, message = %message, raw_excerpt = %raw_excerpt, - "VectorEngine 图片生成上游错误" + "ImageProvider 图片生成上游错误" ); return Err(PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, upstream_status: response_status, raw_excerpt, @@ -64,10 +66,11 @@ pub(crate) async fn handle_vector_engine_response( }); } - let response_json = match parse_json_payload(response_text, failure_context) { + let response_json = match parse_json_payload(response_text, failure_context, provider) { Ok(response_json) => response_json, Err(error) => { let audit = build_failure_audit( + provider_label, request_url, failure_context, "response_parse", @@ -84,12 +87,12 @@ pub(crate) async fn handle_vector_engine_response( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, status = response_status, raw_excerpt = %truncate_raw(response_text), message = %error.message(), - "VectorEngine 图片响应解析失败" + "ImageProvider 图片响应解析失败" ); return Err(error.with_audit(audit)); } @@ -106,6 +109,7 @@ pub(crate) async fn handle_vector_engine_response( task_id, image_urls, candidate_count, + provider, request_deadline, failure_context, ) @@ -118,6 +122,7 @@ pub(crate) async fn handle_vector_engine_response( .audit() .is_some_and(|audit| audit.failure_stage == "request_budget"); let audit = build_failure_audit( + provider_label, request_url, failure_context, if request_budget_exhausted { @@ -146,12 +151,12 @@ pub(crate) async fn handle_vector_engine_response( }; generated.actual_prompt = actual_prompt; tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, image_count = generated.images.len(), elapsed_ms = download_started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine 图片下载完成" + "ImageProvider 图片下载完成" ); return Ok(generated); } @@ -159,9 +164,10 @@ pub(crate) async fn handle_vector_engine_response( if !b64_images.is_empty() { let mut generated = images_from_base64(task_id, b64_images, candidate_count); if generated.images.is_empty() { - let message = format!("{failure_context}:VectorEngine 返回的 base64 图片无法解码"); + let message = format!("{failure_context}:ImageProvider 返回的 base64 图片无法解码"); let raw_excerpt = truncate_raw(response_text); let audit = build_failure_audit( + provider_label, request_url, failure_context, "response_parse", @@ -178,15 +184,15 @@ pub(crate) async fn handle_vector_engine_response( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, status = response_status, image_model, raw_excerpt = %raw_excerpt, - "VectorEngine 图片 base64 解码失败" + "ImageProvider 图片 base64 解码失败" ); return Err(PlatformImageError::ResponseParse { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, raw_excerpt, audit: Some(audit), @@ -194,17 +200,18 @@ pub(crate) async fn handle_vector_engine_response( } generated.actual_prompt = actual_prompt; tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, image_count = generated.images.len(), failure_context, - "VectorEngine 图片 base64 解码完成" + "ImageProvider 图片 base64 解码完成" ); return Ok(generated); } - let message = format!("{failure_context}:VectorEngine 未返回图片地址"); + let message = format!("{failure_context}:ImageProvider 未返回图片地址"); let audit = build_failure_audit( + provider_label, request_url, failure_context, "missing_image", @@ -221,14 +228,14 @@ pub(crate) async fn handle_vector_engine_response( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, status = response_status, raw_excerpt = %truncate_raw(response_text), - "VectorEngine 图片响应未返回图片" + "ImageProvider 图片响应未返回图片" ); Err(PlatformImageError::MissingImage { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, audit: Some(audit), }) diff --git a/server-rs/crates/platform-image/src/vector_engine/audit.rs b/server-rs/crates/platform-image/src/image_provider/runtime/audit.rs similarity index 95% rename from server-rs/crates/platform-image/src/vector_engine/audit.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/audit.rs index 41bef9a6d..0ab99bda6 100644 --- a/server-rs/crates/platform-image/src/vector_engine/audit.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/audit.rs @@ -1,5 +1,3 @@ -use super::constants::VECTOR_ENGINE_PROVIDER; - #[derive(Clone, Debug)] pub struct PlatformImageFailureAudit { pub provider: &'static str, @@ -20,6 +18,7 @@ pub struct PlatformImageFailureAudit { } pub(crate) fn build_failure_audit( + provider: &'static str, request_url: &str, operation: &str, failure_stage: &'static str, @@ -36,7 +35,7 @@ pub(crate) fn build_failure_audit( image_model: Option<&'static str>, ) -> PlatformImageFailureAudit { PlatformImageFailureAudit { - provider: VECTOR_ENGINE_PROVIDER, + provider, endpoint: request_url.to_string(), operation: operation.to_string(), failure_stage, diff --git a/server-rs/crates/platform-image/src/vector_engine/budget.rs b/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs similarity index 93% rename from server-rs/crates/platform-image/src/vector_engine/budget.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/budget.rs index 02f099344..5876fa570 100644 --- a/server-rs/crates/platform-image/src/vector_engine/budget.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/budget.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; -use super::{ - audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, +use crate::image_provider::{ + audit::build_failure_audit, error::PlatformImageError, types::ImageProvider, }; pub(crate) fn effective_request_timeout_ms( @@ -51,6 +51,7 @@ fn retry_delay_fits_request_deadline_at( } pub(crate) fn request_budget_exhausted_error( + provider: ImageProvider, request_url: &str, operation: &str, image_model: Option<&'static str>, @@ -59,8 +60,10 @@ pub(crate) fn request_budget_exhausted_error( reference_image_count: Option, ) -> PlatformImageError { const ERROR_SOURCE: &str = "external request deadline elapsed"; + let provider_label = provider.as_str(); let message = format!("{operation}:外部图片请求执行预算已耗尽"); let audit = build_failure_audit( + provider_label, request_url, operation, "request_budget", @@ -77,7 +80,7 @@ pub(crate) fn request_budget_exhausted_error( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, failure_stage = "request_budget", timeout = true, @@ -86,10 +89,10 @@ pub(crate) fn request_budget_exhausted_error( reference_image_count, image_model, operation, - "VectorEngine 图片请求执行预算已耗尽" + "ImageProvider 图片请求执行预算已耗尽" ); PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, endpoint: Some(request_url.to_string()), timeout: true, @@ -163,6 +166,7 @@ mod tests { #[test] fn exhausted_budget_maps_to_timeout_request_error_and_audit() { let error = request_budget_exhausted_error( + ImageProvider::VectorEngine, "https://vector.example/v1/images/generations", "生成图片失败", None, diff --git a/server-rs/crates/platform-image/src/vector_engine/error.rs b/server-rs/crates/platform-image/src/image_provider/runtime/error.rs similarity index 70% rename from server-rs/crates/platform-image/src/vector_engine/error.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/error.rs index cb820e08a..3aa2971d8 100644 --- a/server-rs/crates/platform-image/src/vector_engine/error.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/error.rs @@ -1,6 +1,19 @@ use std::{error::Error, fmt}; -use super::{audit::PlatformImageFailureAudit, util::is_timeout_message}; +use crate::image_provider::{ + audit::PlatformImageFailureAudit, types::ImageProvider, util::is_timeout_message, +}; + +/// 图片编辑缺少参考图时的统一错误;调用方可在解析 provider 客户端之前先行校验。 +pub fn missing_reference_images_error( + provider: ImageProvider, + failure_context: &str, +) -> PlatformImageError { + PlatformImageError::InvalidRequest { + provider: provider.as_str(), + message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), + } +} #[derive(Clone, Debug)] pub enum PlatformImageError { @@ -42,10 +55,6 @@ pub enum PlatformImageError { message: String, audit: Option, }, - FallbackFailed { - final_error: Box, - recovered_failure_audits: Vec, - }, } impl PlatformImageError { @@ -57,7 +66,6 @@ impl PlatformImageError { | Self::Upstream { provider, .. } | Self::ResponseParse { provider, .. } | Self::MissingImage { provider, .. } => provider, - Self::FallbackFailed { final_error, .. } => final_error.provider(), } } @@ -69,7 +77,6 @@ impl PlatformImageError { | Self::Upstream { message, .. } | Self::ResponseParse { message, .. } | Self::MissingImage { message, .. } => message, - Self::FallbackFailed { final_error, .. } => final_error.message(), } } @@ -79,42 +86,10 @@ impl PlatformImageError { | Self::Upstream { audit, .. } | Self::ResponseParse { audit, .. } | Self::MissingImage { audit, .. } => audit.as_ref(), - Self::FallbackFailed { final_error, .. } => final_error.audit(), Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => None, } } - pub fn recovered_failure_audits(&self) -> &[PlatformImageFailureAudit] { - match self { - Self::FallbackFailed { - recovered_failure_audits, - .. - } => recovered_failure_audits.as_slice(), - _ => &[], - } - } - - pub(crate) fn with_recovered_failure_audits( - self, - recovered_failure_audits: Vec, - ) -> Self { - if recovered_failure_audits.is_empty() { - self - } else { - Self::FallbackFailed { - final_error: Box::new(self), - recovered_failure_audits, - } - } - } - - pub fn into_final_error(self) -> Self { - match self { - Self::FallbackFailed { final_error, .. } => final_error.into_final_error(), - error => error, - } - } - pub fn status_hint(&self) -> PlatformImageStatusHint { match self { Self::InvalidConfig { .. } => PlatformImageStatusHint::ServiceUnavailable, @@ -131,7 +106,6 @@ impl PlatformImageError { | Self::Upstream { .. } | Self::ResponseParse { .. } | Self::MissingImage { .. } => PlatformImageStatusHint::BadGateway, - Self::FallbackFailed { final_error, .. } => final_error.status_hint(), } } } diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs similarity index 52% rename from server-rs/crates/platform-image/src/vector_engine/client.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/executor.rs index 24b10196a..af372d447 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/executor.rs @@ -4,37 +4,36 @@ const VECTOR_ENGINE_SEND_MAX_ATTEMPTS: u32 = 5; const VECTOR_ENGINE_SEND_RETRY_BASE_DELAY_MS: u64 = 500; const VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS: u64 = 999; -use super::{ +use crate::image_provider::{ budget::{ effective_request_timeout_ms, request_budget_exhausted_error, retry_delay_fits_request_deadline, }, constants::{ - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, - VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER, + GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, + GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, + VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, }, curl_transport::{ - map_curl_error, send_vector_engine_json_request_with_curl, - send_vector_engine_multipart_edit_request_with_curl, + map_curl_error, send_image_json_request_with_curl, + send_image_multipart_edit_request_with_curl, }, - error::PlatformImageError, + error::{PlatformImageError, missing_reference_images_error}, image_source::resolve_reference_images, request::{ - build_vector_engine_image_edit_request_log_params, - build_vector_engine_image_request_body_with_model, - build_vector_engine_nanobanana_generate_content_request_body, is_gpt_image_2_family_model, - normalize_image_size_for_model, normalize_vector_engine_image_model, - vector_engine_images_edit_url, vector_engine_images_generation_url, - vector_engine_nanobanana_generate_content_url, + build_image_edit_request_log_params, build_image_request_body_with_model, + build_nanobanana_generate_content_request_body, images_edit_url, images_generation_url, + nanobanana_generate_content_url, normalize_image_model, normalize_image_size_for_model, + normalize_nanobanana_model, resolve_image_provider, }, - response::handle_vector_engine_response, - types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, + response::handle_image_response, + types::{GeneratedImages, ImageProvider, ImageProviderSettings, ReferenceImage}, util::truncate_raw, }; -pub async fn create_vector_engine_image_generation( +pub async fn create_image_generation( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, @@ -42,10 +41,16 @@ pub async fn create_vector_engine_image_generation( reference_images: &[String], failure_context: &str, ) -> Result { - create_vector_engine_image_generation_with_model( + create_image_generation_with_model( http_client, settings, - GPT_IMAGE_2_MODEL, + // 带参考图时执行路径会走 /v1/images/edits,必须与任务边界的 + // `editor_image_generation_concrete_model` 判据一致,提交编辑 concrete model。 + if reference_images.is_empty() { + GPT_IMAGE_2_5_GENERATION_MODEL + } else { + GPT_IMAGE_2_5_EDIT_MODEL + }, prompt, negative_prompt, size, @@ -57,9 +62,9 @@ pub async fn create_vector_engine_image_generation( } #[allow(clippy::too_many_arguments)] -pub async fn create_vector_engine_image_generation_with_model( +pub async fn create_image_generation_with_model( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, model: &str, prompt: &str, negative_prompt: Option<&str>, @@ -68,16 +73,18 @@ pub async fn create_vector_engine_image_generation_with_model( reference_images: &[String], failure_context: &str, ) -> Result { - let requested_model = normalize_vector_engine_image_model(model); + let requested_model = normalize_image_model(model); + ensure_provider_matches_model(settings, requested_model, failure_context)?; if !reference_images.is_empty() { let resolved_references = resolve_reference_images( http_client, reference_images, + settings.provider, failure_context, settings.request_deadline, ) .await?; - return create_vector_engine_image_edit_with_references_and_model( + return create_image_edit_with_references_and_model( http_client, settings, requested_model, @@ -91,14 +98,13 @@ pub async fn create_vector_engine_image_generation_with_model( .await; } - let request_url = vector_engine_images_generation_url(settings); + let request_url = images_generation_url(settings); let normalized_size = normalize_image_size_for_model(requested_model, size); let started_at = std::time::Instant::now(); - let mut upstream_model = preferred_vector_engine_upstream_model(requested_model); - let mut recovered_failure_audits = Vec::new(); + let upstream_model = preferred_image_upstream_model(requested_model); let mut attempt = 1; loop { - let request_body = build_vector_engine_image_request_body_with_model( + let request_body = build_image_request_body_with_model( upstream_model, prompt, negative_prompt, @@ -109,19 +115,17 @@ pub async fn create_vector_engine_image_generation_with_model( let Some(attempt_timeout_ms) = effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) else { - return Err(finish_vector_engine_model_fallback_error( - request_budget_exhausted_error( - request_url.as_str(), - failure_context, - auditable_vector_engine_image_model(upstream_model), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - &mut recovered_failure_audits, + return Err(request_budget_exhausted_error( + settings.provider, + request_url.as_str(), + failure_context, + auditable_image_model(upstream_model), + Some(started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(reference_images.len()), )); }; - let response = match send_vector_engine_json_request_with_curl( + let response = match send_image_json_request_with_curl( request_url.as_str(), settings.api_key.as_str(), &request_body, @@ -130,57 +134,13 @@ pub async fn create_vector_engine_image_generation_with_model( .await { Ok(response) => { - if should_retry_vector_engine_upstream_response( + if should_retry_image_upstream_response( response.status, response.body.as_str(), attempt, ) { - let primary_error = if upstream_model == GPT_IMAGE_2_MODEL { - handle_vector_engine_response( - http_client, - request_url.as_str(), - response.status, - response.body.as_str(), - auditable_vector_engine_image_model(upstream_model), - failure_context, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_images.len()), - candidate_count, - "vector-engine", - settings.request_deadline, - ) - .await - .err() - } else { - None - }; - if primary_error.as_ref().is_some_and(|error| { - should_fallback_to_gpt_image_2_c( - requested_model, - upstream_model, - attempt, - error, - settings, - ) - }) { - let error = primary_error.expect("primary error checked above"); - record_vector_engine_model_fallback( - "generation", - request_url.as_str(), - upstream_model, - GPT_IMAGE_2_C_MODEL, - attempt, - &error, - ); - if let Some(audit) = error.audit().cloned() { - recovered_failure_audits.push(audit); - } - upstream_model = GPT_IMAGE_2_C_MODEL; - attempt += 1; - continue; - } - if retry_vector_engine_upstream_status_after_delay( + if retry_image_upstream_status_after_delay( + settings.provider, "generation", request_url.as_str(), attempt, @@ -201,8 +161,9 @@ pub async fn create_vector_engine_image_generation_with_model( response } Err(error) => { - if should_retry_vector_engine_curl_send_error(&error, attempt) { - if retry_vector_engine_send_after_delay( + if should_retry_image_curl_send_error(&error, attempt) { + if retry_image_send_after_delay( + settings.provider, "generation", request_url.as_str(), "request_send", @@ -224,25 +185,23 @@ pub async fn create_vector_engine_image_generation_with_model( continue; } } - return Err(finish_vector_engine_model_fallback_error( - map_curl_error( - format!("{failure_context}:创建图片生成任务失败").as_str(), - request_url.as_str(), - "request_send", - auditable_vector_engine_image_model(upstream_model), - error, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_images.len()), - Some(&request_body), - ), - &mut recovered_failure_audits, + return Err(map_curl_error( + settings.provider, + format!("{failure_context}:创建图片生成任务失败").as_str(), + request_url.as_str(), + "request_send", + auditable_image_model(upstream_model), + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + Some(&request_body), )); } }; let response_status = response.status; tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = settings.provider.as_str(), endpoint = %request_url, status = response_status, image_model = upstream_model, @@ -253,68 +212,37 @@ pub async fn create_vector_engine_image_generation_with_model( attempt, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine 图片生成 HTTP 返回" + "ImageProvider 图片生成 HTTP 返回" ); let response_text = response.body; - match handle_vector_engine_response( + let task_id_prefix = image_task_id_prefix(settings.provider, None); + match handle_image_response( http_client, request_url.as_str(), + settings.provider, response_status, response_text.as_str(), - auditable_vector_engine_image_model(upstream_model), + auditable_image_model(upstream_model), failure_context, started_at.elapsed().as_millis() as u64, Some(prompt.chars().count()), Some(reference_images.len()), candidate_count, - "vector-engine", + task_id_prefix.as_str(), settings.request_deadline, ) .await { - Ok(mut generated) => { - generated - .recovered_failure_audits - .append(&mut recovered_failure_audits); - return Ok(generated); - } - Err(error) - if should_fallback_to_gpt_image_2_c( - requested_model, - upstream_model, - attempt, - &error, - settings, - ) => - { - record_vector_engine_model_fallback( - "generation", - request_url.as_str(), - upstream_model, - GPT_IMAGE_2_C_MODEL, - attempt, - &error, - ); - if let Some(audit) = error.audit().cloned() { - recovered_failure_audits.push(audit); - } - upstream_model = GPT_IMAGE_2_C_MODEL; - attempt += 1; - } - Err(error) => { - return Err(finish_vector_engine_model_fallback_error( - error, - &mut recovered_failure_audits, - )); - } + Ok(generated) => return Ok(generated), + Err(error) => return Err(error), } } } #[allow(clippy::too_many_arguments)] -pub async fn create_vector_engine_nanobanana_generate_content( +pub async fn create_nanobanana_generate_content( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, model: &str, prompt: &str, negative_prompt: Option<&str>, @@ -323,18 +251,19 @@ pub async fn create_vector_engine_nanobanana_generate_content( reference_images: &[ReferenceImage], failure_context: &str, ) -> Result { + let model = normalize_nanobanana_model(model); + ensure_provider_matches_model(settings, model, failure_context)?; if reference_images.len() > VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: settings.provider.as_str(), message: format!( "{failure_context}:参考图最多允许 {VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。", reference_images.len() ), }); } - let model = normalize_vector_engine_image_model(model); - let request_url = vector_engine_nanobanana_generate_content_url(settings, model); - let request_body = build_vector_engine_nanobanana_generate_content_request_body( + let request_url = nanobanana_generate_content_url(settings, model); + let request_body = build_nanobanana_generate_content_request_body( prompt, negative_prompt, aspect_ratio, @@ -365,6 +294,7 @@ pub async fn create_vector_engine_nanobanana_generate_content( effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) else { return Err(request_budget_exhausted_error( + settings.provider, request_url.as_str(), failure_context, None, @@ -373,7 +303,7 @@ pub async fn create_vector_engine_nanobanana_generate_content( Some(reference_image_count), )); }; - match send_vector_engine_json_request_with_curl( + match send_image_json_request_with_curl( request_url.as_str(), settings.api_key.as_str(), &request_body, @@ -382,12 +312,13 @@ pub async fn create_vector_engine_nanobanana_generate_content( .await { Ok(response) => { - if should_retry_vector_engine_upstream_response( + if should_retry_image_upstream_response( response.status, response.body.as_str(), attempt, ) { - if retry_vector_engine_upstream_status_after_delay( + if retry_image_upstream_status_after_delay( + settings.provider, "nanobanana_generate_content", request_url.as_str(), attempt, @@ -408,8 +339,9 @@ pub async fn create_vector_engine_nanobanana_generate_content( break response; } Err(error) => { - if should_retry_vector_engine_curl_send_error(&error, attempt) { - if retry_vector_engine_send_after_delay( + if should_retry_image_curl_send_error(&error, attempt) { + if retry_image_send_after_delay( + settings.provider, "nanobanana_generate_content", request_url.as_str(), "request_send", @@ -432,6 +364,7 @@ pub async fn create_vector_engine_nanobanana_generate_content( } } return Err(map_curl_error( + settings.provider, format!("{failure_context}:创建 nanobanana2 图片生成任务失败").as_str(), request_url.as_str(), "request_send", @@ -447,7 +380,7 @@ pub async fn create_vector_engine_nanobanana_generate_content( }; let response_status = response.status; tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = settings.provider.as_str(), endpoint = %request_url, status = response_status, image_model = model, @@ -460,12 +393,14 @@ pub async fn create_vector_engine_nanobanana_generate_content( attempt, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine nanobanana2 图片生成 HTTP 返回" + "ImageProvider nanobanana2 图片生成 HTTP 返回" ); let response_text = response.body; - handle_vector_engine_response( + let task_id_prefix = image_task_id_prefix(settings.provider, Some("nanobanana")); + handle_image_response( http_client, request_url.as_str(), + settings.provider, response_status, response_text.as_str(), None, @@ -474,22 +409,22 @@ pub async fn create_vector_engine_nanobanana_generate_content( Some(prompt.chars().count()), Some(reference_image_count), 1, - "vector-engine-nanobanana", + task_id_prefix.as_str(), settings.request_deadline, ) .await } -pub async fn create_vector_engine_image_edit( +pub async fn create_image_edit( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, reference_image: &ReferenceImage, failure_context: &str, ) -> Result { - create_vector_engine_image_edit_with_references( + create_image_edit_with_references( http_client, settings, prompt, @@ -502,9 +437,9 @@ pub async fn create_vector_engine_image_edit( .await } -pub async fn create_vector_engine_image_edit_with_references( +pub async fn create_image_edit_with_references( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, @@ -512,10 +447,10 @@ pub async fn create_vector_engine_image_edit_with_references( reference_images: &[ReferenceImage], failure_context: &str, ) -> Result { - create_vector_engine_image_edit_with_references_and_model( + create_image_edit_with_references_and_model( http_client, settings, - GPT_IMAGE_2_MODEL, + GPT_IMAGE_2_5_EDIT_MODEL, prompt, negative_prompt, size, @@ -527,9 +462,9 @@ pub async fn create_vector_engine_image_edit_with_references( } #[allow(clippy::too_many_arguments)] -pub async fn create_vector_engine_image_edit_with_references_and_model( +pub async fn create_image_edit_with_references_and_model( http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, + settings: &ImageProviderSettings, model: &str, prompt: &str, negative_prompt: Option<&str>, @@ -538,16 +473,17 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( reference_images: &[ReferenceImage], failure_context: &str, ) -> Result { - let requested_model = normalize_vector_engine_image_model(model); + let requested_model = normalize_image_model(model); + ensure_provider_matches_model(settings, requested_model, failure_context)?; if reference_images.is_empty() { - return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), - }); + return Err(missing_reference_images_error( + settings.provider, + failure_context, + )); } if reference_images.len() > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: settings.provider.as_str(), message: format!( "{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。", reference_images.len() @@ -555,18 +491,17 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( }); } - let request_url = vector_engine_images_edit_url(settings); + let request_url = images_edit_url(settings); let normalized_size = normalize_image_size_for_model(requested_model, size); let reference_image_count = reference_images.len(); let reference_image_bytes_total: usize = reference_images.iter().map(|image| image.bytes.len()).sum(); let started_at = std::time::Instant::now(); - let mut upstream_model = preferred_vector_engine_upstream_model(requested_model); - let mut recovered_failure_audits = Vec::new(); + let upstream_model = preferred_image_upstream_model(requested_model); let mut attempt = 1; loop { - let request_params = build_vector_engine_image_edit_request_log_params( + let request_params = build_image_edit_request_log_params( upstream_model, prompt, negative_prompt, @@ -575,7 +510,7 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( reference_images, ); tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = settings.provider.as_str(), endpoint = %request_url, image_model = upstream_model, requested_image_model = requested_model, @@ -594,24 +529,22 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( request_params = %request_params, attempt, failure_context, - "VectorEngine 图片编辑请求参数" + "ImageProvider 图片编辑请求参数" ); let Some(attempt_timeout_ms) = effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) else { - return Err(finish_vector_engine_model_fallback_error( - request_budget_exhausted_error( - request_url.as_str(), - failure_context, - auditable_vector_engine_image_model(upstream_model), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - &mut recovered_failure_audits, + return Err(request_budget_exhausted_error( + settings.provider, + request_url.as_str(), + failure_context, + auditable_image_model(upstream_model), + Some(started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(reference_image_count), )); }; - let response = match send_vector_engine_multipart_edit_request_with_curl( + let response = match send_image_multipart_edit_request_with_curl( request_url.as_str(), settings.api_key.as_str(), upstream_model, @@ -625,57 +558,13 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( .await { Ok(response) => { - if should_retry_vector_engine_upstream_response( + if should_retry_image_upstream_response( response.status, response.body.as_str(), attempt, ) { - let primary_error = if upstream_model == GPT_IMAGE_2_MODEL { - handle_vector_engine_response( - http_client, - request_url.as_str(), - response.status, - response.body.as_str(), - auditable_vector_engine_image_model(upstream_model), - failure_context, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_image_count), - candidate_count, - "vector-engine-edit", - settings.request_deadline, - ) - .await - .err() - } else { - None - }; - if primary_error.as_ref().is_some_and(|error| { - should_fallback_to_gpt_image_2_c( - requested_model, - upstream_model, - attempt, - error, - settings, - ) - }) { - let error = primary_error.expect("primary error checked above"); - record_vector_engine_model_fallback( - "edit", - request_url.as_str(), - upstream_model, - GPT_IMAGE_2_C_MODEL, - attempt, - &error, - ); - if let Some(audit) = error.audit().cloned() { - recovered_failure_audits.push(audit); - } - upstream_model = GPT_IMAGE_2_C_MODEL; - attempt += 1; - continue; - } - if retry_vector_engine_upstream_status_after_delay( + if retry_image_upstream_status_after_delay( + settings.provider, "edit", request_url.as_str(), attempt, @@ -696,8 +585,9 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( response } Err(error) => { - if should_retry_vector_engine_curl_send_error(&error, attempt) { - if retry_vector_engine_send_after_delay( + if should_retry_image_curl_send_error(&error, attempt) { + if retry_image_send_after_delay( + settings.provider, "edit", request_url.as_str(), "request_send", @@ -719,25 +609,23 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( continue; } } - return Err(finish_vector_engine_model_fallback_error( - map_curl_error( - format!("{failure_context}:创建图片编辑任务失败").as_str(), - request_url.as_str(), - "request_send", - auditable_vector_engine_image_model(upstream_model), - error, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_image_count), - Some(&request_params), - ), - &mut recovered_failure_audits, + return Err(map_curl_error( + settings.provider, + format!("{failure_context}:创建图片编辑任务失败").as_str(), + request_url.as_str(), + "request_send", + auditable_image_model(upstream_model), + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + Some(&request_params), )); } }; let response_status = response.status; tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = settings.provider.as_str(), endpoint = %request_url, status = response_status, image_model = upstream_model, @@ -750,160 +638,90 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( attempt, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine 图片编辑 HTTP 返回" + "ImageProvider 图片编辑 HTTP 返回" ); let response_text = response.body; - match handle_vector_engine_response( + let task_id_prefix = image_task_id_prefix(settings.provider, Some("edit")); + match handle_image_response( http_client, request_url.as_str(), + settings.provider, response_status, response_text.as_str(), - auditable_vector_engine_image_model(upstream_model), + auditable_image_model(upstream_model), failure_context, started_at.elapsed().as_millis() as u64, Some(prompt.chars().count()), Some(reference_image_count), candidate_count, - "vector-engine-edit", + task_id_prefix.as_str(), settings.request_deadline, ) .await { - Ok(mut generated) => { - generated - .recovered_failure_audits - .append(&mut recovered_failure_audits); - return Ok(generated); - } - Err(error) - if should_fallback_to_gpt_image_2_c( - requested_model, - upstream_model, - attempt, - &error, - settings, - ) => - { - record_vector_engine_model_fallback( - "edit", - request_url.as_str(), - upstream_model, - GPT_IMAGE_2_C_MODEL, - attempt, - &error, - ); - if let Some(audit) = error.audit().cloned() { - recovered_failure_audits.push(audit); - } - upstream_model = GPT_IMAGE_2_C_MODEL; - attempt += 1; - } - Err(error) => { - return Err(finish_vector_engine_model_fallback_error( - error, - &mut recovered_failure_audits, - )); - } + Ok(generated) => return Ok(generated), + Err(error) => return Err(error), } } } -fn preferred_vector_engine_upstream_model(requested_model: &str) -> &str { - if is_gpt_image_2_family_model(requested_model) { - GPT_IMAGE_2_MODEL - } else { - requested_model +/// 校验请求模型与 settings 绑定的 provider 是否一致。 +/// +/// executor 用 `settings.base_url`/`settings.api_key` 发请求,如果调用方传入了属于 +/// 另一个 provider 的模型,就会把错误的密钥/网关当成目标端点。这里显式 fail fast, +/// 避免静默地把请求发到不匹配的 provider。 +pub(crate) fn ensure_provider_matches_model( + settings: &ImageProviderSettings, + model: &str, + failure_context: &str, +) -> Result<(), PlatformImageError> { + // `resolve_image_provider` 的错误信息已经带上被拒绝的模型名,这里不再重复拼接。 + let resolved = + resolve_image_provider(model).map_err(|message| PlatformImageError::InvalidRequest { + provider: settings.provider.as_str(), + message: format!("{failure_context}:{message}"), + })?; + if resolved != settings.provider { + return Err(PlatformImageError::InvalidRequest { + provider: settings.provider.as_str(), + message: format!( + "{failure_context}:模型 {model} 属于 {},与当前 {provider} 设置不一致", + resolved.as_str(), + provider = settings.provider.as_str() + ), + }); + } + Ok(()) +} + +/// 兜底 task id 前缀:必须跟随实际 provider,否则 Tiantoken 任务的 +/// `task_id` 会带着 `vector-engine` 前缀进入持久化与审计。 +fn image_task_id_prefix(provider: ImageProvider, task_kind: Option<&str>) -> String { + match task_kind { + Some(kind) => format!("{}-{kind}", provider.as_str()), + None => provider.as_str().to_string(), } } -fn finish_vector_engine_model_fallback_error( - error: PlatformImageError, - recovered_failure_audits: &mut Vec, -) -> PlatformImageError { - error.with_recovered_failure_audits(std::mem::take(recovered_failure_audits)) +fn preferred_image_upstream_model(requested_model: &str) -> &str { + // Provider routing is selected by api-server at the task boundary. Keep the + // concrete value intact here so retries stay on the same model and legacy + // persisted values are never silently rewritten. + requested_model } -fn auditable_vector_engine_image_model(model: &str) -> Option<&'static str> { +fn auditable_image_model(model: &str) -> Option<&'static str> { match model { - GPT_IMAGE_2_C_MODEL => Some(GPT_IMAGE_2_C_MODEL), GPT_IMAGE_2_MODEL => Some(GPT_IMAGE_2_MODEL), + GPT_IMAGE_2_5_BUSINESS_NAME => Some(GPT_IMAGE_2_5_BUSINESS_NAME), + GPT_IMAGE_2_5_GENERATION_MODEL => Some(GPT_IMAGE_2_5_GENERATION_MODEL), + GPT_IMAGE_2_5_EDIT_MODEL => Some(GPT_IMAGE_2_5_EDIT_MODEL), + NANOBANANA_2_MODEL => Some(NANOBANANA_2_MODEL), _ => None, } } -fn should_fallback_to_gpt_image_2_c( - requested_model: &str, - upstream_model: &str, - attempt: u32, - error: &PlatformImageError, - settings: &VectorEngineImageSettings, -) -> bool { - if !is_gpt_image_2_family_model(requested_model) - || upstream_model != GPT_IMAGE_2_MODEL - || attempt >= VECTOR_ENGINE_SEND_MAX_ATTEMPTS - || effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) - .is_none() - { - return false; - } - - match error { - PlatformImageError::Upstream { - upstream_status, - message, - raw_excerpt, - .. - } => match *upstream_status { - 408 => true, - 429 => !contains_vector_engine_content_rejection(message, raw_excerpt), - status if status >= 500 => true, - 400 | 404 | 422 => contains_vector_engine_model_unavailable(message, raw_excerpt), - _ => false, - }, - PlatformImageError::ResponseParse { - message, - raw_excerpt, - .. - } => !contains_vector_engine_content_rejection(message, raw_excerpt), - PlatformImageError::MissingImage { message, audit, .. } => { - let raw_excerpt = audit - .as_ref() - .and_then(|audit| audit.raw_excerpt.as_deref()) - .unwrap_or_default(); - !contains_vector_engine_content_rejection(message, raw_excerpt) - } - PlatformImageError::InvalidConfig { .. } - | PlatformImageError::InvalidRequest { .. } - | PlatformImageError::Request { .. } - | PlatformImageError::FallbackFailed { .. } => false, - } -} - -fn contains_vector_engine_model_unavailable(message: &str, raw_excerpt: &str) -> bool { - let haystack = format!("{message}\n{raw_excerpt}").to_ascii_lowercase(); - let mentions_model = haystack.contains("model") - || haystack.contains("模型") - || haystack.contains(GPT_IMAGE_2_MODEL) - || haystack.contains(GPT_IMAGE_2_C_MODEL); - let unavailable = [ - "not found", - "not supported", - "unsupported", - "unavailable", - "does not exist", - "invalid model", - "unknown model", - "不存在", - "不支持", - "不可用", - "未开通", - ] - .iter() - .any(|marker| haystack.contains(marker)); - mentions_model && unavailable -} - -fn contains_vector_engine_content_rejection(message: &str, raw_excerpt: &str) -> bool { +fn contains_image_provider_content_rejection(message: &str, raw_excerpt: &str) -> bool { let haystack = format!("{message}\n{raw_excerpt}").to_ascii_lowercase(); [ "invalid_prompt", @@ -928,47 +746,23 @@ fn contains_vector_engine_content_rejection(message: &str, raw_excerpt: &str) -> .any(|marker| haystack.contains(marker)) } -fn record_vector_engine_model_fallback( - request_kind: &'static str, - request_url: &str, - from_model: &str, - to_model: &str, - attempt: u32, - error: &PlatformImageError, -) { - let audit = error.audit(); - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - request_kind, - fallback_from_model = from_model, - fallback_to_model = to_model, - attempt, - next_attempt = attempt + 1, - max_attempts = VECTOR_ENGINE_SEND_MAX_ATTEMPTS, - failure_stage = audit.map(|audit| audit.failure_stage).unwrap_or("unknown"), - status = audit.and_then(|audit| audit.status_code).unwrap_or_default(), - error = %error.message(), - "VectorEngine 首选图片模型失败,切换兼容模型" - ); -} - -fn should_retry_vector_engine_curl_send_error( - error: &super::curl_transport::VectorEngineCurlError, +fn should_retry_image_curl_send_error( + error: &super::curl_transport::ImageProviderCurlError, attempt: u32, ) -> bool { attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (error.is_timeout() || error.is_connect() || error.is_transient_transport()) } -fn should_retry_vector_engine_upstream_response(status: u16, raw_body: &str, attempt: u32) -> bool { +fn should_retry_image_upstream_response(status: u16, raw_body: &str, attempt: u32) -> bool { attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (status == 408 || status >= 500 - || (status == 429 && !contains_vector_engine_content_rejection("", raw_body))) + || (status == 429 && !contains_image_provider_content_rejection("", raw_body))) } -async fn retry_vector_engine_send_after_delay( +async fn retry_image_send_after_delay( + provider: ImageProvider, request_kind: &'static str, request_url: &str, failure_stage: &'static str, @@ -984,10 +778,12 @@ async fn retry_vector_engine_send_after_delay( request_params: Option<&serde_json::Value>, request_deadline: Option, ) -> bool { - let delay_ms = vector_engine_send_retry_delay_ms(attempt, vector_engine_send_retry_jitter_ms()); + let provider_label = provider.as_str(); + let delay_ms = + image_provider_send_retry_delay_ms(attempt, image_provider_send_retry_jitter_ms()); if !retry_delay_fits_request_deadline(request_deadline, delay_ms) { tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, request_kind, failure_stage = "request_budget", @@ -1003,12 +799,12 @@ async fn retry_vector_engine_send_after_delay( elapsed_ms, prompt_chars, reference_image_count, - "VectorEngine 图片请求剩余预算不足,停止重试" + "ImageProvider 图片请求剩余预算不足,停止重试" ); return false; } tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, request_kind, failure_stage, @@ -1027,13 +823,14 @@ async fn retry_vector_engine_send_after_delay( request_params = %request_params .map(|value| value.to_string()) .unwrap_or_default(), - "VectorEngine 图片请求发送失败,准备重试" + "ImageProvider 图片请求发送失败,准备重试" ); tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; true } -async fn retry_vector_engine_upstream_status_after_delay( +async fn retry_image_upstream_status_after_delay( + provider: ImageProvider, request_kind: &'static str, request_url: &str, attempt: u32, @@ -1045,10 +842,12 @@ async fn retry_vector_engine_upstream_status_after_delay( request_params: Option<&serde_json::Value>, request_deadline: Option, ) -> bool { - let delay_ms = vector_engine_send_retry_delay_ms(attempt, vector_engine_send_retry_jitter_ms()); + let provider_label = provider.as_str(); + let delay_ms = + image_provider_send_retry_delay_ms(attempt, image_provider_send_retry_jitter_ms()); if !retry_delay_fits_request_deadline(request_deadline, delay_ms) { tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, request_kind, failure_stage = "request_budget", @@ -1060,12 +859,12 @@ async fn retry_vector_engine_upstream_status_after_delay( elapsed_ms, prompt_chars, reference_image_count, - "VectorEngine 图片请求剩余预算不足,停止上游状态重试" + "ImageProvider 图片请求剩余预算不足,停止上游状态重试" ); return false; } tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, request_kind, failure_stage = "upstream_status", @@ -1081,19 +880,19 @@ async fn retry_vector_engine_upstream_status_after_delay( request_params = %request_params .map(|value| value.to_string()) .unwrap_or_default(), - "VectorEngine 图片上游状态可重试,准备重试" + "ImageProvider 图片上游状态可重试,准备重试" ); tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; true } -fn vector_engine_send_retry_delay_ms(attempt: u32, jitter_ms: u64) -> u64 { +fn image_provider_send_retry_delay_ms(attempt: u32, jitter_ms: u64) -> u64 { let exponential_factor = 1_u64 << attempt.saturating_sub(1).min(10); let bounded_jitter_ms = jitter_ms.min(VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS); VECTOR_ENGINE_SEND_RETRY_BASE_DELAY_MS * exponential_factor + bounded_jitter_ms } -fn vector_engine_send_retry_jitter_ms() -> u64 { +fn image_provider_send_retry_jitter_ms() -> u64 { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.subsec_nanos()) @@ -1104,6 +903,7 @@ fn vector_engine_send_retry_jitter_ms() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::image_provider::types::ImageProvider; fn reference_image(index: usize) -> ReferenceImage { ReferenceImage { @@ -1113,8 +913,19 @@ mod tests { } } - fn test_settings() -> VectorEngineImageSettings { - VectorEngineImageSettings { + fn vector_engine_settings() -> ImageProviderSettings { + ImageProviderSettings { + provider: ImageProvider::VectorEngine, + base_url: "http://127.0.0.1:9".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + request_deadline: None, + } + } + + fn tiantoken_settings() -> ImageProviderSettings { + ImageProviderSettings { + provider: ImageProvider::Tiantoken, base_url: "http://127.0.0.1:9".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, @@ -1125,9 +936,9 @@ mod tests { #[tokio::test] async fn gpt_image_edit_rejects_six_references_before_network_send() { let references = (0..6).map(reference_image).collect::>(); - let error = create_vector_engine_image_edit_with_references_and_model( + let error = create_image_edit_with_references_and_model( &reqwest::Client::new(), - &test_settings(), + &tiantoken_settings(), GPT_IMAGE_2_MODEL, "测试提示词", None, @@ -1145,10 +956,10 @@ mod tests { #[tokio::test] async fn nanobanana_rejects_fifteen_references_before_network_send() { let references = (0..15).map(reference_image).collect::>(); - let error = create_vector_engine_nanobanana_generate_content( + let error = create_nanobanana_generate_content( &reqwest::Client::new(), - &test_settings(), - super::super::constants::NANOBANANA_2_MODEL, + &vector_engine_settings(), + crate::image_provider::constants::NANOBANANA_2_MODEL, "测试提示词", None, "1:1", @@ -1164,13 +975,14 @@ mod tests { #[tokio::test] async fn expired_deadline_stops_generation_before_network_send() { - let settings = VectorEngineImageSettings { + let settings = ImageProviderSettings { + provider: ImageProvider::Tiantoken, base_url: "http://127.0.0.1:9".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, request_deadline: Some(Instant::now()), }; - let error = create_vector_engine_image_generation( + let error = create_image_generation( &reqwest::Client::new(), &settings, "测试提示词", @@ -1196,133 +1008,84 @@ mod tests { ); } + #[tokio::test] + async fn image_generation_rejects_model_from_another_provider() { + let error = create_image_generation( + &reqwest::Client::new(), + &vector_engine_settings(), + "测试提示词", + None, + "1024x1024", + 1, + &[], + "测试图片生成失败", + ) + .await + .expect_err("provider mismatch must fail fast"); + + match error { + PlatformImageError::InvalidRequest { message, .. } => { + assert!( + message.contains("设置不一致"), + "unexpected message: {message}" + ); + } + other => panic!("expected InvalidRequest, got {other:?}"), + } + } + #[test] - fn vector_engine_send_retry_policy_allows_four_retries_before_final_attempt() { + fn image_provider_send_retry_policy_allows_four_retries_before_final_attempt() { assert_eq!(VECTOR_ENGINE_SEND_MAX_ATTEMPTS, 5); } #[test] - fn vector_engine_send_retry_policy_treats_ssl_reset_as_transient_transport() { - let error = super::super::curl_transport::VectorEngineCurlError::Curl(curl::Error::new(35)); + fn image_provider_send_retry_policy_treats_ssl_reset_as_transient_transport() { + let error = crate::image_provider::transport::curl::ImageProviderCurlError::Curl( + curl::Error::new(35), + ); assert!(error.is_transient_transport()); - assert!(should_retry_vector_engine_curl_send_error(&error, 1)); - assert!(!should_retry_vector_engine_curl_send_error(&error, 5)); + assert!(should_retry_image_curl_send_error(&error, 1)); + assert!(!should_retry_image_curl_send_error(&error, 5)); } #[test] - fn vector_engine_send_retry_policy_treats_recv_eof_as_transient_transport() { - let error = super::super::curl_transport::VectorEngineCurlError::Curl(curl::Error::new(56)); + fn image_provider_send_retry_policy_treats_recv_eof_as_transient_transport() { + let error = crate::image_provider::transport::curl::ImageProviderCurlError::Curl( + curl::Error::new(56), + ); assert!(error.is_transient_transport()); - assert!(should_retry_vector_engine_curl_send_error(&error, 1)); - assert!(!should_retry_vector_engine_curl_send_error(&error, 5)); + assert!(should_retry_image_curl_send_error(&error, 1)); + assert!(!should_retry_image_curl_send_error(&error, 5)); } #[test] - fn vector_engine_send_retry_policy_treats_upstream_502_as_retryable() { - assert!(should_retry_vector_engine_upstream_response(502, "", 1)); - assert!(should_retry_vector_engine_upstream_response(429, "", 1)); - assert!(should_retry_vector_engine_upstream_response( + fn image_provider_send_retry_policy_treats_upstream_502_as_retryable() { + assert!(should_retry_image_upstream_response(502, "", 1)); + assert!(should_retry_image_upstream_response(429, "", 1)); + assert!(should_retry_image_upstream_response( 429, "request rejected due to rate limit", 1, )); - assert!(should_retry_vector_engine_upstream_response(408, "", 1)); - assert!(!should_retry_vector_engine_upstream_response( + assert!(should_retry_image_upstream_response(408, "", 1)); + assert!(!should_retry_image_upstream_response( 429, "内容审核拒绝", 1, )); - assert!(!should_retry_vector_engine_upstream_response(400, "", 1)); - assert!(!should_retry_vector_engine_upstream_response(502, "", 5)); + assert!(!should_retry_image_upstream_response(400, "", 1)); + assert!(!should_retry_image_upstream_response(502, "", 5)); } #[test] - fn model_fallback_only_accepts_eligible_provider_failures() { - let settings = VectorEngineImageSettings { - base_url: "https://vector.example/v1".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000, - request_deadline: None, - }; - let unsupported_model = PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, - message: "model gpt-image-2 is not supported".to_string(), - upstream_status: 400, - raw_excerpt: "unknown model".to_string(), - audit: None, - }; - let content_rejection = PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, - message: "moderation blocked".to_string(), - upstream_status: 429, - raw_excerpt: "invalid_prompt".to_string(), - audit: None, - }; - let uncertain_send_failure = PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, - message: "send failed".to_string(), - endpoint: None, - timeout: true, - connect: false, - request: true, - body: false, - status_code: None, - source: None, - audit: None, - }; - - assert!(should_fallback_to_gpt_image_2_c( - GPT_IMAGE_2_MODEL, - GPT_IMAGE_2_MODEL, - 1, - &unsupported_model, - &settings, - )); - assert!(!should_fallback_to_gpt_image_2_c( - GPT_IMAGE_2_MODEL, - GPT_IMAGE_2_MODEL, - 1, - &content_rejection, - &settings, - )); - let rate_limit_rejection = PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, - message: "request rejected due to rate limit".to_string(), - upstream_status: 429, - raw_excerpt: "rate_limit_exceeded".to_string(), - audit: None, - }; - assert!(should_fallback_to_gpt_image_2_c( - GPT_IMAGE_2_MODEL, - GPT_IMAGE_2_MODEL, - 1, - &rate_limit_rejection, - &settings, - )); - assert!(!should_fallback_to_gpt_image_2_c( - GPT_IMAGE_2_MODEL, - GPT_IMAGE_2_MODEL, - 1, - &uncertain_send_failure, - &settings, - )); - assert!(!should_fallback_to_gpt_image_2_c( - GPT_IMAGE_2_MODEL, - GPT_IMAGE_2_MODEL, - VECTOR_ENGINE_SEND_MAX_ATTEMPTS, - &unsupported_model, - &settings, - )); - } - - #[test] - fn vector_engine_send_retry_delay_uses_exponential_backoff_with_bounded_jitter() { - assert_eq!(vector_engine_send_retry_delay_ms(1, 0), 500); - assert_eq!(vector_engine_send_retry_delay_ms(2, 0), 1_000); - assert_eq!(vector_engine_send_retry_delay_ms(3, 0), 2_000); - assert_eq!(vector_engine_send_retry_delay_ms(4, 0), 4_000); - assert_eq!(vector_engine_send_retry_delay_ms(4, 999), 4_999); + fn image_provider_send_retry_delay_uses_exponential_backoff_with_bounded_jitter() { + assert_eq!(image_provider_send_retry_delay_ms(1, 0), 500); + assert_eq!(image_provider_send_retry_delay_ms(2, 0), 1_000); + assert_eq!(image_provider_send_retry_delay_ms(3, 0), 2_000); + assert_eq!(image_provider_send_retry_delay_ms(4, 0), 4_000); + assert_eq!(image_provider_send_retry_delay_ms(4, 999), 4_999); } } diff --git a/server-rs/crates/platform-image/src/vector_engine/image_source.rs b/server-rs/crates/platform-image/src/image_provider/runtime/image_source.rs similarity index 90% rename from server-rs/crates/platform-image/src/vector_engine/image_source.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/image_source.rs index dbc0b38a4..864cc3d07 100644 --- a/server-rs/crates/platform-image/src/vector_engine/image_source.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/image_source.rs @@ -2,21 +2,23 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use reqwest::header; use std::time::Instant; -use super::{ +use crate::image_provider::{ budget::request_budget_exhausted_error, - constants::{VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER}, + constants::VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, error::PlatformImageError, - types::{DownloadedImage, GeneratedImages, ReferenceImage}, + types::{DownloadedImage, GeneratedImages, ImageProvider, ReferenceImage}, }; pub async fn download_remote_image( http_client: &reqwest::Client, image_url: &str, + provider: ImageProvider, ) -> Result { let response = http_client.get(image_url).send().await.map_err(|error| { map_simple_request_error( format!("下载生成图片失败:{error}"), Some(image_url.to_string()), + provider, ) })?; let status = response.status(); @@ -30,11 +32,12 @@ pub async fn download_remote_image( map_simple_request_error( format!("读取生成图片内容失败:{error}"), Some(image_url.to_string()), + provider, ) })?; if !status.is_success() { return Err(PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: "下载生成图片失败".to_string(), endpoint: Some(image_url.to_string()), timeout: false, @@ -58,15 +61,17 @@ pub async fn download_remote_image( async fn download_remote_image_with_deadline( http_client: &reqwest::Client, image_url: &str, + provider: ImageProvider, request_deadline: Option, operation: &str, ) -> Result { let Some(request_deadline) = request_deadline else { - return download_remote_image(http_client, image_url).await; + return download_remote_image(http_client, image_url, provider).await; }; let started_at = Instant::now(); if request_deadline <= started_at { return Err(request_budget_exhausted_error( + provider, image_url, operation, None, @@ -77,11 +82,12 @@ async fn download_remote_image_with_deadline( } tokio::time::timeout_at( tokio::time::Instant::from_std(request_deadline), - download_remote_image(http_client, image_url), + download_remote_image(http_client, image_url, provider), ) .await .map_err(|_| { request_budget_exhausted_error( + provider, image_url, operation, None, @@ -97,6 +103,7 @@ pub(crate) async fn download_images_from_urls( task_id: String, image_urls: Vec, candidate_count: u32, + provider: ImageProvider, request_deadline: Option, operation: &str, ) -> Result { @@ -109,6 +116,7 @@ pub(crate) async fn download_images_from_urls( download_remote_image_with_deadline( http_client, image_url.as_str(), + provider, request_deadline, operation, ) @@ -119,13 +127,13 @@ pub(crate) async fn download_images_from_urls( task_id, actual_prompt: None, images, - recovered_failure_audits: Vec::new(), }) } pub(crate) async fn resolve_reference_images( http_client: &reqwest::Client, reference_images: &[String], + provider: ImageProvider, failure_context: &str, request_deadline: Option, ) -> Result, PlatformImageError> { @@ -135,7 +143,7 @@ pub(crate) async fn resolve_reference_images( .count(); if reference_count > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!( "{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {reference_count} 张。" ), @@ -147,7 +155,7 @@ pub(crate) async fn resolve_reference_images( if source.is_empty() { continue; } - if let Some(reference_image) = parse_reference_image_data_url(source, index)? { + if let Some(reference_image) = parse_reference_image_data_url(source, index, provider)? { resolved.push(reference_image); continue; } @@ -155,6 +163,7 @@ pub(crate) async fn resolve_reference_images( let downloaded = download_remote_image_with_deadline( http_client, source, + provider, request_deadline, failure_context, ) @@ -171,14 +180,14 @@ pub(crate) async fn resolve_reference_images( continue; } return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"), }); } if resolved.is_empty() { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("{failure_context}:图片编辑需要至少一张参考图。"), }); } @@ -189,25 +198,26 @@ pub(crate) async fn resolve_reference_images( pub(crate) fn parse_reference_image_data_url( source: &str, index: usize, + provider: ImageProvider, ) -> Result, PlatformImageError> { let Some(body) = source.strip_prefix("data:") else { return Ok(None); }; let Some((mime_type, data)) = body.split_once(";base64,") else { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: "参考图 Data URL 必须是 base64 图片。".to_string(), }); }; if !mime_type.starts_with("image/") { return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: "参考图 Data URL 必须是图片类型。".to_string(), }); } let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| { PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("参考图 Data URL 解码失败:{error}"), } })?; @@ -237,7 +247,6 @@ pub(crate) fn images_from_base64( task_id, actual_prompt: None, images, - recovered_failure_audits: Vec::new(), } } @@ -290,9 +299,13 @@ pub(crate) fn infer_image_mime_type(bytes: &[u8]) -> Option { None } -fn map_simple_request_error(message: String, endpoint: Option) -> PlatformImageError { +fn map_simple_request_error( + message: String, + endpoint: Option, + provider: ImageProvider, +) -> PlatformImageError { PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message, endpoint, timeout: false, @@ -349,6 +362,7 @@ mod tests { let error = download_remote_image_with_deadline( &http_client, "http://127.0.0.1:9/not-called.png", + ImageProvider::VectorEngine, Some(Instant::now()), "下载测试图片失败", ) @@ -387,6 +401,7 @@ mod tests { let error = download_remote_image_with_deadline( &reqwest::Client::new(), format!("http://{address}/pending.png").as_str(), + ImageProvider::VectorEngine, Some(Instant::now() + Duration::from_millis(100)), "下载测试图片失败", ) diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs b/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs new file mode 100644 index 000000000..b78e51476 --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/runtime/mod.rs @@ -0,0 +1,26 @@ +pub(crate) mod audit; +pub(crate) mod budget; +pub(crate) mod error; +pub(crate) mod executor; +pub(crate) mod image_source; +pub(crate) mod types; +pub(crate) mod util; + +pub(crate) use crate::image_provider::transport::curl as curl_transport; + +pub use audit::PlatformImageFailureAudit; +pub(crate) use audit::build_failure_audit; +pub(crate) use budget::{effective_request_timeout_ms, request_budget_exhausted_error}; +pub use error::{PlatformImageError, PlatformImageStatusHint, missing_reference_images_error}; +pub(crate) use executor::ensure_provider_matches_model; +pub use executor::{ + create_image_edit, create_image_edit_with_references, + create_image_edit_with_references_and_model, create_image_generation, + create_image_generation_with_model, create_nanobanana_generate_content, +}; +pub use image_source::download_remote_image; +pub use types::{ + DownloadedImage, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings, + ReferenceImage, +}; +pub(crate) use util::truncate_raw; diff --git a/server-rs/crates/platform-image/src/image_provider/runtime/types.rs b/server-rs/crates/platform-image/src/image_provider/runtime/types.rs new file mode 100644 index 000000000..8594c622a --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/runtime/types.rs @@ -0,0 +1,96 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ImageProvider { + VectorEngine, + Tiantoken, +} + +impl ImageProvider { + pub const fn as_str(self) -> &'static str { + match self { + Self::VectorEngine => crate::image_provider::constants::VECTOR_ENGINE_PROVIDER, + Self::Tiantoken => crate::image_provider::constants::TIANTOKEN_PROVIDER, + } + } +} + +#[derive(Clone)] +pub struct ImageProviderSettings { + pub provider: ImageProvider, + pub base_url: String, + pub api_key: String, + pub request_timeout_ms: u64, + pub request_deadline: Option, +} + +impl std::fmt::Debug for ImageProviderSettings { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // 中文注释:与 ImageProviderClient 的 Debug 保持一致,避免日志泄漏 api_key。 + formatter + .debug_struct("ImageProviderSettings") + .field("provider", &self.provider) + .field("base_url", &self.base_url) + .field("api_key", &"") + .field("request_timeout_ms", &self.request_timeout_ms) + .field("request_deadline", &self.request_deadline) + .finish() + } +} + +#[derive(Clone)] +pub struct ImageProviderClient { + settings: ImageProviderSettings, + http_client: reqwest::Client, +} + +impl std::fmt::Debug for ImageProviderClient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ImageProviderClient") + .field("provider", &self.settings.provider) + .field("base_url", &self.settings.base_url) + .field("api_key", &"") + .finish() + } +} + +impl ImageProviderClient { + pub(crate) fn new(settings: ImageProviderSettings, http_client: reqwest::Client) -> Self { + Self { + settings, + http_client, + } + } + + pub fn provider(&self) -> ImageProvider { + self.settings.provider + } + + pub fn settings(&self) -> &ImageProviderSettings { + &self.settings + } + + pub fn http_client(&self) -> &reqwest::Client { + &self.http_client + } +} + +#[derive(Clone, Debug)] +pub struct GeneratedImages { + pub task_id: String, + pub actual_prompt: Option, + pub images: Vec, +} + +#[derive(Clone, Debug)] +pub struct DownloadedImage { + pub bytes: Vec, + pub mime_type: String, + pub extension: String, +} + +#[derive(Clone, Debug)] +pub struct ReferenceImage { + pub bytes: Vec, + pub mime_type: String, + pub file_name: String, +} diff --git a/server-rs/crates/platform-image/src/vector_engine/util.rs b/server-rs/crates/platform-image/src/image_provider/runtime/util.rs similarity index 87% rename from server-rs/crates/platform-image/src/vector_engine/util.rs rename to server-rs/crates/platform-image/src/image_provider/runtime/util.rs index 621d57c6a..a125aedb6 100644 --- a/server-rs/crates/platform-image/src/vector_engine/util.rs +++ b/server-rs/crates/platform-image/src/image_provider/runtime/util.rs @@ -1,6 +1,6 @@ use serde_json::Value; -use super::{audit::PlatformImageFailureAudit, error::PlatformImageError}; +use crate::image_provider::{audit::PlatformImageFailureAudit, error::PlatformImageError}; pub(crate) fn is_timeout_message(message: &str) -> bool { let lower = message.to_ascii_lowercase(); @@ -79,13 +79,6 @@ impl PlatformImageError { message, audit: Some(audit), }, - Self::FallbackFailed { - final_error, - recovered_failure_audits, - } => Self::FallbackFailed { - final_error: Box::new(final_error.with_audit(audit)), - recovered_failure_audits, - }, Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => self, } } diff --git a/server-rs/crates/platform-image/src/image_provider/tests.rs b/server-rs/crates/platform-image/src/image_provider/tests.rs new file mode 100644 index 000000000..5d96e0a8e --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/tests.rs @@ -0,0 +1,237 @@ +use super::*; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde_json::json; + +use super::image_source::{decode_generated_image_base64, parse_reference_image_data_url}; +use super::payload::{extract_b64_images, extract_image_urls}; +use super::request::normalize_nanobanana_model; + +#[test] +fn request_body_normalizes_size_prompt_and_candidate_count() { + let body = build_image_request_body( + " 风雨夜里的街道 ", + Some(" 低清,水印 "), + " 1:1 ", + 10, + &["data:image/png;base64,AAAA".to_string()], + ); + + assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); + assert_eq!(body["size"], "1024x1024"); + assert_eq!(body["n"], 4); + assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印"); + assert!(body.get("image").is_none()); +} + +#[test] +fn provider_urls_normalize_root_and_v1_base_urls() { + let root_settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, + base_url: "https://vector.example".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + request_deadline: None, + }; + let v1_settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, + base_url: "https://vector.example/v1".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + request_deadline: None, + }; + + assert_eq!( + images_generation_url(&root_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + images_generation_url(&v1_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + images_edit_url(&root_settings), + "https://vector.example/v1/images/edits" + ); + assert_eq!( + images_edit_url(&v1_settings), + "https://vector.example/v1/images/edits" + ); +} + +#[test] +fn nanobanana_model_resolution_defaults_blank_values_to_nanobanana() { + assert_eq!(normalize_nanobanana_model(" "), NANOBANANA_2_MODEL); + assert_eq!( + normalize_nanobanana_model(" gemini-3.1-flash-image-preview "), + NANOBANANA_2_MODEL + ); + assert_eq!( + normalize_nanobanana_model(GPT_IMAGE_2_5_EDIT_MODEL), + GPT_IMAGE_2_5_EDIT_MODEL + ); +} + +#[test] +fn data_url_and_base64_image_decoding_preserves_image_metadata() { + let data_url = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") + ); + + let reference = parse_reference_image_data_url(&data_url, 2, ImageProvider::VectorEngine) + .expect("data url should parse") + .expect("image data url should be accepted"); + assert_eq!(reference.file_name, "reference-2.png"); + assert_eq!(reference.mime_type, "image/png"); + assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest"); + + let image = + decode_generated_image_base64(BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str()) + .expect("base64 image should decode"); + assert_eq!(image.extension, "png"); + assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest"); +} + +#[test] +fn error_status_hints_and_audit_fields_are_structured() { + let audit = PlatformImageFailureAudit { + provider: VECTOR_ENGINE_PROVIDER, + endpoint: "https://vector.example/v1/images/generations".to_string(), + operation: "图片生成失败".to_string(), + failure_stage: "upstream_status", + status_code: Some(504), + status_class: Some("5xx"), + timeout: true, + retryable: true, + error_message: "上游超时".to_string(), + error_source: Some("read timeout".to_string()), + raw_excerpt: Some("{\"error\":\"timeout\"}".to_string()), + latency_ms: Some(987), + prompt_chars: Some(64), + reference_image_count: Some(2), + image_model: Some(GPT_IMAGE_2_MODEL), + }; + + let request_error = PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: "请求发送失败".to_string(), + endpoint: Some("https://vector.example/v1/images/generations".to_string()), + timeout: true, + connect: false, + request: true, + body: false, + status_code: None, + source: None, + audit: None, + }; + let invalid_config = PlatformImageError::InvalidConfig { + provider: VECTOR_ENGINE_PROVIDER, + message: "缺少配置".to_string(), + }; + let invalid_request = PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "请求不合法".to_string(), + }; + let upstream_timeout = PlatformImageError::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message: "upstream timeout".to_string(), + upstream_status: 502, + raw_excerpt: "deadline has elapsed".to_string(), + audit: Some(audit.clone()), + }; + + assert_eq!( + invalid_config.status_hint(), + PlatformImageStatusHint::ServiceUnavailable + ); + assert_eq!( + invalid_request.status_hint(), + PlatformImageStatusHint::BadRequest + ); + assert_eq!( + request_error.status_hint(), + PlatformImageStatusHint::GatewayTimeout + ); + assert_eq!( + upstream_timeout.status_hint(), + PlatformImageStatusHint::GatewayTimeout + ); + assert_eq!( + PlatformImageError::MissingImage { + provider: VECTOR_ENGINE_PROVIDER, + message: "缺图".to_string(), + audit: Some(audit.clone()), + } + .status_hint(), + PlatformImageStatusHint::BadGateway + ); + + let audit_ref = upstream_timeout.audit().expect("audit should be preserved"); + assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER); + assert_eq!( + audit_ref.endpoint, + "https://vector.example/v1/images/generations" + ); + assert_eq!(audit_ref.status_code, Some(504)); + assert_eq!(audit_ref.status_class, Some("5xx")); + assert!(audit_ref.timeout); + assert!(audit_ref.retryable); + assert_eq!(audit_ref.reference_image_count, Some(2)); + assert_eq!(audit_ref.image_model, Some(GPT_IMAGE_2_MODEL)); + assert!(invalid_config.audit().is_none()); + assert!(invalid_request.audit().is_none()); +} + +#[test] +fn extract_image_urls_are_deduped_and_b64_values_keep_payload_order() { + let payload = json!({ + "data": [ + {"image": "https://example.com/a.png"}, + {"url": "https://example.com/a.png"}, + {"image_url": "ftp://example.com/b.png"}, + {"url": "https://example.com/b.png"} + ], + "nested": { + "b64_json": ["YWJj", "ZGVm"], + "parts": [ + { + "inlineData": { + "mimeType": "image/png", + "data": "aW1hZ2UtMQ==" + } + }, + { + "inline_data": { + "mime_type": "image/jpeg", + "data": "aW1hZ2UtMg==" + } + }, + { + "inlineData": { + "mimeType": "text/plain", + "data": "bm90LWltYWdl" + } + } + ] + } + }); + + assert_eq!( + extract_image_urls(&payload), + vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string() + ] + ); + assert_eq!( + extract_b64_images(&payload), + vec![ + "YWJj".to_string(), + "ZGVm".to_string(), + "aW1hZ2UtMQ==".to_string(), + "aW1hZ2UtMg==".to_string(), + ] + ); +} diff --git a/server-rs/crates/platform-image/src/image_provider/transport/client.rs b/server-rs/crates/platform-image/src/image_provider/transport/client.rs new file mode 100644 index 000000000..51bb6c71c --- /dev/null +++ b/server-rs/crates/platform-image/src/image_provider/transport/client.rs @@ -0,0 +1,30 @@ +use std::time::Duration; + +use crate::image_provider::{ + error::PlatformImageError, + types::{ImageProviderClient, ImageProviderSettings}, +}; + +pub fn build_image_http_client( + settings: &ImageProviderSettings, +) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) + .http1_only() + .pool_max_idle_per_host(0) + .build() + .map_err(|error| PlatformImageError::InvalidConfig { + provider: settings.provider.as_str(), + message: format!( + "构造 {} 图片生成 HTTP 客户端失败:{error}", + settings.provider.as_str() + ), + }) +} + +pub fn build_image_provider_client( + settings: ImageProviderSettings, +) -> Result { + let http_client = build_image_http_client(&settings)?; + Ok(ImageProviderClient::new(settings, http_client)) +} diff --git a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs b/server-rs/crates/platform-image/src/image_provider/transport/curl.rs similarity index 86% rename from server-rs/crates/platform-image/src/vector_engine/curl_transport.rs rename to server-rs/crates/platform-image/src/image_provider/transport/curl.rs index fbe94e1b1..800c5bbee 100644 --- a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs +++ b/server-rs/crates/platform-image/src/image_provider/transport/curl.rs @@ -6,25 +6,27 @@ use curl::{ }; use serde_json::Value; -use super::{ - audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, - request::build_prompt_with_negative, types::ReferenceImage, +use crate::image_provider::{ + audit::build_failure_audit, + error::PlatformImageError, + request::build_prompt_with_negative, + types::{ImageProvider, ReferenceImage}, }; #[derive(Debug)] -pub(crate) struct VectorEngineCurlResponse { +pub(crate) struct ImageProviderCurlResponse { pub(crate) status: u16, pub(crate) body: String, } #[derive(Debug)] -pub(crate) enum VectorEngineCurlError { +pub(crate) enum ImageProviderCurlError { Curl(curl::Error), Form(FormError), WorkerJoin(tokio::task::JoinError), } -impl VectorEngineCurlError { +impl ImageProviderCurlError { pub(crate) fn is_timeout(&self) -> bool { match self { Self::Curl(error) => error.is_operation_timedout(), @@ -63,7 +65,7 @@ impl VectorEngineCurlError { } } -impl fmt::Display for VectorEngineCurlError { +impl fmt::Display for ImageProviderCurlError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Curl(error) => write!(formatter, "{error}"), @@ -73,26 +75,26 @@ impl fmt::Display for VectorEngineCurlError { } } -impl Error for VectorEngineCurlError {} +impl Error for ImageProviderCurlError {} -impl From for VectorEngineCurlError { +impl From for ImageProviderCurlError { fn from(error: curl::Error) -> Self { Self::Curl(error) } } -impl From for VectorEngineCurlError { +impl From for ImageProviderCurlError { fn from(error: FormError) -> Self { Self::Form(error) } } -pub(crate) async fn send_vector_engine_json_request_with_curl( +pub(crate) async fn send_image_json_request_with_curl( request_url: &str, api_key: &str, request_body: &Value, timeout_ms: u64, -) -> Result { +) -> Result { let request_url = request_url.to_string(); let api_key = api_key.to_string(); let request_body = request_body.to_string(); @@ -105,11 +107,11 @@ pub(crate) async fn send_vector_engine_json_request_with_curl( ) }) .await - .map_err(VectorEngineCurlError::WorkerJoin)? + .map_err(ImageProviderCurlError::WorkerJoin)? } #[allow(clippy::too_many_arguments)] -pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( +pub(crate) async fn send_image_multipart_edit_request_with_curl( request_url: &str, api_key: &str, model: &str, @@ -119,7 +121,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( candidate_count: u32, reference_images: &[ReferenceImage], timeout_ms: u64, -) -> Result { +) -> Result { let request_url = request_url.to_string(); let api_key = api_key.to_string(); let model = model.to_string(); @@ -141,25 +143,28 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( ) }) .await - .map_err(VectorEngineCurlError::WorkerJoin)? + .map_err(ImageProviderCurlError::WorkerJoin)? } pub(crate) fn map_curl_error( + provider: ImageProvider, context: &str, request_url: &str, failure_stage: &'static str, image_model: Option<&'static str>, - error: VectorEngineCurlError, + error: ImageProviderCurlError, latency_ms: u64, prompt_chars: Option, reference_image_count: Option, request_params: Option<&Value>, ) -> PlatformImageError { + let provider_label = provider.as_str(); let is_timeout = error.is_timeout(); let is_connect = error.is_connect() || error.is_transient_transport(); let source = error.to_string(); let message = format!("{context}:{source}"); let audit = build_failure_audit( + provider_label, request_url, context, failure_stage, @@ -176,7 +181,7 @@ pub(crate) fn map_curl_error( image_model, ); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %request_url, failure_stage, timeout = is_timeout, @@ -195,11 +200,11 @@ pub(crate) fn map_curl_error( request_params = %request_params .map(|value| value.to_string()) .unwrap_or_default(), - "VectorEngine 图片 libcurl 请求失败" + "ImageProvider 图片 libcurl 请求失败" ); PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, endpoint: Some(request_url.to_string()), timeout: is_timeout, @@ -217,8 +222,8 @@ fn send_json_request_with_curl_blocking( api_key: &str, request_body: &str, timeout_ms: u64, -) -> Result { - let mut headers = vector_engine_curl_headers(api_key)?; +) -> Result { + let mut headers = image_curl_headers(api_key)?; headers.append("Content-Type: application/json")?; let mut easy = Easy::new(); easy.url(request_url)?; @@ -240,7 +245,7 @@ fn send_multipart_edit_request_with_curl_blocking( candidate_count: u32, reference_images: &[ReferenceImage], timeout_ms: u64, -) -> Result { +) -> Result { let mut form = Form::new(); form.part("model").contents(model.as_bytes()).add()?; form.part("prompt") @@ -263,7 +268,7 @@ fn send_multipart_edit_request_with_curl_blocking( .add()?; } - let headers = vector_engine_curl_headers(api_key)?; + let headers = image_curl_headers(api_key)?; let mut easy = Easy::new(); easy.url(request_url)?; easy.httppost(form)?; @@ -272,14 +277,14 @@ fn send_multipart_edit_request_with_curl_blocking( Ok(perform_curl_request(easy)?) } -fn vector_engine_curl_headers(api_key: &str) -> Result { +fn image_curl_headers(api_key: &str) -> Result { let mut headers = List::new(); headers.append(format!("Authorization: Bearer {api_key}").as_str())?; headers.append("Accept: application/json")?; Ok(headers) } -fn perform_curl_request(mut easy: Easy) -> Result { +fn perform_curl_request(mut easy: Easy) -> Result { let mut body = Vec::new(); { let mut transfer = easy.transfer(); @@ -291,13 +296,13 @@ fn perform_curl_request(mut easy: Easy) -> Result Result { + let provider_label = settings.provider.as_str(); + // 中文注释:raw edit 只发送 GPT Image 2.5 编辑 concrete model,先按共享白名单校验 + // provider,避免把 Tiantoken 的模型发到 VectorEngine 端点(或反之)。 + ensure_provider_matches_model(settings, GPT_IMAGE_2_5_EDIT_MODEL, failure_context)?; validate_raw_image_edit_dimensions(options.width, options.height) - .map_err(|error| invalid_input(failure_context, error.to_string()))?; - let url = vector_engine_images_edit_url(settings); + .map_err(|error| invalid_input(settings.provider, failure_context, error.to_string()))?; + let url = images_edit_url(settings); let started_at = Instant::now(); let prompt_chars = Some(prompt.chars().count()); let reference_image_count = Some(1_usize + usize::from(options.mask.is_some())); @@ -135,9 +139,10 @@ pub async fn create_vector_engine_raw_image_edit( effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline) else { return Err(request_budget_exhausted_error( + settings.provider, url.as_str(), failure_context, - Some(GPT_IMAGE_2_MODEL), + Some(GPT_IMAGE_2_5_EDIT_MODEL), Some(started_at.elapsed().as_millis() as u64), prompt_chars, reference_image_count, @@ -149,7 +154,7 @@ pub async fn create_vector_engine_raw_image_edit( mime_type: image_mime_type, } = image; let mut form = Form::new() - .text("model", GPT_IMAGE_2_MODEL.to_string()) + .text("model", GPT_IMAGE_2_5_EDIT_MODEL.to_string()) .text("n", "1".to_string()) .text("prompt", prompt.to_string()) .text("size", format!("{}x{}", options.width, options.height)) @@ -161,7 +166,9 @@ pub async fn create_vector_engine_raw_image_edit( Part::stream(reqwest::Body::from(image_bytes)) .file_name(image_file_name) .mime_str(image_mime_type.as_str()) - .map_err(|error| invalid_request(failure_context, error.to_string()))?, + .map_err(|error| { + invalid_request(settings.provider, failure_context, error.to_string()) + })?, ); if let Some(value) = options.quality { form = form.text("quality", value); @@ -178,7 +185,9 @@ pub async fn create_vector_engine_raw_image_edit( Part::stream(reqwest::Body::from(mask.bytes)) .file_name(mask.file_name) .mime_str(mask.mime_type.as_str()) - .map_err(|error| invalid_request(failure_context, error.to_string()))?, + .map_err(|error| { + invalid_request(settings.provider, failure_context, error.to_string()) + })?, ); } @@ -191,6 +200,7 @@ pub async fn create_vector_engine_raw_image_edit( .await .map_err(|error| { request_error( + settings.provider, &url, failure_context, "request_send", @@ -202,17 +212,18 @@ pub async fn create_vector_engine_raw_image_edit( })?; let status = response.status(); tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %url, status = status.as_u16(), prompt_chars, reference_image_count, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine Raw 图片编辑 HTTP 返回" + "ImageProvider Raw 图片编辑 HTTP 返回" ); let body = response.text().await.map_err(|error| { request_error( + settings.provider, &url, failure_context, "response_read", @@ -229,6 +240,7 @@ pub async fn create_vector_engine_raw_image_edit( ); let raw_excerpt = truncate_raw(body.as_str()); let audit = build_failure_audit( + provider_label, url.as_str(), failure_context, "upstream_status", @@ -242,10 +254,10 @@ pub async fn create_vector_engine_raw_image_edit( Some(started_at.elapsed().as_millis() as u64), prompt_chars, reference_image_count, - Some(GPT_IMAGE_2_MODEL), + Some(GPT_IMAGE_2_5_EDIT_MODEL), ); return Err(PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, upstream_status: status.as_u16(), raw_excerpt, @@ -258,6 +270,7 @@ pub async fn create_vector_engine_raw_image_edit( let message = format!("{failure_context}:上游响应不是 JSON:{error}"); let raw_excerpt = truncate_raw(body.as_str()); let audit = build_failure_audit( + provider_label, url.as_str(), failure_context, "response_parse", @@ -271,10 +284,10 @@ pub async fn create_vector_engine_raw_image_edit( Some(started_at.elapsed().as_millis() as u64), prompt_chars, reference_image_count, - Some(GPT_IMAGE_2_MODEL), + Some(GPT_IMAGE_2_5_EDIT_MODEL), ); return Err(PlatformImageError::ResponseParse { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, raw_excerpt, audit: Some(audit), @@ -285,6 +298,7 @@ pub async fn create_vector_engine_raw_image_edit( if b64_images.is_empty() { let message = format!("{failure_context}:上游未返回 b64_json 图片"); let audit = build_failure_audit( + provider_label, url.as_str(), failure_context, "missing_image", @@ -298,10 +312,10 @@ pub async fn create_vector_engine_raw_image_edit( Some(started_at.elapsed().as_millis() as u64), prompt_chars, reference_image_count, - Some(GPT_IMAGE_2_MODEL), + Some(GPT_IMAGE_2_5_EDIT_MODEL), ); return Err(PlatformImageError::MissingImage { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, audit: Some(audit), }); @@ -316,21 +330,22 @@ fn collect_b64_images(data: Vec) -> Vec { .collect() } -fn invalid_request(context: &str, message: String) -> PlatformImageError { +fn invalid_request(provider: ImageProvider, context: &str, message: String) -> PlatformImageError { PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("{context}:构造上游请求失败:{message}"), } } -fn invalid_input(context: &str, message: String) -> PlatformImageError { +fn invalid_input(provider: ImageProvider, context: &str, message: String) -> PlatformImageError { PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider.as_str(), message: format!("{context}:请求参数无效:{message}"), } } fn request_error( + provider: ImageProvider, url: &str, context: &str, failure_stage: &'static str, @@ -339,12 +354,13 @@ fn request_error( prompt_chars: Option, reference_image_count: Option, ) -> PlatformImageError { + let provider_label = provider.as_str(); let timeout = error.is_timeout(); let connect = error.is_connect(); let source = error.to_string(); let message = format!("{context}:上游请求失败:{source}"); tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, + provider = provider_label, endpoint = %url, failure_stage, timeout, @@ -354,9 +370,10 @@ fn request_error( elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context = context, error = %source, - "VectorEngine Raw 图片编辑请求失败" + "ImageProvider Raw 图片编辑请求失败" ); let audit = build_failure_audit( + provider_label, url, context, failure_stage, @@ -370,10 +387,10 @@ fn request_error( Some(started_at.elapsed().as_millis() as u64), prompt_chars, reference_image_count, - Some(GPT_IMAGE_2_MODEL), + Some(GPT_IMAGE_2_5_EDIT_MODEL), ); PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, + provider: provider_label, message, endpoint: Some(url.to_string()), timeout, @@ -462,7 +479,11 @@ mod tests { #[test] fn dimension_validation_error_identifies_client_input() { - let error = invalid_input("raw_image_edit", "尺寸无效".to_string()); + let error = invalid_input( + ImageProvider::Tiantoken, + "raw_image_edit", + "尺寸无效".to_string(), + ); assert_eq!(error.to_string(), "raw_image_edit:请求参数无效:尺寸无效"); } diff --git a/server-rs/crates/platform-image/src/vector_engine/mod.rs b/server-rs/crates/platform-image/src/vector_engine/mod.rs deleted file mode 100644 index 25e984180..000000000 --- a/server-rs/crates/platform-image/src/vector_engine/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -mod audit; -mod budget; -mod client; -mod constants; -mod curl_transport; -mod error; -mod image_source; -mod payload; -mod raw_edit; -mod request; -mod response; -mod transport; -mod types; -mod util; - -pub use audit::PlatformImageFailureAudit; -pub use client::{ - create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, - create_vector_engine_image_edit_with_references_and_model, - create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, - create_vector_engine_nanobanana_generate_content, -}; -pub use constants::{ - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, - VECTOR_ENGINE_PROVIDER, -}; -pub use error::{PlatformImageError, PlatformImageStatusHint}; -pub use image_source::download_remote_image; -pub use raw_edit::{ - GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, - RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, RawImageEditDimensionError, RawImageEditImage, - RawImageEditOptions, RawImageEditResult, create_vector_engine_raw_image_edit, - validate_raw_image_edit_dimensions, -}; -pub use request::{ - build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model, - build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size_for_model, - vector_engine_images_edit_url, vector_engine_images_generation_url, - vector_engine_nanobanana_generate_content_url, -}; -pub use transport::build_vector_engine_image_http_client; -pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings}; diff --git a/server-rs/crates/platform-image/src/vector_engine/tests.rs b/server-rs/crates/platform-image/src/vector_engine/tests.rs deleted file mode 100644 index febcd539f..000000000 --- a/server-rs/crates/platform-image/src/vector_engine/tests.rs +++ /dev/null @@ -1,221 +0,0 @@ -#[cfg(test)] -mod tests { - use super::*; - use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; - use serde_json::json; - - #[test] - fn request_body_normalizes_size_prompt_and_candidate_count() { - let body = build_vector_engine_image_request_body( - " 风雨夜里的街道 ", - Some(" 低清,水印 "), - " 1:1 ", - 10, - &["data:image/png;base64,AAAA".to_string()], - ); - - assert_eq!(body["model"], GPT_IMAGE_2_MODEL); - assert_eq!(body["size"], "1024x1024"); - assert_eq!(body["n"], 4); - assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印"); - assert!(body.get("image").is_none()); - } - - #[test] - fn provider_urls_normalize_root_and_v1_base_urls() { - let root_settings = VectorEngineImageSettings { - base_url: "https://vector.example".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000, - request_deadline: None, - }; - let v1_settings = VectorEngineImageSettings { - base_url: "https://vector.example/v1".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000, - request_deadline: None, - }; - - assert_eq!( - vector_engine_images_generation_url(&root_settings), - "https://vector.example/v1/images/generations" - ); - assert_eq!( - vector_engine_images_generation_url(&v1_settings), - "https://vector.example/v1/images/generations" - ); - assert_eq!( - vector_engine_images_edit_url(&root_settings), - "https://vector.example/v1/images/edits" - ); - assert_eq!( - vector_engine_images_edit_url(&v1_settings), - "https://vector.example/v1/images/edits" - ); - } - - #[test] - fn data_url_and_base64_image_decoding_preserves_image_metadata() { - let data_url = format!( - "data:image/png;base64,{}", - BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") - ); - - let reference = parse_reference_image_data_url(&data_url, 2) - .expect("data url should parse") - .expect("image data url should be accepted"); - assert_eq!(reference.file_name, "reference-2.png"); - assert_eq!(reference.mime_type, "image/png"); - assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest"); - - let image = decode_generated_image_base64( - BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str(), - ) - .expect("base64 image should decode"); - assert_eq!(image.extension, "png"); - assert_eq!(image.mime_type, "image/png"); - assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest"); - } - - #[test] - fn error_status_hints_and_audit_fields_are_structured() { - let audit = PlatformImageFailureAudit { - provider: VECTOR_ENGINE_PROVIDER, - endpoint: "https://vector.example/v1/images/generations".to_string(), - operation: "图片生成失败".to_string(), - failure_stage: "upstream_status", - status_code: Some(504), - status_class: Some("5xx"), - timeout: true, - retryable: true, - error_message: "上游超时".to_string(), - error_source: Some("read timeout".to_string()), - raw_excerpt: Some("{\"error\":\"timeout\"}".to_string()), - latency_ms: Some(987), - prompt_chars: Some(64), - reference_image_count: Some(2), - image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), - }; - - let request_error = PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, - message: "请求发送失败".to_string(), - endpoint: Some("https://vector.example/v1/images/generations".to_string()), - timeout: true, - connect: false, - request: true, - body: false, - status_code: None, - source: None, - audit: None, - }; - let invalid_config = PlatformImageError::InvalidConfig { - provider: VECTOR_ENGINE_PROVIDER, - message: "缺少配置".to_string(), - }; - let invalid_request = PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: "请求不合法".to_string(), - }; - let upstream_timeout = PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, - message: "upstream timeout".to_string(), - upstream_status: 502, - raw_excerpt: "deadline has elapsed".to_string(), - audit: Some(audit.clone()), - }; - - assert_eq!( - invalid_config.status_hint(), - PlatformImageStatusHint::ServiceUnavailable - ); - assert_eq!( - invalid_request.status_hint(), - PlatformImageStatusHint::BadRequest - ); - assert_eq!( - request_error.status_hint(), - PlatformImageStatusHint::GatewayTimeout - ); - assert_eq!( - upstream_timeout.status_hint(), - PlatformImageStatusHint::GatewayTimeout - ); - assert_eq!( - PlatformImageError::MissingImage { - provider: VECTOR_ENGINE_PROVIDER, - message: "缺图".to_string(), - audit: Some(audit.clone()), - } - .status_hint(), - PlatformImageStatusHint::BadGateway - ); - - let audit_ref = upstream_timeout.audit().expect("audit should be preserved"); - assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER); - assert_eq!( - audit_ref.endpoint, - "https://vector.example/v1/images/generations" - ); - assert_eq!(audit_ref.status_code, Some(504)); - assert_eq!(audit_ref.status_class, Some("5xx")); - assert!(audit_ref.timeout); - assert!(audit_ref.retryable); - assert_eq!(audit_ref.reference_image_count, Some(2)); - assert_eq!(audit_ref.image_model, Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL)); - assert!(invalid_config.audit().is_none()); - assert!(invalid_request.audit().is_none()); - } - - #[test] - fn extract_image_urls_and_b64_values_are_deduped() { - let payload = json!({ - "data": [ - {"image": "https://example.com/a.png"}, - {"url": "https://example.com/a.png"}, - {"image_url": "ftp://example.com/b.png"}, - {"url": "https://example.com/b.png"} - ], - "nested": { - "b64_json": ["YWJj", "ZGVm"], - "parts": [ - { - "inlineData": { - "mimeType": "image/png", - "data": "aW1hZ2UtMQ==" - } - }, - { - "inline_data": { - "mime_type": "image/jpeg", - "data": "aW1hZ2UtMg==" - } - }, - { - "inlineData": { - "mimeType": "text/plain", - "data": "bm90LWltYWdl" - } - } - ] - } - }); - - assert_eq!( - extract_image_urls(&payload), - vec![ - "https://example.com/a.png".to_string(), - "https://example.com/b.png".to_string() - ] - ); - assert_eq!( - extract_b64_images(&payload), - vec![ - "YWJj".to_string(), - "ZGVm".to_string(), - "aW1hZ2UtMQ==".to_string(), - "aW1hZ2UtMg==".to_string(), - ] - ); - } -} diff --git a/server-rs/crates/platform-image/src/vector_engine/transport.rs b/server-rs/crates/platform-image/src/vector_engine/transport.rs deleted file mode 100644 index 6a63878bd..000000000 --- a/server-rs/crates/platform-image/src/vector_engine/transport.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::time::Duration; - -use super::{ - constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, types::VectorEngineImageSettings, -}; - -pub fn build_vector_engine_image_http_client( - settings: &VectorEngineImageSettings, -) -> Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) - .http1_only() - .pool_max_idle_per_host(0) - .build() - .map_err(|error| PlatformImageError::InvalidConfig { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"), - }) -} diff --git a/server-rs/crates/platform-image/src/vector_engine/types.rs b/server-rs/crates/platform-image/src/vector_engine/types.rs deleted file mode 100644 index 77fbd19f9..000000000 --- a/server-rs/crates/platform-image/src/vector_engine/types.rs +++ /dev/null @@ -1,31 +0,0 @@ -use super::audit::PlatformImageFailureAudit; - -#[derive(Clone, Debug)] -pub struct VectorEngineImageSettings { - pub base_url: String, - pub api_key: String, - pub request_timeout_ms: u64, - pub request_deadline: Option, -} - -#[derive(Clone, Debug)] -pub struct GeneratedImages { - pub task_id: String, - pub actual_prompt: Option, - pub images: Vec, - pub recovered_failure_audits: Vec, -} - -#[derive(Clone, Debug)] -pub struct DownloadedImage { - pub bytes: Vec, - pub mime_type: String, - pub extension: String, -} - -#[derive(Clone, Debug)] -pub struct ReferenceImage { - pub bytes: Vec, - pub mime_type: String, - pub file_name: String, -} diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/image_provider.rs similarity index 66% rename from server-rs/crates/platform-image/tests/vector_engine.rs rename to server-rs/crates/platform-image/tests/image_provider.rs index f1bd4470b..3264b81ef 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/image_provider.rs @@ -1,11 +1,11 @@ -use platform_image::vector_engine::{ - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, PlatformImageError, ReferenceImage, - VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, - build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model, - build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit, - create_vector_engine_image_generation, create_vector_engine_nanobanana_generate_content, - vector_engine_images_edit_url, vector_engine_images_generation_url, - vector_engine_nanobanana_generate_content_url, +use platform_image::image_provider::{ + GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, + GPT_IMAGE_2_MODEL, ImageProvider, ImageProviderSettings, PlatformImageError, ReferenceImage, + VECTOR_ENGINE_PROVIDER, build_image_http_client, build_image_request_body, + build_image_request_body_with_model, build_nanobanana_generate_content_request_body, + create_image_edit, create_image_generation, create_nanobanana_generate_content, + images_edit_url, images_generation_url, nanobanana_generate_content_url, + resolve_image_provider, }; use std::{ sync::{ @@ -21,39 +21,38 @@ use tokio::{ }; #[test] -fn vector_engine_module_exposes_provider_protocol_helpers() { - let settings = VectorEngineImageSettings { +fn image_provider_module_exposes_provider_protocol_helpers() { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, request_deadline: None, }; - let body = - build_vector_engine_image_request_body("雾海神殿", Some("文字,水印"), "16:9", 9, &[]); + let body = build_image_request_body("雾海神殿", Some("文字,水印"), "16:9", 9, &[]); assert_eq!(GPT_IMAGE_2_MODEL, "gpt-image-2"); - assert_eq!(GPT_IMAGE_2_C_MODEL, "gpt-image-2-c"); assert_eq!(VECTOR_ENGINE_PROVIDER, "vector-engine"); - assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); assert_eq!(body["size"], "1536x1024"); assert_eq!(body["n"], 4); assert_eq!(body["prompt"], "雾海神殿\n避免:文字,水印"); assert_eq!( - vector_engine_images_generation_url(&settings), + images_generation_url(&settings), "https://vector.example/v1/images/generations" ); assert_eq!( - vector_engine_images_edit_url(&settings), + images_edit_url(&settings), "https://vector.example/v1/images/edits" ); } #[test] -fn vector_engine_clamps_gpt_image_2_explicit_pixel_sizes_to_its_supported_pixel_budget() { - let cover = build_vector_engine_image_request_body("宣发首图", None, "720x540", 1, &[]); - let detail = build_vector_engine_image_request_body("详情单图", None, "720x1280", 1, &[]); - let poster = build_vector_engine_image_request_body("运营海报", None, "1280x720", 1, &[]); +fn image_provider_clamps_gpt_image_2_explicit_pixel_sizes_to_its_supported_pixel_budget() { + let cover = build_image_request_body("宣发首图", None, "720x540", 1, &[]); + let detail = build_image_request_body("详情单图", None, "720x1280", 1, &[]); + let poster = build_image_request_body("运营海报", None, "1280x720", 1, &[]); assert_eq!(cover["size"], "944x704"); assert_eq!(detail["size"], "720x1280"); @@ -61,17 +60,26 @@ fn vector_engine_clamps_gpt_image_2_explicit_pixel_sizes_to_its_supported_pixel_ } #[test] -fn vector_engine_normalizes_2k_landscape_spec_size() { - let body = build_vector_engine_image_request_body("生成规范图", None, "2048x1152", 1, &[]); +fn image_provider_blank_model_falls_back_to_default_generation_model() { + let blank = build_image_request_body_with_model(" ", "空模型", None, "1024x1024", 1, &[]); + let default = build_image_request_body("空模型", None, "1024x1024", 1, &[]); - assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(blank["model"], GPT_IMAGE_2_5_GENERATION_MODEL); + assert_eq!(blank["model"], default["model"]); +} + +#[test] +fn image_provider_normalizes_2k_landscape_spec_size() { + let body = build_image_request_body("生成规范图", None, "2048x1152", 1, &[]); + + assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL); assert_eq!(body["size"], "2048x1152"); assert_eq!(body["n"], 1); } #[test] -fn vector_engine_request_body_can_use_nanobanana2_model() { - let body = build_vector_engine_image_request_body_with_model( +fn image_provider_request_body_can_use_nanobanana2_model() { + let body = build_image_request_body_with_model( "gemini-3.1-flash-image-preview", "生成图标 spritesheet", None, @@ -86,16 +94,10 @@ fn vector_engine_request_body_can_use_nanobanana2_model() { } #[test] -fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { - let gpt_body = build_vector_engine_image_request_body_with_model( - GPT_IMAGE_2_MODEL, - "小尺寸图", - None, - "640x640", - 1, - &[], - ); - let nanobanana_body = build_vector_engine_image_request_body_with_model( +fn image_provider_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { + let gpt_body = + build_image_request_body_with_model(GPT_IMAGE_2_MODEL, "小尺寸图", None, "640x640", 1, &[]); + let nanobanana_body = build_image_request_body_with_model( "gemini-3.1-flash-image-preview", "小尺寸图", None, @@ -103,7 +105,7 @@ fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { 1, &[], ); - let oversized_gpt_body = build_vector_engine_image_request_body_with_model( + let oversized_gpt_body = build_image_request_body_with_model( GPT_IMAGE_2_MODEL, "大尺寸图", None, @@ -111,23 +113,13 @@ fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { 1, &[], ); - let fallback_gpt_body = build_vector_engine_image_request_body_with_model( - GPT_IMAGE_2_C_MODEL, - "小尺寸图", - None, - "640x640", - 1, - &[], - ); - assert_eq!(gpt_body["size"], "816x816"); - assert_eq!(fallback_gpt_body["size"], "816x816"); assert_eq!(nanobanana_body["size"], "640x640"); assert_eq!(oversized_gpt_body["size"], "2880x2880"); } #[test] -fn vector_engine_gpt_image_2_sizes_always_meet_the_full_provider_envelope() { +fn image_provider_gpt_image_2_sizes_always_meet_the_full_provider_envelope() { for size in [ "1x1", "720x540", @@ -137,7 +129,7 @@ fn vector_engine_gpt_image_2_sizes_always_meet_the_full_provider_envelope() { "16x4096", "3840x3840", ] { - let body = build_vector_engine_image_request_body("约束测试", None, size, 1, &[]); + let body = build_image_request_body("约束测试", None, size, 1, &[]); let normalized = body["size"].as_str().expect("size should be a string"); let (width, height) = normalized .split_once('x') @@ -154,8 +146,8 @@ fn vector_engine_gpt_image_2_sizes_always_meet_the_full_provider_envelope() { } #[test] -fn vector_engine_request_body_can_use_nanobanana2_half_k() { - let body = build_vector_engine_image_request_body_with_model( +fn image_provider_request_body_can_use_nanobanana2_half_k() { + let body = build_image_request_body_with_model( "gemini-3.1-flash-image-preview", "生成图标 spritesheet", None, @@ -170,7 +162,7 @@ fn vector_engine_request_body_can_use_nanobanana2_half_k() { #[test] fn nanobanana_generate_content_body_carries_aspect_ratio_and_image_size() { - let body = build_vector_engine_nanobanana_generate_content_request_body( + let body = build_nanobanana_generate_content_request_body( "生成角色图", Some("文字、水印"), "2:3", @@ -195,7 +187,8 @@ fn nanobanana_generate_content_body_carries_aspect_ratio_and_image_size() { #[test] fn nanobanana_generate_content_url_uses_model_path() { - let settings = VectorEngineImageSettings { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000, @@ -203,13 +196,67 @@ fn nanobanana_generate_content_url_uses_model_path() { }; assert_eq!( - vector_engine_nanobanana_generate_content_url(&settings, "gemini-3.1-flash-image-preview"), + nanobanana_generate_content_url(&settings, "gemini-3.1-flash-image-preview"), "https://vector.example/v1beta/models/gemini-3.1-flash-image-preview:generateContent" ); } +#[test] +fn nanobanana_generate_content_url_blank_model_falls_back_to_nanobanana_model() { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, + base_url: "https://vector.example".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + request_deadline: None, + }; + + assert_eq!( + nanobanana_generate_content_url(&settings, " "), + "https://vector.example/v1beta/models/gemini-3.1-flash-image-preview:generateContent" + ); +} + +#[test] +fn resolve_image_provider_rejects_business_model_and_accepts_concrete_models() { + // 中文注释:业务模型名不是 provider model,必须在 api-server 任务边界先解析成具体 model。 + let business_model_error = + resolve_image_provider(GPT_IMAGE_2_5_BUSINESS_NAME).expect_err("业务模型名必须被拒绝"); + assert!( + business_model_error.contains(GPT_IMAGE_2_5_BUSINESS_NAME), + "拒绝原因必须带上被拒绝的模型名,实际为:{business_model_error}" + ); + // 中文注释:已删除的 gpt-image-2-c 不再被任何层接受。 + assert!(resolve_image_provider("gpt-image-2-c").is_err()); + + assert_eq!( + resolve_image_provider(GPT_IMAGE_2_5_GENERATION_MODEL), + Ok(ImageProvider::Tiantoken) + ); + assert_eq!( + resolve_image_provider(GPT_IMAGE_2_5_EDIT_MODEL), + Ok(ImageProvider::Tiantoken) + ); + // 中文注释:`gpt-image-2` 是历史持久化值,仍按 Tiantoken 路由;它不得被当成 + // 未知 model 拒绝,也不得被静默改写成当前业务模型后再发往上游。 + assert_eq!( + resolve_image_provider(GPT_IMAGE_2_MODEL), + Ok(ImageProvider::Tiantoken) + ); + assert_eq!( + resolve_image_provider("gemini-3.1-flash-image-preview"), + Ok(ImageProvider::VectorEngine) + ); + let unknown_model_error = + resolve_image_provider("unknown-model").expect_err("未知模型必须被拒绝"); + assert!( + unknown_model_error.contains("unknown-model"), + "拒绝原因必须带上被拒绝的模型名,实际为:{unknown_model_error}" + ); +} + #[tokio::test] -async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { +async fn image_edit_retries_send_timeout_once_and_succeeds() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("mock server should bind"); @@ -250,41 +297,40 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { } }); - let settings = VectorEngineImageSettings { + let settings = ImageProviderSettings { + provider: ImageProvider::Tiantoken, base_url: format!("http://{server_addr}/v1"), api_key: "test-key".to_string(), request_timeout_ms: 40, request_deadline: None, }; - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let http_client = build_image_http_client(&settings).expect("client should build"); let reference_image = ReferenceImage { bytes: b"reference".to_vec(), mime_type: "image/png".to_string(), file_name: "reference.png".to_string(), }; - let generated = create_vector_engine_image_edit( + let generated = create_image_edit( &http_client, &settings, "测试提示词", None, "1024x1024", &reference_image, - "测试 VectorEngine 图片编辑失败", + "测试 ImageProvider 图片编辑失败", ) .await .expect("second attempt should return generated image"); assert_eq!(generated.images.len(), 1); assert_eq!(generated.images[0].mime_type, "image/png"); - assert!(generated.recovered_failure_audits.is_empty()); assert_eq!(request_count.load(Ordering::SeqCst), 2); let requests = requests.lock().await; assert!( requests .iter() - .all(|request| request.contains("\r\n\r\ngpt-image-2\r\n")) + .all(|request| request.contains(&format!("\r\n\r\n{GPT_IMAGE_2_5_EDIT_MODEL}\r\n"))) ); server.abort(); } @@ -328,7 +374,7 @@ async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Vec { } #[tokio::test] -async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() { +async fn image_provider_deadline_clips_stalled_attempt_and_prevents_retry() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("mock server should bind"); @@ -351,18 +397,18 @@ async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() { } }); - let mut settings = VectorEngineImageSettings { + let mut settings = ImageProviderSettings { + provider: ImageProvider::Tiantoken, base_url: format!("http://{server_addr}/v1"), api_key: "test-key".to_string(), request_timeout_ms: 5_000, request_deadline: None, }; - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let http_client = build_image_http_client(&settings).expect("client should build"); let started_at = Instant::now(); settings.request_deadline = Some(started_at + Duration::from_secs(1)); - let error = create_vector_engine_image_generation( + let error = create_image_generation( &http_client, &settings, "测试提示词", @@ -370,7 +416,7 @@ async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() { "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await .expect_err("stalled request should exhaust the shared deadline"); @@ -406,20 +452,7 @@ async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() { let Ok((mut stream, _)) = listener.accept().await else { return; }; - let mut request = Vec::new(); - let mut buffer = [0_u8; 4096]; - loop { - let Ok(read) = stream.read(&mut buffer).await else { - return; - }; - if read == 0 { - return; - } - request.extend_from_slice(&buffer[..read]); - if request.windows(4).any(|window| window == b"\r\n\r\n") { - break; - } - } + let request = read_http_request(&mut stream).await; let request_text = String::from_utf8_lossy(request.as_slice()); assert!( request_text.contains("/v1beta/models/gemini-3.1-flash-image-preview:generateContent") @@ -435,15 +468,16 @@ async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() { ); let _ = stream.write_all(response.as_bytes()).await; }); - let settings = VectorEngineImageSettings { + let settings = ImageProviderSettings { + provider: ImageProvider::VectorEngine, base_url: format!("http://{}", server_addr), api_key: "test-key".to_string(), request_timeout_ms: 1_000, request_deadline: None, }; - let client = build_vector_engine_image_http_client(&settings).expect("client should build"); + let client = build_image_http_client(&settings).expect("client should build"); - let generated = create_vector_engine_nanobanana_generate_content( + let generated = create_nanobanana_generate_content( &client, &settings, "gemini-3.1-flash-image-preview", @@ -463,7 +497,7 @@ async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() { } #[tokio::test] -async fn vector_engine_image_generation_falls_back_after_upstream_502_and_succeeds() { +async fn image_generation_stays_on_model_after_upstream_502() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("mock server should bind"); @@ -510,16 +544,16 @@ async fn vector_engine_image_generation_falls_back_after_upstream_502_and_succee } }); - let settings = VectorEngineImageSettings { + let settings = ImageProviderSettings { + provider: ImageProvider::Tiantoken, base_url: format!("http://{server_addr}/v1"), api_key: "test-key".to_string(), request_timeout_ms: 1_000, request_deadline: None, }; - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let http_client = build_image_http_client(&settings).expect("client should build"); - let generated = create_vector_engine_image_generation( + let generated = create_image_generation( &http_client, &settings, "测试提示词", @@ -527,38 +561,31 @@ async fn vector_engine_image_generation_falls_back_after_upstream_502_and_succee "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await - .expect("second attempt should return generated image"); + .expect("same-model retry should recover"); assert_eq!(generated.images.len(), 1); - assert_eq!(generated.images[0].mime_type, "image/png"); - assert_eq!(generated.recovered_failure_audits.len(), 1); - assert_eq!( - generated.recovered_failure_audits[0].image_model, - Some(GPT_IMAGE_2_MODEL) - ); assert_eq!(request_count.load(Ordering::SeqCst), 2); let requests = requests.lock().await; - assert!(requests[0].contains("\"model\":\"gpt-image-2\"")); - assert!(requests[1].contains("\"model\":\"gpt-image-2-c\"")); + assert!(requests[0].contains(&format!("\"model\":\"{GPT_IMAGE_2_5_GENERATION_MODEL}\""))); + assert!(requests[1].contains(&format!("\"model\":\"{GPT_IMAGE_2_5_GENERATION_MODEL}\""))); server.abort(); } #[tokio::test] -async fn vector_engine_image_generation_uses_gpt_image_2_without_fallback_on_success() { +async fn image_generation_uses_default_generation_model_on_success() { let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse { status: "200 OK", content_type: "application/json", body: r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#, }]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); - let generated = create_vector_engine_image_generation( + let generated = create_image_generation( &http_client, &settings, "测试提示词", @@ -566,21 +593,20 @@ async fn vector_engine_image_generation_uses_gpt_image_2_without_fallback_on_suc "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await .expect("preferred model should generate image"); assert_eq!(generated.images.len(), 1); - assert!(generated.recovered_failure_audits.is_empty()); let requests = requests.lock().await; assert_eq!(requests.len(), 1); - assert!(requests[0].contains("\"model\":\"gpt-image-2\"")); + assert!(requests[0].contains(&format!("\"model\":\"{GPT_IMAGE_2_5_GENERATION_MODEL}\""))); server.abort(); } #[tokio::test] -async fn vector_engine_image_edit_falls_back_when_preferred_model_is_unsupported() { +async fn image_edit_does_not_cross_model_when_unsupported() { let (base_url, server, requests) = start_http_response_sequence(vec![ MockResponse { status: "400 Bad Request", @@ -594,53 +620,77 @@ async fn vector_engine_image_edit_falls_back_when_preferred_model_is_unsupported }, ]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); let reference = ReferenceImage { bytes: b"reference".to_vec(), mime_type: "image/png".to_string(), file_name: "reference.png".to_string(), }; - let generated = create_vector_engine_image_edit( + let error = create_image_edit( &http_client, &settings, "测试提示词", None, "1024x1024", &reference, - "测试 VectorEngine 图片编辑失败", + "测试 ImageProvider 图片编辑失败", ) .await - .expect("fallback model should recover unsupported preferred model"); + .expect_err("unsupported model must remain terminal"); - assert_eq!(generated.images.len(), 1); - assert_eq!(generated.recovered_failure_audits.len(), 1); - assert_eq!( - generated.recovered_failure_audits[0].image_model, - Some(GPT_IMAGE_2_MODEL) - ); + assert!(matches!(error, PlatformImageError::Upstream { .. })); let requests = requests.lock().await; - assert_eq!(requests.len(), 2); - assert!(requests[0].contains("\r\n\r\ngpt-image-2\r\n")); - assert!(requests[1].contains("\r\n\r\ngpt-image-2-c\r\n")); + assert_eq!(requests.len(), 1); + assert!(requests[0].contains(&format!("\r\n\r\n{GPT_IMAGE_2_5_EDIT_MODEL}\r\n"))); server.abort(); } #[tokio::test] -async fn vector_engine_image_generation_does_not_fallback_on_auth_failure() { +async fn image_generation_with_references_uses_the_edit_concrete_model() { + let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse { + status: "200 OK", + content_type: "application/json", + body: r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#, + }]) + .await; + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); + + let generated = create_image_generation( + &http_client, + &settings, + "测试提示词", + None, + "1024x1024", + 1, + &["data:image/png;base64,iVBORw0KGgpyZXN0".to_string()], + "测试 ImageProvider 图片生成失败", + ) + .await + .expect("referenced generation should go through the edit endpoint"); + + assert_eq!(generated.images.len(), 1); + let requests = requests.lock().await; + assert_eq!(requests.len(), 1); + assert!(requests[0].starts_with("POST /v1/images/edits")); + assert!(requests[0].contains(&format!("\r\n\r\n{GPT_IMAGE_2_5_EDIT_MODEL}\r\n"))); + server.abort(); +} + +#[tokio::test] +async fn image_generation_does_not_fallback_on_auth_failure() { let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse { status: "401 Unauthorized", content_type: "application/json", body: r#"{"error":{"message":"invalid api key"}}"#, }]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); - let error = create_vector_engine_image_generation( + let error = create_image_generation( &http_client, &settings, "测试提示词", @@ -648,7 +698,7 @@ async fn vector_engine_image_generation_does_not_fallback_on_auth_failure() { "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await .expect_err("authentication failure should remain terminal"); @@ -666,7 +716,7 @@ async fn vector_engine_image_generation_does_not_fallback_on_auth_failure() { } #[tokio::test] -async fn vector_engine_image_generation_falls_back_after_non_image_base64_response() { +async fn image_generation_does_not_cross_model_after_invalid_response() { let (base_url, server, requests) = start_http_response_sequence(vec![ MockResponse { status: "200 OK", @@ -680,11 +730,10 @@ async fn vector_engine_image_generation_falls_back_after_non_image_base64_respon }, ]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); - let generated = create_vector_engine_image_generation( + let error = create_image_generation( &http_client, &settings, "测试提示词", @@ -692,26 +741,20 @@ async fn vector_engine_image_generation_falls_back_after_non_image_base64_respon "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await - .expect("fallback model should recover invalid preferred response"); + .expect_err("invalid image response must remain terminal"); - assert_eq!(generated.images.len(), 1); - assert_eq!(generated.recovered_failure_audits.len(), 1); - assert_eq!( - generated.recovered_failure_audits[0].failure_stage, - "response_parse" - ); + assert!(matches!(error, PlatformImageError::ResponseParse { .. })); let requests = requests.lock().await; - assert_eq!(requests.len(), 2); - assert!(requests[0].contains("\"model\":\"gpt-image-2\"")); - assert!(requests[1].contains("\"model\":\"gpt-image-2-c\"")); + assert_eq!(requests.len(), 1); + assert!(requests[0].contains(&format!("\"model\":\"{GPT_IMAGE_2_5_GENERATION_MODEL}\""))); server.abort(); } #[tokio::test] -async fn vector_engine_image_generation_preserves_primary_audit_when_fallback_also_fails() { +async fn image_generation_does_not_retry_as_another_model() { let (base_url, server, requests) = start_http_response_sequence(vec![ MockResponse { status: "502 Bad Gateway", @@ -725,11 +768,10 @@ async fn vector_engine_image_generation_preserves_primary_audit_when_fallback_al }, ]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); - let error = create_vector_engine_image_generation( + let error = create_image_generation( &http_client, &settings, "测试提示词", @@ -737,38 +779,37 @@ async fn vector_engine_image_generation_preserves_primary_audit_when_fallback_al "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await - .expect_err("fallback authentication failure should remain terminal"); + .expect_err("upstream failure should remain terminal"); - assert_eq!(error.recovered_failure_audits().len(), 1); - assert_eq!( - error.recovered_failure_audits()[0].image_model, - Some(GPT_IMAGE_2_MODEL) - ); - assert_eq!( - error.audit().and_then(|audit| audit.image_model), - Some(GPT_IMAGE_2_C_MODEL) - ); + assert!(matches!( + error, + PlatformImageError::Upstream { + upstream_status: 401, + .. + } + )); let requests = requests.lock().await; assert_eq!(requests.len(), 2); + assert!(requests[0].contains(&format!("\"model\":\"{GPT_IMAGE_2_5_GENERATION_MODEL}\""))); + assert!(requests[1].contains(&format!("\"model\":\"{GPT_IMAGE_2_5_GENERATION_MODEL}\""))); server.abort(); } #[tokio::test] -async fn vector_engine_image_generation_does_not_fallback_on_safety_parse_failure() { +async fn image_generation_does_not_fallback_on_safety_parse_failure() { let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse { status: "200 OK", content_type: "application/json", body: "safety refusal: 内容审核拒绝", }]) .await; - let settings = test_vector_engine_settings(base_url); - let http_client = - build_vector_engine_image_http_client(&settings).expect("client should build"); + let settings = test_image_provider_settings(base_url); + let http_client = build_image_http_client(&settings).expect("client should build"); - let error = create_vector_engine_image_generation( + let error = create_image_generation( &http_client, &settings, "测试提示词", @@ -776,7 +817,7 @@ async fn vector_engine_image_generation_does_not_fallback_on_safety_parse_failur "1024x1024", 1, &[], - "测试 VectorEngine 图片生成失败", + "测试 ImageProvider 图片生成失败", ) .await .expect_err("content rejection should not switch models"); @@ -828,8 +869,9 @@ async fn start_http_response_sequence( (format!("http://{server_addr}/v1"), server, requests) } -fn test_vector_engine_settings(base_url: String) -> VectorEngineImageSettings { - VectorEngineImageSettings { +fn test_image_provider_settings(base_url: String) -> ImageProviderSettings { + ImageProviderSettings { + provider: ImageProvider::Tiantoken, base_url, api_key: "test-key".to_string(), request_timeout_ms: 1_000, diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx index 7cd021358..60e5d8e6c 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx @@ -232,10 +232,10 @@ describe('ImageCanvasBasicGenerationComposerView', () => { ); const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' }); fireEvent.click( - within(modelPanel).getByRole('button', { name: /gpt-image-2/ }), + within(modelPanel).getByRole('button', { name: /GPT Image 2\.5/ }), ); - expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2'); + expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2.5'); const submitButton = within(panel).getByRole('button', { name: '生成' }); expect(submitButton.textContent).toBe('生成5泥点'); }); @@ -284,7 +284,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => { ).toBeTruthy(); expect( within(panel).getByRole('button', { - name: '快速编辑图片模型 gpt-image-2', + name: '快速编辑图片模型 GPT Image 2.5', }), ).toBeTruthy(); expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx index 05a538ab8..200498fa9 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx @@ -14,7 +14,7 @@ import type { SpecGenerationType, UploadTarget, } from './ImageCanvasEditorTypes'; -import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel'; +import { IMAGE_MODEL_GPT_IMAGE_2_5 } from './ImageCanvasGenerationModel'; function createDialog( patch: Partial = {}, @@ -165,9 +165,9 @@ describe('ImageCanvasCharacterGenerationComposerView', () => { fireEvent.click( screen.getByRole('button', { name: '生成图片模型 nanobanana2' }), ); - fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' })); + fireEvent.click(screen.getByRole('button', { name: 'GPT Image 2.5' })); - expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); + expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2_5); expect(screen.getByRole('button', { name: '生成' }).textContent).toBe( '生成3泥点', ); diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx index 83e7bcb00..b2b48f37f 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx @@ -3,7 +3,10 @@ import { type Dispatch, type SetStateAction } from 'react'; import { UnifiedModal } from '../common/UnifiedModal'; import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; import type { GenerateDialogState } from './ImageCanvasEditorTypes'; -import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; +import { + calculateEditorImageGenerationPrice, + IMAGE_MODEL_GPT_IMAGE_2_5, +} from './ImageCanvasGenerationModel'; type ImageCanvasEditGenerationModalViewProps = { dialog: GenerateDialogState | null; @@ -17,7 +20,7 @@ export function ImageCanvasEditGenerationModalView({ onSubmit, }: ImageCanvasEditGenerationModalViewProps) { const isOpen = dialog?.mode === 'edit' && dialog.status !== 'generating'; - const dialogImageModel = dialog?.imageModel ?? 'gpt-image-2'; + const dialogImageModel = dialog?.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2_5; const dialogImageSize = dialog?.imageSize ?? '1K'; const editPrice = calculateEditorImageGenerationPrice({ kind: 'quick-edit', diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index f778bfa5c..7f9aee873 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -1527,7 +1527,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'spec', - model: 'gpt-image-2', + model: 'gpt-image-2.5', size: '2048x1152', aspectRatio: '16:9', imageSize: '2K', @@ -1807,7 +1807,7 @@ describe('ImageCanvasEditorView generation integration', () => { const characterPanel = screen.getByRole('dialog', { name: '生成角色形象', }); - selectGenerationModel(characterPanel, 'gpt-image-2'); + selectGenerationModel(characterPanel, 'GPT Image 2.5'); selectGenerationDimensions(characterPanel, '2:3', '2K'); setPlainTextEditorValue( within(characterPanel).getByLabelText('角色设定'), @@ -1822,7 +1822,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect.objectContaining({ kind: 'character', prompt: '蓝衣剑士', - model: 'gpt-image-2', + model: 'gpt-image-2.5', aspectRatio: '2:3', imageSize: '2K', }), @@ -1838,7 +1838,7 @@ describe('ImageCanvasEditorView generation integration', () => { const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); expect( within(iconPanel).getByRole('button', { - name: '生成图片模型 gpt-image-2', + name: '生成图片模型 GPT Image 2.5', }), ).toBeTruthy(); fireEvent.click( @@ -1872,7 +1872,7 @@ describe('ImageCanvasEditorView generation integration', () => { await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ - model: 'gpt-image-2', + model: 'gpt-image-2.5', aspectRatio: '2:3', imageSize: '2K', }), @@ -2315,7 +2315,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'spec', - model: 'gpt-image-2', + model: 'gpt-image-2.5', size: '2048x1152', aspectRatio: '16:9', imageSize: '2K', @@ -3894,7 +3894,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy(); expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy(); expect(within(metadataDialog).getByText('Model')).toBeTruthy(); - expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy(); + expect(within(metadataDialog).getByText('GPT Image 2.5')).toBeTruthy(); expect(within(metadataDialog).queryByText('Size')).toBeNull(); expect(within(metadataDialog).getByText('Resolution')).toBeTruthy(); expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 92b947c88..8665e391d 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1274,7 +1274,7 @@ describe('ImageCanvasEditorView', () => { fields: [{ title: '生成提示词', value: '明亮主视觉' }], references: [], }, - model: 'gpt-image-2', + model: 'GPT Image 2.5', task: '1', object: 'asset-object-visible', resolution: '1024 x 1024 px', diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 9a1e95e04..6c77f038a 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -187,7 +187,7 @@ describe('ImageCanvasExportModel', () => { }, ], }, - model: 'gpt-image-2', + model: 'GPT Image 2.5', task: '1', object: 'generated/layer.png', resolution: '1024 x 1024 px', @@ -268,7 +268,7 @@ describe('ImageCanvasExportModel', () => { ], references: [], }); - expect(metadata.visible.model).toBe('gpt-image-2'); + expect(metadata.visible.model).toBe('GPT Image 2.5'); }); it('builds a Spine JSON attachment timeline from image sequence frames', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index ceb4edc9e..582b7f9da 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -445,7 +445,7 @@ describe('ImageCanvasGenerationComposerView', () => { ).toBeTruthy(); expect( within(panel).getByRole('button', { - name: '快速编辑图片模型 gpt-image-2', + name: '快速编辑图片模型 GPT Image 2.5', }), ).toBeTruthy(); expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index c1b69ca4e..9787afda4 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -40,6 +40,7 @@ import { } from './ImageCanvasGenerationDialogModel'; import { IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; @@ -187,7 +188,7 @@ describe('ImageCanvasGenerationDialogModel', () => { ).toMatchObject({ mode: 'character', style: 'none', - imageModel: 'gpt-image-2', + imageModel: 'gpt-image-2.5', aspectRatio: '1:1', imageSize: '1K', characterSpecReference: null, @@ -260,7 +261,7 @@ describe('ImageCanvasGenerationDialogModel', () => { }), ).toMatchObject({ mode: 'ui-design', - imageModel: 'gpt-image-2', + imageModel: 'gpt-image-2.5', aspectRatio: '16:9', imageSize: '1K', uiDesignSpecReference: null, @@ -465,7 +466,7 @@ describe('ImageCanvasGenerationDialogModel', () => { status: 'idle', composerOpen: true, sourceLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: 'gpt-image-2.5', aspectRatio: '4:3', imageSize: '1K', }); @@ -475,7 +476,7 @@ describe('ImageCanvasGenerationDialogModel', () => { size: '1024x768', aspectRatio: '4:3', imageSize: '1K', - model: 'gpt-image-2', + model: 'gpt-image-2.5', quickEditReferences: [], status: 'idle', }); @@ -516,7 +517,7 @@ describe('ImageCanvasGenerationDialogModel', () => { expect(createEditDialogDraft(sourceLayer)).toMatchObject({ prompt: '改成雨天', - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', }); @@ -737,7 +738,7 @@ describe('ImageCanvasGenerationDialogModel', () => { mode: 'generate', prompt: '普通图', sourceLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: 'gpt-image-2.5', aspectRatio: '2:3', imageSize: '2K', style: 'none', @@ -778,7 +779,7 @@ describe('ImageCanvasGenerationDialogModel', () => { }), ).toMatchObject({ mode, - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', placeholder: { @@ -815,7 +816,7 @@ describe('ImageCanvasGenerationDialogModel', () => { mode: 'generate', prompt: '元数据提示词', generatedLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: 'gpt-image-2.5', placeholder: { x: 120, y: 140, @@ -1149,7 +1150,7 @@ describe('ImageCanvasGenerationDialogModel', () => { ).toMatchObject({ mode: 'generate', prompt: '', - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', }); @@ -1204,7 +1205,7 @@ describe('ImageCanvasGenerationDialogModel', () => { prompt: '雨夜海边车站', sceneStylePreset: 'custom', sceneCustomStyle: '复古像素风', - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '16:9', imageSize: '2K', generationReferences: [ @@ -1273,7 +1274,7 @@ describe('ImageCanvasGenerationDialogModel', () => { mode: 'scene', prompt: '雨夜海边车站', sceneStylePreset: 'watercolor', - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '16:9', imageSize: '2K', }); @@ -1394,7 +1395,7 @@ describe('ImageCanvasGenerationDialogModel', () => { imageSize: '2K', }), ).toMatchObject({ - model: IMAGE_MODEL_GPT_IMAGE_2, + model: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '16:9', imageSize: '2K', }); diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 6c6a5fb0f..ef6b72ad9 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -43,7 +43,7 @@ import { EDITOR_IMAGE_DIMENSION_OPTIONS, EDITOR_IMAGE_MODEL_OPTIONS, formatGenerationInputValue, - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, inferEditorImageAspectRatio, inferEditorImageSizeLabel, isNormalizedCanvasGenerationInputs, @@ -426,7 +426,7 @@ export function createPublicationGenerationDialogDraft({ publicationWorkflowId: workflowId, publicationGameInfo: { ...DEFAULT_PUBLICATION_GAME_INFO }, publicationReferences: [], - imageModel: IMAGE_MODEL_GPT_IMAGE_2, + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: workflow.aspectRatio, imageSize: workflow.imageSize, placeholder: { @@ -1149,7 +1149,7 @@ function createNormalizedGenerationDialogDraft({ imageModel: getNormalizedString( sourceLayer, 'model', - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, ), }), prompt, @@ -1757,7 +1757,7 @@ export function createSameSourceGenerationDialogDraft({ ...createUiDesignGenerationDialogDraft({ canvasSize, viewport, - imageModel: sourceDialog?.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2, + imageModel: sourceDialog?.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2_5, }), prompt, sourceLayerId: sourceLayer.id, @@ -2000,7 +2000,7 @@ export function createQuickEditGenerationDialogDraft({ assetLabel, status = 'idle', references = [], - model = IMAGE_MODEL_GPT_IMAGE_2, + model = IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio, imageSize, frame, diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx index 72b1ad1e7..0f19fbe58 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx @@ -9,6 +9,7 @@ import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationIm import { calculateEditorImageGenerationPrice, IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; @@ -226,17 +227,19 @@ describe('ImageCanvasGenerationImageOptionsView', () => { expect( within(panel).getByRole('button', { name: 'nanobanana2' }), ).toBeTruthy(); - fireEvent.click(within(panel).getByRole('button', { name: 'gpt-image-2' })); + fireEvent.click( + within(panel).getByRole('button', { name: 'GPT Image 2.5' }), + ); expect(screen.getByRole('menu', { name: '生成图片模型选项' })).toBeTruthy(); - expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); + expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2_5); expect(screen.getByLabelText('当前模型').textContent).toBe( - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, ); expect(screen.getByLabelText('当前比例').textContent).toBe('9:16'); expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K'); expect( - screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }), + screen.getByRole('button', { name: '生成图片模型 GPT Image 2.5' }), ).toBeTruthy(); expect( screen.getByRole('button', { name: '生成角色形象' }).textContent, @@ -261,7 +264,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => { ); const modelButton = screen.getByRole('button', { - name: '生成图片模型 gpt-image-2', + name: '生成图片模型 GPT Image 2.5', }) as HTMLButtonElement; expect(modelButton.disabled).toBe(true); @@ -302,12 +305,12 @@ describe('ImageCanvasGenerationImageOptionsView', () => { fireEvent.click( within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole( 'button', - { name: 'gpt-image-2' }, + { name: 'GPT Image 2.5' }, ), ); expect(alertMock).toHaveBeenCalledWith( - '当前已有 8 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换', + '当前已有 8 张参考图,GPT Image 2.5 最多允许 4 张,请先删除多余参考图后再切换', ); expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8'); expect(screen.getByLabelText('当前模型').textContent).toBe( @@ -338,7 +341,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => { fireEvent.click( within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole( 'button', - { name: 'gpt-image-2' }, + { name: 'GPT Image 2.5' }, ), ); @@ -412,12 +415,12 @@ describe('ImageCanvasGenerationImageOptionsView', () => { fireEvent.click( within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole( 'button', - { name: 'gpt-image-2' }, + { name: 'GPT Image 2.5' }, ), ); expect(screen.getByLabelText('当前模型').textContent).toBe( - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, ); expect(screen.getByLabelText('当前比例').textContent).toBe('4:3'); expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K'); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 5a08c5c05..a8c4632ae 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -48,6 +48,7 @@ import { getGenerationFrameAriaLabel, getGenerationFrameLabel, IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, IMAGE_MODEL_NANOBANANA2, isNormalizedCanvasGenerationInputs, isQuickEditSupportedLayer, @@ -237,7 +238,7 @@ describe('ImageCanvasGenerationModel', () => { applyEditorGenerationPricingConfig({ models: { ...EDITOR_MODEL_MUD_POINT_CONFIG, - [IMAGE_MODEL_GPT_IMAGE_2]: { + [IMAGE_MODEL_GPT_IMAGE_2_5]: { unit: 'perGeneration', prices: { '1K': 29, @@ -690,7 +691,17 @@ describe('ImageCanvasGenerationModel', () => { imageSize: '2K', }), ); - expect(valid).toMatchObject({ ok: true, warnings: [] }); + expect(valid).toMatchObject({ + ok: true, + inputs: { + fields: expect.arrayContaining([ + { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2_5 }, + ]), + }, + warnings: expect.arrayContaining([ + expect.objectContaining({ fieldIds: ['model'] }), + ]), + }); const alias = decodeCanvasGenerationInputs({ version: 2, @@ -932,14 +943,14 @@ describe('ImageCanvasGenerationModel', () => { label: 'nanobanana2', value: 'gemini-3.1-flash-image-preview', }, - { label: 'gpt-image-2', value: 'gpt-image-2' }, + { label: 'GPT Image 2.5', value: 'gpt-image-2.5' }, ]); expect(buildQuickEditModelOptions('nanobanana2')).toEqual([ { label: 'nanobanana2', value: 'gemini-3.1-flash-image-preview', }, - { label: 'gpt-image-2', value: 'gpt-image-2' }, + { label: 'GPT Image 2.5', value: 'gpt-image-2.5' }, ]); expect( isQuickEditSupportedLayer({ diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 4929dcedd..c5d407f2e 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -44,6 +44,7 @@ import { // 中文注释:与 api-server/src/editor_generation_config.rs 保持同名模型定价语义,前端仅作为展示兜底。 export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; +export const IMAGE_MODEL_GPT_IMAGE_2_5 = 'gpt-image-2.5'; export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2; const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([ @@ -51,7 +52,9 @@ const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([ 'nanobanana2', 'nano-banana', ]); -export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; +// 历史 GPT Image 2 别名统一映射到当前业务模型,判定与归一复用同一集合。 +const LEGACY_EDITOR_IMAGE_MODELS = new Set([IMAGE_MODEL_GPT_IMAGE_2]); +export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2_5; export const SPEC_GENERATION_ASPECT_RATIO = '16:9'; export const SPEC_GENERATION_IMAGE_SIZE = '2K'; export const SPEC_GENERATION_SIZE = '2048x1152'; @@ -87,16 +90,22 @@ export const PUBLICATION_FRAME_DISPLAY_SIZE: Record< 'publication-detail-gallery': { width: 360, height: 640 }, 'publication-promo-poster': { width: 560, height: 315 }, }; +// `gpt-image-2` 仍是持久化在历史画布项里的模型值,配置保留为防御性别名; +// 生产读取会先经过 normalizeEditorImageModel 归一,因此 legacy 档位必须与 +// `gpt-image-2.5` 同值,这里直接复用同一份常量避免两处字面量各自漂移。 +const GPT_IMAGE_2_5_TIER_PRICES = { + '1K': 3, + '2K': 5, +} as const; export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = { [IMAGE_MODEL_NANOBANANA2]: { '0.5K': 8, '1K': 12, '2K': 24, }, - [IMAGE_MODEL_GPT_IMAGE_2]: { - '1K': 3, - '2K': 5, - }, + [IMAGE_MODEL_GPT_IMAGE_2_5]: GPT_IMAGE_2_5_TIER_PRICES, + // 历史持久化值;不作为新的可选模型暴露,只保证旧值可读。 + [IMAGE_MODEL_GPT_IMAGE_2]: GPT_IMAGE_2_5_TIER_PRICES, } as const; export const SPEC_GENERATION_COST = EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][ @@ -127,18 +136,21 @@ export const QUICK_EDIT_SIZE_PRESETS = [ ] as const; export const EDITOR_IMAGE_MODEL_OPTIONS = [ { label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 }, - { label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 }, + { label: 'GPT Image 2.5', value: IMAGE_MODEL_GPT_IMAGE_2_5 }, ] as const; export const QUICK_EDIT_MODEL_OPTIONS = EDITOR_IMAGE_MODEL_OPTIONS; +// 同上:legacy `gpt-image-2` 与 `gpt-image-2.5` 共用同一份尺寸档位常量。 +const GPT_IMAGE_2_5_DIMENSION_OPTIONS = { + aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'], + imageSizes: ['1K', '2K'], +} as const; export const EDITOR_IMAGE_DIMENSION_OPTIONS = { [IMAGE_MODEL_NANOBANANA2]: { aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'], imageSizes: ['0.5K', '1K', '2K'], }, - [IMAGE_MODEL_GPT_IMAGE_2]: { - aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'], - imageSizes: ['1K', '2K'], - }, + [IMAGE_MODEL_GPT_IMAGE_2_5]: GPT_IMAGE_2_5_DIMENSION_OPTIONS, + [IMAGE_MODEL_GPT_IMAGE_2]: GPT_IMAGE_2_5_DIMENSION_OPTIONS, } as const; type EditorImageFrameSize = { @@ -381,9 +393,9 @@ export const EDITOR_MODEL_MUD_POINT_CONFIG = { unit: 'perGeneration', prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_NANOBANANA2], }, - [IMAGE_MODEL_GPT_IMAGE_2]: { + [IMAGE_MODEL_GPT_IMAGE_2_5]: { unit: 'perGeneration', - prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2], + prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2_5], }, [VIDEO_MODEL_SEEDANCE_2_FAST]: { unit: 'perSecond', @@ -627,9 +639,16 @@ export function normalizeEditorImageModel(model: string | null | undefined) { if (IMAGE_MODEL_NANOBANANA_ALIASES.has(normalizedModel)) { return IMAGE_MODEL_NANOBANANA2; } + if (LEGACY_EDITOR_IMAGE_MODELS.has(normalizedModel)) { + return IMAGE_MODEL_GPT_IMAGE_2_5; + } return normalizedModel; } +export function isLegacyEditorImageModel(model: string | null | undefined) { + return LEGACY_EDITOR_IMAGE_MODELS.has(model?.trim() ?? ''); +} + export function getEditorImageModelDisplayName( model: string | null | undefined, ) { @@ -1387,6 +1406,7 @@ export function decodeCanvasGenerationInputs( defaultAspectRatio: string; fixedAspectRatios?: readonly string[]; }) => { + const rawModel = fields.find((field) => field.id === 'model')?.value; const imageModels = EDITOR_IMAGE_MODEL_OPTIONS.map( (option) => option.value, ); @@ -1397,6 +1417,9 @@ export function decodeCanvasGenerationInputs( defaultModel, normalizeEditorImageModel, ); + if (typeof rawModel === 'string' && isLegacyEditorImageModel(rawModel)) { + addFallbackWarning(['model']); + } const dimensions = EDITOR_IMAGE_DIMENSION_OPTIONS[ model as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS @@ -1452,7 +1475,7 @@ export function decodeCanvasGenerationInputs( } else if (action === 'ui-design.generate') { normalizeStringField('prompt', '用户输入'); normalizeImageParameters({ - defaultModel: IMAGE_MODEL_GPT_IMAGE_2, + defaultModel: IMAGE_MODEL_GPT_IMAGE_2_5, defaultAspectRatio: '16:9', }); } else if (action === 'image.edit') { @@ -1993,7 +2016,7 @@ export function buildUiDesignGenerationInputs( ...createGenerationInputField('用户输入', prompt, 'prompt'), ...createGenerationInputField( '模型', - options.model ?? IMAGE_MODEL_GPT_IMAGE_2, + options.model ?? IMAGE_MODEL_GPT_IMAGE_2_5, 'model', ), ...createGenerationInputField( diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index d6e9d77f9..b8cedeb5c 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -4,6 +4,7 @@ import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/e import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; import { @@ -333,14 +334,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => { expect(plan).toMatchObject({ kind: 'edit', editInput: { - model: IMAGE_MODEL_GPT_IMAGE_2, + model: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', size: '1365x2048', }, generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ - { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 }, + { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2_5 }, { id: 'aspectRatio', title: '比例', value: '2:3' }, { id: 'imageSize', title: '尺寸', value: '2K' }, ]), @@ -384,7 +385,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { sourceLayer, editInput: { size: '1024x768', - model: IMAGE_MODEL_GPT_IMAGE_2, + model: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '4:3', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,style'], @@ -420,7 +421,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ], }), }, - rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, + rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2_5, }); }); @@ -650,7 +651,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { input: { prompt: '白发骑士', kind: 'character', - model: 'gpt-image-2', + model: 'gpt-image-2.5', screenColor: 'auto', segModel: 'birefnet', style: 'pixelArt', @@ -665,7 +666,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { assetKind: 'character', title: '角色形象 9', }, - rememberImageModel: 'gpt-image-2', + rememberImageModel: 'gpt-image-2.5', }); expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( expect.objectContaining({ @@ -727,7 +728,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { normalizedPrompt: '设计移动端商城首页', input: { kind: 'ui-design', - model: IMAGE_MODEL_GPT_IMAGE_2, + model: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '16:9', imageSize: '1K', referenceImageSrcs: [ @@ -762,7 +763,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ], }), }, - rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, + rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2_5, }); }); @@ -802,7 +803,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { prompt: expect.stringContaining('【宣发素材类型】游戏首图'), size: '720x540', kind: 'publication-material', - model: 'gpt-image-2', + model: 'gpt-image-2.5', aspectRatio: '4:3', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,ref'], @@ -950,7 +951,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { expect(plan.input).toEqual( expect.objectContaining({ kind: 'publication-material', - model: 'gpt-image-2', + model: 'gpt-image-2.5', size: '1280x720', aspectRatio: '16:9', imageSize: '1K', @@ -1081,7 +1082,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { referenceImageSrcs: ['data:image/png;base64,ref'], iconDescriptions: ['返回按钮\n\n设置按钮'], sliceMode: 'connected-components', - model: 'gpt-image-2', + model: 'gpt-image-2.5', screenColor: 'auto', segModel: 'birefnet', style: 'pixelArt', @@ -1111,7 +1112,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }, ], }), - rememberImageModel: 'gpt-image-2', + rememberImageModel: 'gpt-image-2.5', resultTitle: '冒险游戏图标', }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index b11548253..fde880e4f 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -51,7 +51,7 @@ import { DEFAULT_VIDEO_WEB_SEARCH_ENABLED, EDITOR_IMAGE_DIMENSION_OPTIONS, EDITOR_IMAGE_MODEL_OPTIONS, - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, inferEditorImageAspectRatio, inferEditorImageSizeLabel, isQuickEditSupportedLayer, @@ -584,17 +584,17 @@ export function buildImageGenerationSubmissionPlan({ const basePrompt = dialog.prompt.trim() || '快速编辑图片'; const normalizedQuickEditPrompt = basePrompt; const requestedImageModel = normalizeEditorImageModel( - dialog.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2, + dialog.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2_5, ); const imageModel = EDITOR_IMAGE_MODEL_OPTIONS.some( (option) => option.value === requestedImageModel, ) ? requestedImageModel - : IMAGE_MODEL_GPT_IMAGE_2; + : IMAGE_MODEL_GPT_IMAGE_2_5; const dimensionOptions = EDITOR_IMAGE_DIMENSION_OPTIONS[ imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS - ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_GPT_IMAGE_2]; + ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_GPT_IMAGE_2_5]; const supportedAspectRatios = dimensionOptions.aspectRatios as readonly string[]; const supportedImageSizes = @@ -852,7 +852,7 @@ export function buildImageGenerationSubmissionPlan({ } if (dialog.mode === 'ui-design') { - const imageModel = IMAGE_MODEL_GPT_IMAGE_2; + const imageModel = IMAGE_MODEL_GPT_IMAGE_2_5; const referenceImageSrcs = [ dialog.uiDesignSpecReference ? resolveImageReferenceSubmissionSource(dialog.uiDesignSpecReference) @@ -897,7 +897,7 @@ export function buildImageGenerationSubmissionPlan({ const workflow = getPublicationMaterialsWorkflow( dialog.publicationWorkflowId ?? 'publication-cover-image', ); - const imageModel = IMAGE_MODEL_GPT_IMAGE_2; + const imageModel = IMAGE_MODEL_GPT_IMAGE_2_5; const publicationPrompt = buildPublicationMaterialsPrompt(dialog.publicationGameInfo) || normalizedPrompt; diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx index f1c4d4bfc..c44630bfe 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx @@ -249,11 +249,11 @@ describe('ImageCanvasIconSpritesheetComposerView', () => { ); const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' }); fireEvent.click( - within(modelPanel).getByRole('button', { name: 'gpt-image-2' }), + within(modelPanel).getByRole('button', { name: 'GPT Image 2.5' }), ); - expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2'); - expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2'); + expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2.5'); + expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2.5'); expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K'); expect(screen.getByRole('button', { name: '生成' }).textContent).toBe( '生成3泥点', diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index c108b87d8..95cba2b9d 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -81,7 +81,7 @@ describe('ImageCanvasMetadataModalView', () => { within(dialog).getByText('项目资源 · resource-reference'), ).toBeTruthy(); expect(within(dialog).getByText('Model')).toBeTruthy(); - expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); + expect(within(dialog).getByText('GPT Image 2.5')).toBeTruthy(); expect(within(dialog).getByText('1024 x 768 px')).toBeTruthy(); expect(within(dialog).queryByText('Provider')).toBeNull(); expect(within(dialog).queryByText(/VectorEngine/u)).toBeNull(); @@ -129,7 +129,7 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('角色抠图')).toBeTruthy(); expect(within(dialog).queryByText('处理模型')).toBeNull(); expect(within(dialog).getByText('Model')).toBeTruthy(); - expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); + expect(within(dialog).getByText('GPT Image 2.5')).toBeTruthy(); expect(within(dialog).queryByText('birefnet')).toBeNull(); expect(within(dialog).queryByText('Aliyun Matting')).toBeNull(); expect(within(dialog).queryByText('segment-common-image')).toBeNull(); diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx index d6f5f47ad..aabefce7e 100644 --- a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx @@ -49,14 +49,14 @@ function PublicationPanelHarness({ } describe('ImageCanvasPublicationMaterialsDemoPanelView', () => { - it('locks publication material model selection to gpt-image-2', () => { + it('locks publication material model selection to GPT Image 2.5', () => { const rememberImageModel = vi.fn(); render( , ); const modelButton = screen.getByRole('button', { - name: '宣发素材模型 gpt-image-2', + name: '宣发素材模型 GPT Image 2.5', }) as HTMLButtonElement; const description = screen.getByRole('textbox', { name: '游戏首图一句话描述游戏', diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx index 74c491470..e9ae251f3 100644 --- a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx @@ -26,7 +26,7 @@ import { buildPublicationMaterialsPrompt, calculateEditorImageModelPrice, DEFAULT_PUBLICATION_GAME_INFO, - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, } from './ImageCanvasGenerationModel'; import type { PublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; @@ -299,10 +299,10 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({ includeDimensions={false} onRememberImageModel={onRememberImageModel} hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} - lockedModel={IMAGE_MODEL_GPT_IMAGE_2} + lockedModel={IMAGE_MODEL_GPT_IMAGE_2_5} optionLabelPrefix="宣发素材" cost={calculateEditorImageModelPrice( - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, workflow.imageSize, )} submitLabel="生成" diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx index 98f8e90a1..106c6230f 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx @@ -107,9 +107,9 @@ describe('ImageCanvasQuickEditPanelView', () => { screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), ); expect(screen.getByRole('button', { name: 'nanobanana2' })).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' })); - expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2'); - expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2'); + fireEvent.click(screen.getByRole('button', { name: 'GPT Image 2.5' })); + expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2.5'); + expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2.5'); }); it('renders the failure message inside the quick edit alert', () => { diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index 9a4121c39..b4839b7c4 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -197,7 +197,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { name: '生成图片尺寸 16:9·2K', }) as HTMLButtonElement; const modelButton = screen.getByRole('button', { - name: '生成图片模型 gpt-image-2', + name: '生成图片模型 GPT Image 2.5', }) as HTMLButtonElement; expect(dimensionsButton.disabled).toBe(true); @@ -256,7 +256,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ); const submitButton = screen.getByRole('button', { name: '生成UI设计图' }); const modelButton = screen.getByRole('button', { - name: '生成图片模型 gpt-image-2', + name: '生成图片模型 GPT Image 2.5', }) as HTMLButtonElement; expect(submitButton.textContent).toBe('生成3泥点'); diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index 877fea08a..160910e94 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -51,7 +51,7 @@ import { calculateEditorUiDesignPrice, CHARACTER_SPEC_VIEW_OPTIONS, getEditorImageModelDisplayName, - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, resolveEditorImageSizeLabel, SPEC_GENERATION_ASPECT_RATIO, SPEC_GENERATION_IMAGE_SIZE, @@ -758,9 +758,9 @@ export function ImageCanvasSpecGenerationPanelView({ includeDimensions onRememberImageModel={onRememberImageModel} hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} - lockedModel={IMAGE_MODEL_GPT_IMAGE_2} + lockedModel={IMAGE_MODEL_GPT_IMAGE_2_5} cost={calculateEditorUiDesignPrice( - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, dialog.imageSize, )} submitLabel="生成" diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx index 6220aacaa..bdc8f89f4 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { - IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel'; @@ -229,11 +229,11 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { fireEvent.click( within(screen.getByRole('menu', { name: '提取素材模型选项' })).getByRole( 'button', - { name: 'gpt-image-2' }, + { name: 'GPT Image 2.5' }, ), ); - expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); + expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2_5); }); it('disables extraction submission while reference uploads are pending', () => { @@ -298,12 +298,12 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { fireEvent.click( within(screen.getByRole('menu', { name: '提取素材模型选项' })).getByRole( 'button', - { name: 'gpt-image-2' }, + { name: 'GPT Image 2.5' }, ), ); expect(alertMock).toHaveBeenCalledWith( - '当前已有 5 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换', + '当前已有 5 张参考图,GPT Image 2.5 最多允许 4 张,请先删除多余参考图后再切换', ); expect(onModelChange).not.toHaveBeenCalled(); expect(screen.getByRole('menu', { name: '提取素材模型选项' })).toBeTruthy(); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index e4e4a1159..44ee6c9c9 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1178,7 +1178,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { prompt: '把当前图改成雨天', sourceReferenceId: 'resource-source', size: '1024x768', - model: 'gpt-image-2', + model: 'gpt-image-2.5', targetLayerId: 'layer-source', assetLabel: '源图 快速编辑', }), @@ -1461,7 +1461,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { referenceImageSrcs: [ 'generated-character-drafts/editor/generation-references/reference.png', ], - model: 'gpt-image-2', + model: 'gpt-image-2.5', targetLayerId: 'layer-source', }), ); @@ -3803,7 +3803,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { size: '720x1280', kind: 'publication-material', assetKind: 'publication-material', - model: 'gpt-image-2', + model: 'gpt-image-2.5', aspectRatio: '9:16', imageSize: '1K', referenceImageSrcs: [ @@ -4102,7 +4102,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect.objectContaining({ referenceId: 'resource-icon-spec', iconDescriptions: ['返回按钮\n设置按钮'], - model: 'gpt-image-2', + model: 'gpt-image-2.5', assetLabel: '复古操作图标', canvasCompletion: expect.objectContaining({ title: '复古操作图标', @@ -4125,7 +4125,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { 'icon:idle:open:layer-icon-spritesheet-1:placeholder:-', ); expect(screen.getByTestId('remembered-model').textContent).toBe( - 'gpt-image-2', + 'gpt-image-2.5', ); expect(screen.getByTestId('generation-warning').textContent).toBe( '图集已生成,但自动拆分未完成:连通域数量不足', diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 0bbdb6dcb..798c96edc 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -31,6 +31,7 @@ import { CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, DEFAULT_IMAGE_MODEL, IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_GPT_IMAGE_2_5, } from './ImageCanvasGenerationModel'; import { readPerfectPixelOperations, @@ -622,7 +623,7 @@ function GenerationWorkflowHarness({ status: 'idle', composerOpen: true, sourceLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', }) @@ -780,7 +781,7 @@ function GenerationWorkflowHarness({ status: 'idle', composerOpen: true, generatedLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '2:3', imageSize: '2K', }) @@ -797,7 +798,7 @@ function GenerationWorkflowHarness({ status: 'generating', composerOpen: false, sourceLayerId: 'layer-source', - imageModel: 'gpt-image-2', + imageModel: IMAGE_MODEL_GPT_IMAGE_2_5, aspectRatio: '1:1', imageSize: '1K', }) @@ -1241,7 +1242,7 @@ function GenerationWorkflowHarness({