diff --git a/docs/ai-image-working-quality-2026-10-15.md b/docs/ai-image-working-quality-2026-10-15.md new file mode 100644 index 0000000..67dd783 --- /dev/null +++ b/docs/ai-image-working-quality-2026-10-15.md @@ -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/`。 diff --git a/lib/__tests__/ai-model-protocol.test.ts b/lib/__tests__/ai-model-protocol.test.ts index b1754d3..ea077cd 100644 --- a/lib/__tests__/ai-model-protocol.test.ts +++ b/lib/__tests__/ai-model-protocol.test.ts @@ -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([]) }) diff --git a/lib/ai-model-protocol.ts b/lib/ai-model-protocol.ts index a2c9df4..7fff362 100644 --- a/lib/ai-model-protocol.ts +++ b/lib/ai-model-protocol.ts @@ -13,6 +13,7 @@ export interface AiModelProtocol { operationId?: string maxImages?: number maxPromptLength?: number + imageQuality?: 'medium' } const paths: Record = { '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 | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null } @@ -75,11 +76,18 @@ function parseInvocation(model: Record, offer: Record, } 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