fix(ai): pin declared image working quality

This commit is contained in:
WuYongJie 2026-10-15 11:51:33 +08:00
parent cfa8f0ff9b
commit eb4836ca81
3 changed files with 56 additions and 3 deletions

View File

@ -0,0 +1,20 @@
# 生图默认画质与 VIP 复验(2026-10-15)
素材库通过 98k 的公开接口调用模型。本次仅调整素材库,不修改外部服务的线路、账号或价格。
## 调整
对于 Images Generations,公开目录明确列出 `medium`,且没有冲突的固定值或默认值时,素材库显式发送 `quality=medium`。目录不声明该档位、声明其他默认值或其他固定值时不添加。未按模型名称编写分支;参考图、型号及已绑定路由保持。
画质保存在协议元数据中,并参与验证上下文指纹。同步到不同的调用参数后,旧的通过记录不能直接开放模型。已有元数据没有该参数时仍沿用原调用,目录同步不会自动开放模型或更改默认模型。
这是一项调用参数明确化。以下单次成功不能证明此前 VIP 失败的内部根因,也不能保证外部服务长期稳定;不将它描述为已修复 98k 的供应线路。
## 验证
- 独立 QA 使用真实 98k Key,通过素材库原生超级管理员测试接口执行一次 VIP 参考图请求;77 秒返回 1254×1254 图片,可解码,随机色块的颜色及位置校验通过。请求编号:`5195362b-66ae-48c3-9cab-807e663b9cc0`。
- 开放 VIP 后出现在详情页候选项,关闭后移除;原有默认值和其他模型配置保持。两次重复提交复用原测试结果,没有新增模型调用或验证记录。
- 三个尚未通过的 2.5 型号仍不能开放;本次未将失败或结果未知的型号改为通过。
- 6 组相关 Jest、168 项测试通过;独立发布预览的 TypeScript 检查和 Next.js 生产构建通过。发布状态另以执行记录为准。
无新依赖、数据库迁移、界面布局变更或自动重试。证据位于 Git 忽略的 `output/ai-98k-recovery-20261015/`。

View File

@ -47,6 +47,30 @@ test('Images Generations sends declared image array with private bytes and retai
expect((await sharp(result.image).metadata()).width).toBe(32)
})
test.each([
[{ enum: ['auto', 'low', 'medium', 'high'] }, 'medium'],
[{ enum: ['medium', 'high'], default: 'medium' }, 'medium'],
[{ enum: ['low', 'high'] }, undefined],
[{ type: 'string' }, undefined],
[{ enum: ['medium', 'high'], default: 'high' }, undefined],
[{ enum: ['medium', 'high'], const: 'high' }, undefined],
])('only chooses an explicitly supported image working quality: %j', (quality, expected) => {
const entry = { type: 'image', modality: 'image', capabilities: ['image_to_image'], input_modes: ['text', 'image_url'], invocation: { method: 'POST', path: '/v1/images/generations', content_type: 'application/json', headers: {}, input_schema: { properties: { quality, image: { type: 'array', items: { type: 'string', format: 'uri' } } } } } }
expect(parseAiModelProtocols(entry)[0].imageQuality).toBe(expected)
})
test('working quality survives storage and reaches the selected image route without changing references', async () => {
const protocol: AiModelProtocol = { id: 'openai-image-generate', path: '/v1/images/generations', headers: { 'X-Relay-Route-Option': 'same-route' }, routeId: 'same-route', supportsImageInput: true, inputField: 'image', imageQuality: 'medium' }
expect(validateStoredAiModelProtocols(JSON.parse(JSON.stringify([protocol])))).toEqual([protocol])
send.mockResolvedValue(Response.json({ data: [{ b64_json: png.toString('base64') }] }))
await callAiModelProtocol({ ...base, protocol, images: [{ buffer: png, mimeType: 'image/png' }] })
expect(send).toHaveBeenCalledTimes(1)
expect(JSON.parse(send.mock.calls[0][3])).toMatchObject({ model: base.modelId, quality: 'medium', image: [expect.stringMatching(/^data:image\/png;base64,/)], n: 1 })
expect(send.mock.calls[0][5]).toEqual(protocol.headers)
expect(validateStoredAiModelProtocols([{ ...protocol, imageQuality: 'high' }])).toEqual([])
expect(validateStoredAiModelProtocols([{ ...chat, imageQuality: 'medium' }])).toEqual([])
})
test.each(['audio', 'video'])('does not turn %s models into chat or detail generation adapters', modality => {
expect(parseAiModelProtocols({ type: modality, modality, invocation: { method: 'POST', path: '/v1/chat/completions', content_type: 'application/json', headers: {} } })).toEqual([])
})

View File

@ -13,6 +13,7 @@ export interface AiModelProtocol {
operationId?: string
maxImages?: number
maxPromptLength?: number
imageQuality?: 'medium'
}
const paths: Record<AiModelProtocol['id'], string> = { 'openai-chat': '/v1/chat/completions', 'anthropic-messages': '/v1/messages', 'openai-image-edit': '/v1/images/edits', 'openai-image-generate': '/v1/images/generations', 'async-image-task': '/extensions/v1/tasks' }
function record(value: unknown): Record<string, any> | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, any> : null }
@ -75,11 +76,18 @@ function parseInvocation(model: Record<string, any>, offer: Record<string, any>,
}
const maxImages = inputField && record(properties[inputField])?.maxItems
const maxPromptLength = record(properties.prompt)?.maxLength
// The editor has no quality control: choose an explicit supported working
// quality instead of relying on an undocumented upstream default.
const quality = record(properties.quality)
const imageQuality = id === 'openai-image-generate' && list(quality?.enum).includes('medium') &&
(quality?.const === undefined || quality.const === 'medium') &&
(quality?.default === undefined || quality.default === 'medium') ? 'medium' as const : undefined
return { id, path: paths[id], headers, supportsImageInput, ...(inputField ? { inputField } : {}),
...(scopeId ? { scopeId } : {}), ...(routeId ? { routeId } : {}),
...(identifier(offer.public_operation_id) ? { operationId: offer.public_operation_id } : {}),
...(Number.isInteger(maxImages) && maxImages > 0 ? { maxImages: Math.min(8, maxImages) } : {}),
...(Number.isInteger(maxPromptLength) && maxPromptLength > 0 ? { maxPromptLength } : {}) }
...(Number.isInteger(maxPromptLength) && maxPromptLength > 0 ? { maxPromptLength } : {}),
...(imageQuality ? { imageQuality } : {}) }
}
/** At setup time pick a concrete compatible route in the default scope; no runtime routing fallback. */
@ -111,9 +119,10 @@ export function validateStoredAiModelProtocols(value: unknown): AiModelProtocol[
if (raw.routeId && headers['X-Relay-Route-Option'] !== raw.routeId) return []
if (raw.inputField !== undefined && !['image', 'image[]', 'images', 'file_urls'].includes(raw.inputField)) return []
if (['maxImages', 'maxPromptLength'].some(field => raw[field] !== undefined && (!Number.isInteger(raw[field]) || raw[field] < 1 || raw[field] > (field === 'maxImages' ? 8 : 1000000)))) return []
if (raw.imageQuality !== undefined && (raw.id !== 'openai-image-generate' || raw.imageQuality !== 'medium')) return []
if (raw.id === 'async-image-task' && (!raw.supportsImageInput || raw.inputField !== 'file_urls') || raw.id === 'openai-image-generate' && raw.supportsImageInput && !raw.inputField || raw.id === 'openai-image-edit' && (!raw.supportsImageInput || !['image', 'image[]'].includes(raw.inputField))) return []
result.push({ id: raw.id, path: raw.path, headers, supportsImageInput: raw.supportsImageInput,
...Object.fromEntries(['inputField', 'scopeId', 'routeId', 'operationId', 'maxImages', 'maxPromptLength'].filter(key => raw[key] !== undefined).map(key => [key, raw[key]])) })
...Object.fromEntries(['inputField', 'scopeId', 'routeId', 'operationId', 'maxImages', 'maxPromptLength', 'imageQuality'].filter(key => raw[key] !== undefined).map(key => [key, raw[key]])) })
}
return result
}
@ -198,7 +207,7 @@ export async function callAiModelProtocol(input: AiModelProtocolInput): Promise<
else if (protocol.id === 'openai-image-edit') {
body = new FormData(); body.set('model', input.modelId); body.set('prompt', input.prompt); body.set('n', '1'); body.set('response_format', 'b64_json')
images.forEach((image, index) => (body as FormData).append(protocol.inputField!, new Blob([new Uint8Array(image)], { type: 'image/png' }), `reference-${index + 1}.png`))
} else body = JSON.stringify({ model: input.modelId, prompt: input.prompt, ...(images.length ? { [protocol.inputField!]: dataUris } : {}), ...(protocol.id === 'openai-image-generate' ? { n: 1, response_format: 'b64_json' } : {}) })
} else body = JSON.stringify({ model: input.modelId, prompt: input.prompt, ...(images.length ? { [protocol.inputField!]: dataUris } : {}), ...(protocol.id === 'openai-image-generate' ? { n: 1, response_format: 'b64_json', ...(protocol.imageQuality ? { quality: protocol.imageQuality } : {}) } : {}) })
dispatched = true
const response = await requestAiModelOperation(input.baseUrl, input.apiKey, protocol.path, body, input.idempotencyKey, protocol.headers, input.beforeRequest)
const parsed = await readJson(response, input.apiKey); requestId = parsed.requestId