fix(ai): bind image parameters and expose actual request settings
This commit is contained in:
parent
88e0a058e2
commit
4f123ea272
@ -19,6 +19,34 @@ const configured = (): AiModelAdminState => ({
|
|||||||
afterEach(() => jest.restoreAllMocks())
|
afterEach(() => jest.restoreAllMocks())
|
||||||
beforeEach(() => sessionStorage.clear())
|
beforeEach(() => sessionStorage.clear())
|
||||||
|
|
||||||
|
test('展开详情显示服务端实际请求参数和参考图传法,不会调用或修改模型', async () => {
|
||||||
|
const next = configured(), model = next.providers[0].models[1]
|
||||||
|
model.capabilities.protocols = { image: { id: 'async-image-task', path: '/extensions/v1/tasks', inputField: 'file_urls', referenceTransport: 'json-inline', maxPromptLength: 32000, requestParameters: { size: 'auto', resolution: '1K', aspect_ratio: '3:4' } } }
|
||||||
|
global.fetch = jest.fn(async () => response(next)) as typeof fetch
|
||||||
|
render(<AiModelManagementPanel />)
|
||||||
|
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
|
||||||
|
fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' }))
|
||||||
|
const parameters = within(row).getByLabelText('nano-banana-2 当前请求参数')
|
||||||
|
expect(within(parameters).getByText('尺寸')).toBeVisible()
|
||||||
|
expect(within(parameters).getByText('auto')).toBeVisible()
|
||||||
|
expect(within(parameters).getByText('1K')).toBeVisible()
|
||||||
|
expect(within(parameters).getByText('3:4')).toBeVisible()
|
||||||
|
expect(within(row).getByText('提示词:最多 32000 字')).toBeVisible()
|
||||||
|
expect(within(row).getByText('参考图参数:file_urls(内联图片)')).toBeVisible()
|
||||||
|
expect((global.fetch as jest.Mock).mock.calls.every(([, init]) => !init?.method)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有指定的异步图片参数明确显示为上游默认,不伪造尺寸和画质', async () => {
|
||||||
|
const next = configured(), model = next.providers[0].models[1]
|
||||||
|
model.capabilities.protocols = { image: { id: 'async-image-task', path: '/extensions/v1/tasks', requestParameters: {} } }
|
||||||
|
global.fetch = jest.fn(async () => response(next)) as typeof fetch
|
||||||
|
render(<AiModelManagementPanel />)
|
||||||
|
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
|
||||||
|
fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' }))
|
||||||
|
expect(within(row).getByText('未指定尺寸、比例和画质,使用上游默认值。')).toBeVisible()
|
||||||
|
expect(screen.queryByText('1024x1024')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
test.each(['untested', 'passed'] as const)('参考图数量以实测证据为准,%s 不把平台测试上限当成已支持', async status => {
|
test.each(['untested', 'passed'] as const)('参考图数量以实测证据为准,%s 不把平台测试上限当成已支持', async status => {
|
||||||
const next = configured(), model = next.providers[0].models[1]
|
const next = configured(), model = next.providers[0].models[1]
|
||||||
model.capabilities.protocols = { image: { id: 'openai-image-generate', path: '/v1/images/generations', maxImages: 8, referenceLimitSource: 'verification' } }
|
model.capabilities.protocols = { image: { id: 'openai-image-generate', path: '/v1/images/generations', maxImages: 8, referenceLimitSource: 'verification' } }
|
||||||
|
|||||||
@ -21,6 +21,7 @@ const primaryClass = `${buttonClass} !border-[#0050cb] !bg-[#0050cb] !text-white
|
|||||||
const checkboxClass = 'h-4 w-4 shrink-0 accent-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed'
|
const checkboxClass = 'h-4 w-4 shrink-0 accent-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed'
|
||||||
const capabilityLabels = { text: '对话', vision: '视觉理解', image: '生图', tts: '语音合成', asr: '语音识别', music: '音乐', video: '视频', embedding: '向量', 'image-tool': '图像工具', 'three-d': '3D', other: '其他', unknown: '待识别' } as const
|
const capabilityLabels = { text: '对话', vision: '视觉理解', image: '生图', tts: '语音合成', asr: '语音识别', music: '音乐', video: '视频', embedding: '向量', 'image-tool': '图像工具', 'three-d': '3D', other: '其他', unknown: '待识别' } as const
|
||||||
const verificationLabels = { untested: '未测试', running: '测试中', passed: '已通过', failed: '测试失败', unknown: '待核查', stale: '需重新测试' } as const
|
const verificationLabels = { untested: '未测试', running: '测试中', passed: '已通过', failed: '测试失败', unknown: '待核查', stale: '需重新测试' } as const
|
||||||
|
const parameterLabels: Record<string, string> = { n: '生成数量', stream: '流式输出', response_format: '返回格式', size: '尺寸', aspect_ratio: '画幅比例', resolution: '分辨率', quality: '画质', background: '背景', output_format: '文件格式', version: '模型版本', thinkingLevel: '思考级别' }
|
||||||
function verificationLabel(result: AiModelVerificationSummary) {
|
function verificationLabel(result: AiModelVerificationSummary) {
|
||||||
return result.reconciled && result.status === 'failed' ? '已核查,可重测' : verificationLabels[result.status]
|
return result.reconciled && result.status === 'failed' ? '已核查,可重测' : verificationLabels[result.status]
|
||||||
}
|
}
|
||||||
@ -367,6 +368,9 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
|
|||||||
{showBlockedReason && <p id={blockedReasonId} className="text-xs text-[#66758b]">{blockedReason}</p>}
|
{showBlockedReason && <p id={blockedReasonId} className="text-xs text-[#66758b]">{blockedReason}</p>}
|
||||||
{canInspect && <div id={detailsId} hidden={!expandedDetails[detailsId]} className="space-y-1 break-words pb-2 text-xs text-[#66758b]">
|
{canInspect && <div id={detailsId} hidden={!expandedDetails[detailsId]} className="space-y-1 break-words pb-2 text-xs text-[#66758b]">
|
||||||
{protocol && <>{protocol.maxImages && <p>{protocol.referenceLimitSource === 'verification' ? result.status === 'passed' && result.verifiedReferenceImageCount ? `参考图:已逐张验证 ${result.verifiedReferenceImageCount} 张,本平台最多 ${Math.min(protocol.maxImages, result.verifiedReferenceImageCount)} 张` : `参考图:数量待测试(本平台最多测试 ${protocol.maxImages} 张)` : `参考图:最多 ${protocol.maxImages} 张`}</p>}<p className="break-all">协议:{protocol.id}</p><p className="break-all">接口:{protocol.path}</p>{(protocol.scopeId || protocol.routeId || protocol.operationId) && <p className="break-all">路由:{[protocol.scopeId, protocol.routeId, protocol.operationId].filter(Boolean).join(' / ')}</p>}</>}
|
{protocol && <>{protocol.maxImages && <p>{protocol.referenceLimitSource === 'verification' ? result.status === 'passed' && result.verifiedReferenceImageCount ? `参考图:已逐张验证 ${result.verifiedReferenceImageCount} 张,本平台最多 ${Math.min(protocol.maxImages, result.verifiedReferenceImageCount)} 张` : `参考图:数量待测试(本平台最多测试 ${protocol.maxImages} 张)` : `参考图:最多 ${protocol.maxImages} 张`}</p>}<p className="break-all">协议:{protocol.id}</p><p className="break-all">接口:{protocol.path}</p>{(protocol.scopeId || protocol.routeId || protocol.operationId) && <p className="break-all">路由:{[protocol.scopeId, protocol.routeId, protocol.operationId].filter(Boolean).join(' / ')}</p>}</>}
|
||||||
|
{protocol?.maxPromptLength && <p>提示词:最多 {protocol.maxPromptLength} 字</p>}
|
||||||
|
{protocol?.inputField && <p className="break-all">参考图参数:{protocol.inputField}({protocol.referenceTransport === 'multipart' ? '文件上传' : '内联图片'})</p>}
|
||||||
|
{protocol?.requestParameters && <div className="space-y-1 pt-1"><p className="font-medium text-[#344054]">当前请求参数</p><dl aria-label={`${model.id} 当前请求参数`} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-x-3 gap-y-1">{Object.entries(protocol.requestParameters).map(([name, value]) => <div key={name} className="contents"><dt title={name}>{parameterLabels[name] || name}</dt><dd className="break-all text-right">{String(value)}</dd></div>)}</dl><p>{Object.keys(protocol.requestParameters).length ? '未列出的可选参数使用上游默认值。' : '未指定尺寸、比例和画质,使用上游默认值。'}</p></div>}
|
||||||
{protocol?.referenceLimitSource === 'verification' && <label className="flex flex-wrap items-center gap-2"><span>本次测试数量</span><select className={`${fieldClass} !w-auto`} aria-label={`${model.id} · ${provider.name} 本次测试参考图数量`} disabled={!!busy || result.status === 'running' || result.status === 'unknown' || !!attempt} value={testReferenceCounts[model.key] ?? result.verifiedReferenceImageCount ?? protocol.maxImages} onChange={event => setTestReferenceCounts(current => ({ ...current, [model.key]: Number(event.target.value) }))}>{[1, 2, 4, 8].filter(count => count <= (protocol.maxImages ?? 8)).map(count => <option key={count} value={count}>{count} 张</option>)}</select></label>}
|
{protocol?.referenceLimitSource === 'verification' && <label className="flex flex-wrap items-center gap-2"><span>本次测试数量</span><select className={`${fieldClass} !w-auto`} aria-label={`${model.id} · ${provider.name} 本次测试参考图数量`} disabled={!!busy || result.status === 'running' || result.status === 'unknown' || !!attempt} value={testReferenceCounts[model.key] ?? result.verifiedReferenceImageCount ?? protocol.maxImages} onChange={event => setTestReferenceCounts(current => ({ ...current, [model.key]: Number(event.target.value) }))}>{[1, 2, 4, 8].filter(count => count <= (protocol.maxImages ?? 8)).map(count => <option key={count} value={count}>{count} 张</option>)}</select></label>}
|
||||||
{sharedCheckedAt && <p>{new Date(sharedCheckedAt).toLocaleString('zh-CN')}</p>}
|
{sharedCheckedAt && <p>{new Date(sharedCheckedAt).toLocaleString('zh-CN')}</p>}
|
||||||
{sharedRequestId && <p className="break-all">请求编号:{sharedRequestId}</p>}
|
{sharedRequestId && <p className="break-all">请求编号:{sharedRequestId}</p>}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# AI 模型参数与能力核查
|
# AI 模型参数与能力核查
|
||||||
|
|
||||||
核查时间:2026/10/16 11:01:27(UTC+8);目录抓取于 2026/10/16 11:01:27(UTC+8);平台状态读取于 2026/10/16 11:01:20(UTC+8)。只读取 [98k 公开模型目录](https://api.vip98k.com/api/v2/catalog/models) 及本平台管理员状态,不调用生成接口。
|
核查时间:2026/10/16 14:22:23(UTC+8);目录抓取于 2026/10/16 13:57:04(UTC+8);平台状态读取于 2026/10/16 13:58:02(UTC+8)。只读取 [98k 公开模型目录](https://api.vip98k.com/api/v2/catalog/models) 及本平台管理员状态,不调用生成接口。
|
||||||
|
|
||||||
公开目录共 75 个模型。本平台旧目录中另有 3 个不在本次公开目录。目录规范JSON摘要 SHA-256:`123f87ba50547c03e1c54b7f8c8308c993eeccffd6053f6fbc3284e2c1b26946`。
|
公开目录共 75 个模型。本平台旧目录中另有 3 个不在本次公开目录。目录规范JSON摘要 SHA-256:`a11230d97f59052681539f9f45f610d312d4b07482874e57663f2e53f2d6b34a`。
|
||||||
|
|
||||||
透传字段仅表示网关接受/转发该字段,不证明具体模型执行该功能;只有实际测试记录可作为用途开放证据。未声明上限保持未知,不按模型名称推断。
|
透传字段仅表示网关接受/转发该字段,不证明具体模型执行该功能;只有实际测试记录可作为用途开放证据。未声明上限保持未知,不按模型名称推断。
|
||||||
|
|
||||||
@ -29,29 +29,29 @@
|
|||||||
| claude-fable-5-1 | vision | 分析、文案 | 分析:通过;文案:通过 | — |
|
| claude-fable-5-1 | vision | 分析、文案 | 分析:通过;文案:通过 | — |
|
||||||
| claude-haiku-4-5-20251001 | text | 分析、文案 | 分析:失败;文案:失败 | — |
|
| claude-haiku-4-5-20251001 | text | 分析、文案 | 分析:失败;文案:失败 | — |
|
||||||
| claude-opus-4-5-20251101 | vision | 分析、文案 | 分析:待核查;文案:待核查 | — |
|
| claude-opus-4-5-20251101 | vision | 分析、文案 | 分析:待核查;文案:待核查 | — |
|
||||||
| claude-opus-4-6 | vision | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| claude-opus-4-6 | vision | 分析、文案 | 分析:通过;文案:通过 | — |
|
||||||
| claude-opus-4-7 | vision | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| claude-opus-4-7 | vision | 分析、文案 | 分析:通过;文案:通过 | 分析 |
|
||||||
| claude-opus-4-8 | vision | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| claude-opus-4-8 | vision | 分析、文案 | 分析:通过;文案:通过 | — |
|
||||||
| claude-opus-5 | vision | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| claude-opus-5 | vision | 分析、文案 | 分析:通过;文案:通过 | — |
|
||||||
| claude-opus-5-5 | vision | 分析、文案 | 分析:通过;文案:未测试 | 分析 |
|
| claude-opus-5-5 | vision | 分析、文案 | 分析:通过;文案:通过 | 文案 |
|
||||||
| claude-sonnet-4-6 | vision | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| claude-sonnet-4-6 | vision | 分析、文案 | 分析:通过;文案:通过 | 文案 |
|
||||||
| claude-sonnet-5 | vision | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| claude-sonnet-5 | vision | 分析、文案 | 分析:通过;文案:通过 | — |
|
||||||
| gemini-3-flash | text | 分析、文案 | 分析:通过;文案:通过 | 分析、文案 |
|
| gemini-3-flash | text | 分析、文案 | 分析:通过;文案:通过 | 文案 |
|
||||||
| gemini-3-pro | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gemini-3-pro | text | 分析、文案 | 分析:通过;文案:通过 | 文案 |
|
||||||
| gemini-3.1-flash-lite | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gemini-3.1-flash-lite | text | 分析、文案 | 分析:通过;文案:通过 | 分析 |
|
||||||
| gemini-3.1-pro | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gemini-3.1-pro | text | 分析、文案 | 分析:通过;文案:通过 | 文案 |
|
||||||
| gemini-3.5-flash | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gemini-3.5-flash | text | 分析、文案 | 分析:通过;文案:通过 | — |
|
||||||
| gemini-3.5-flash-lite | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gemini-3.5-flash-lite | text | 分析、文案 | 分析:通过;文案:通过 | 分析 |
|
||||||
| gemini-3.7-flash | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gemini-3.7-flash | text | 分析、文案 | 分析:通过;文案:通过 | 分析 |
|
||||||
| gemini-3.8-flash | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gemini-3.8-flash | text | 分析、文案 | 分析:通过;文案:通过 | 文案 |
|
||||||
| gpt-5.6-sol | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gpt-5.6-sol | text | 分析、文案 | 分析:通过;文案:通过 | 分析、文案 |
|
||||||
| gpt-5.6-terra | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gpt-5.6-terra | text | 分析、文案 | 分析:通过;文案:通过 | 文案 |
|
||||||
| gpt-6-astra | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gpt-6-astra | text | 分析、文案 | 分析:通过;文案:通过 | 分析、文案 |
|
||||||
| gpt-6-sol | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gpt-6-sol | text | 分析、文案 | 分析:通过;文案:通过 | 分析、文案 |
|
||||||
| gpt-6.1-sol | text | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| gpt-6.1-sol | text | 分析、文案 | 分析:通过;文案:通过 | 分析、文案 |
|
||||||
| grok-4.5 | vision | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| grok-4.5 | vision | 分析、文案 | 分析:失败;文案:通过 | 文案 |
|
||||||
| grok-4.6 | vision | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| grok-4.6 | vision | 分析、文案 | 分析:通过;文案:通过 | 分析、文案 |
|
||||||
| grok-4.7 | vision | 分析、文案 | 分析:未测试;文案:未测试 | — |
|
| grok-4.7 | vision | 分析、文案 | 分析:通过;文案:通过 | 分析 |
|
||||||
| mimo-v2.5-asr | asr | 未接入详情页 | — | — |
|
| mimo-v2.5-asr | asr | 未接入详情页 | — | — |
|
||||||
| mimo-v2.5-tts | tts | 未接入详情页 | — | — |
|
| mimo-v2.5-tts | tts | 未接入详情页 | — | — |
|
||||||
| mimo-v2.5-tts-voiceclone | tts | 未接入详情页 | — | — |
|
| mimo-v2.5-tts-voiceclone | tts | 未接入详情页 | — | — |
|
||||||
@ -614,7 +614,6 @@
|
|||||||
### gpt-image-2
|
### gpt-image-2
|
||||||
|
|
||||||
- 本平台可适配合同:`openai-image-generate`;有图片输入适配;参考图字段 `image`;平台合同限制最多8张(仍需实测);请求参数 `{"n":1,"quality":"medium","response_format":"b64_json","size":"1024x1024","stream":false}`;公开协议头 `{"X-Relay-Route-Option":"914de45a-498e-4cd8-9d21-b1515fe8b5f2"}`。
|
- 本平台可适配合同:`openai-image-generate`;有图片输入适配;参考图字段 `image`;平台合同限制最多8张(仍需实测);请求参数 `{"n":1,"quality":"medium","response_format":"b64_json","size":"1024x1024","stream":false}`;公开协议头 `{"X-Relay-Route-Option":"914de45a-498e-4cd8-9d21-b1515fe8b5f2"}`。
|
||||||
- 本平台可适配合同:`async-image-task`;有图片输入适配;参考图字段 `file_urls`;平台合同限制最多8张(仍需实测);公开协议头 `{"X-Relay-Route-Option":"914de45a-498e-4cd8-9d21-b1515fe8b5f2"}`。
|
|
||||||
- 操作:`openai.images.generations.create`;`POST /v1/images/generations`;`application/json`。
|
- 操作:`openai.images.generations.create`;`POST /v1/images/generations`;`application/json`。
|
||||||
- 输入声明:未声明;输出声明:未声明。
|
- 输入声明:未声明;输出声明:未声明。
|
||||||
- 参数:`background`(offer_disabled)、`file_urls`(schema-only)、`image`(schema-only)、`image[]`(schema-only)、`images`(schema-only)、`input_fidelity`(schema-only)、`mask`(schema-only)、`model`(typed)、`moderation`(passthrough)、`n`(passthrough)、`output_compression`(offer_disabled)、`output_format`(offer_disabled)、`partial_images`(offer_disabled)、`prompt`(typed)、`quality`(passthrough)、`response_format`(typed)、`size`(passthrough)、`stream`(typed)、`style`(passthrough)、`user`(passthrough)。
|
- 参数:`background`(offer_disabled)、`file_urls`(schema-only)、`image`(schema-only)、`image[]`(schema-only)、`images`(schema-only)、`input_fidelity`(schema-only)、`mask`(schema-only)、`model`(typed)、`moderation`(passthrough)、`n`(passthrough)、`output_compression`(offer_disabled)、`output_format`(offer_disabled)、`partial_images`(offer_disabled)、`prompt`(typed)、`quality`(passthrough)、`response_format`(typed)、`size`(passthrough)、`stream`(typed)、`style`(passthrough)、`user`(passthrough)。
|
||||||
@ -660,7 +659,6 @@
|
|||||||
### nano-banana-2
|
### nano-banana-2
|
||||||
|
|
||||||
- 本平台可适配合同:`openai-image-generate`;有图片输入适配;参考图字段 `image`;平台合同限制最多8张(仍需实测);请求参数 `{"aspect_ratio":"1:1","n":1,"response_format":"b64_json","size":"1024x1024","stream":false,"thinkingLevel":"minimal"}`;公开协议头 `{"X-Relay-Route-Option":"0d05dafd-e27c-4073-bd19-6a457582a55f"}`。
|
- 本平台可适配合同:`openai-image-generate`;有图片输入适配;参考图字段 `image`;平台合同限制最多8张(仍需实测);请求参数 `{"aspect_ratio":"1:1","n":1,"response_format":"b64_json","size":"1024x1024","stream":false,"thinkingLevel":"minimal"}`;公开协议头 `{"X-Relay-Route-Option":"0d05dafd-e27c-4073-bd19-6a457582a55f"}`。
|
||||||
- 本平台可适配合同:`async-image-task`;有图片输入适配;参考图字段 `file_urls`;平台合同限制最多8张(仍需实测);公开协议头 `{"X-Relay-Route-Option":"0d05dafd-e27c-4073-bd19-6a457582a55f"}`。
|
|
||||||
- 操作:`openai.images.generations.create`;`POST /v1/images/generations`;`application/json`。
|
- 操作:`openai.images.generations.create`;`POST /v1/images/generations`;`application/json`。
|
||||||
- 输入声明:未声明;输出声明:未声明。
|
- 输入声明:未声明;输出声明:未声明。
|
||||||
- 参数:`background`(typed)、`model`(typed)、`moderation`(passthrough)、`n`(passthrough)、`output_compression`(passthrough)、`output_format`(passthrough)、`partial_images`(passthrough)、`prompt`(typed)、`quality`(passthrough)、`response_format`(typed)、`size`(passthrough)、`stream`(typed)、`style`(passthrough)、`user`(passthrough)。
|
- 参数:`background`(typed)、`model`(typed)、`moderation`(passthrough)、`n`(passthrough)、`output_compression`(passthrough)、`output_format`(passthrough)、`partial_images`(passthrough)、`prompt`(typed)、`quality`(passthrough)、`response_format`(typed)、`size`(passthrough)、`stream`(typed)、`style`(passthrough)、`user`(passthrough)。
|
||||||
@ -727,7 +725,6 @@
|
|||||||
### nano-banana-pro
|
### nano-banana-pro
|
||||||
|
|
||||||
- 本平台可适配合同:`openai-image-generate`;有图片输入适配;参考图字段 `image`;平台合同限制最多8张(仍需实测);请求参数 `{"aspect_ratio":"1:1","n":1,"output_format":"jpeg","response_format":"b64_json","size":"1024x1024","stream":false}`;公开协议头 `{"X-Relay-Route-Option":"dbc2a91d-146a-48bc-9ae0-d3631d5efcd3"}`。
|
- 本平台可适配合同:`openai-image-generate`;有图片输入适配;参考图字段 `image`;平台合同限制最多8张(仍需实测);请求参数 `{"aspect_ratio":"1:1","n":1,"output_format":"jpeg","response_format":"b64_json","size":"1024x1024","stream":false}`;公开协议头 `{"X-Relay-Route-Option":"dbc2a91d-146a-48bc-9ae0-d3631d5efcd3"}`。
|
||||||
- 本平台可适配合同:`async-image-task`;有图片输入适配;参考图字段 `file_urls`;平台合同限制最多8张(仍需实测);公开协议头 `{"X-Relay-Route-Option":"dbc2a91d-146a-48bc-9ae0-d3631d5efcd3"}`。
|
|
||||||
- 操作:`openai.images.generations.create`;`POST /v1/images/generations`;`application/json`。
|
- 操作:`openai.images.generations.create`;`POST /v1/images/generations`;`application/json`。
|
||||||
- 输入声明:未声明;输出声明:未声明。
|
- 输入声明:未声明;输出声明:未声明。
|
||||||
- 参数:`background`(typed)、`model`(typed)、`moderation`(passthrough)、`n`(passthrough)、`output_compression`(passthrough)、`output_format`(passthrough)、`partial_images`(passthrough)、`prompt`(typed)、`quality`(passthrough)、`response_format`(typed)、`size`(passthrough)、`stream`(typed)、`style`(passthrough)、`user`(passthrough)。
|
- 参数:`background`(typed)、`model`(typed)、`moderation`(passthrough)、`n`(passthrough)、`output_compression`(passthrough)、`output_format`(passthrough)、`partial_images`(passthrough)、`prompt`(typed)、`quality`(passthrough)、`response_format`(typed)、`size`(passthrough)、`stream`(typed)、`style`(passthrough)、`user`(passthrough)。
|
||||||
|
|||||||
@ -40,6 +40,10 @@ HTTP 410“未留存”、访问失败、格式或模型身份无法核对时,
|
|||||||
|
|
||||||
模型行的详情按钮默认折叠。展开后可查看当前用途实际绑定的协议、接口路径,以及目录提供的服务范围、路由和操作标识;已有结果还显示测试时间、消息和请求编号。这里显示服务端绑定,不允许前端随意修改路由。
|
模型行的详情按钮默认折叠。展开后可查看当前用途实际绑定的协议、接口路径,以及目录提供的服务范围、路由和操作标识;已有结果还显示测试时间、消息和请求编号。这里显示服务端绑定,不允许前端随意修改路由。
|
||||||
|
|
||||||
|
图片用途的展开详情还显示参考图字段、内联或文件上传方式、提示词长度限制及当前实际请求参数。未传的可选参数明确使用上游默认值,不用示例或模型名称声称已指定尺寸和画质。此处是只读诊断,展开不会调用模型或保存设置。
|
||||||
|
|
||||||
|
同步目录时,若同一服务范围明确提供可接收私有参考图的异步图片任务,则优先绑定该任务协议,先保存任务编号再读取同一任务结果。尺寸、比例、分辨率及其它已适配参数按对应公开 Schema 校验后传入;不向异步任务强加同步 Images 接口的 `n` 或 `response_format`。明确像素尺寸与可选 `resolution` 不重复发送;固定约束冲突或必填参数尚未适配时拒绝该路由。协议或参数改变会使相应模型需要重新测试,不会自动转移旧通过记录或扩大开放范围。原任务查询严格只允许 GET,不接受新的正文、幂等键或路由覆盖。
|
||||||
|
|
||||||
## 当前适配协议
|
## 当前适配协议
|
||||||
|
|
||||||
| 用途 | 已实现的协议适配 | 实际测试检查 |
|
| 用途 | 已实现的协议适配 | 实际测试检查 |
|
||||||
@ -85,3 +89,5 @@ HTTP 410“未留存”、访问失败、格式或模型身份无法核对时,
|
|||||||
新阶段启动时清除上一阶段的请求号,防止把分析请求误当成文案请求查询。恢复前的本地检查失败、410、连接中断或读取权限失败都不能当作原请求执行失败。截断、工具调用要求、非 JSON 或不符合结构的已完成响应则明确记为未通过,不执行模型要求的工具,也不自动重试。分析警告会带入文案,不能被文案模型悄悄删除。
|
新阶段启动时清除上一阶段的请求号,防止把分析请求误当成文案请求查询。恢复前的本地检查失败、410、连接中断或读取权限失败都不能当作原请求执行失败。截断、工具调用要求、非 JSON 或不符合结构的已完成响应则明确记为未通过,不执行模型要求的工具,也不自动重试。分析警告会带入文案,不能被文案模型悄悄删除。
|
||||||
|
|
||||||
逐模型参数、公开声明、实际适配与已有测试记录见 [2026-10-16 模型合同核查](ai-model-contracts-2026-10-16.md)。本次恢复验收与未完成项见 [资料分析和文案恢复验收记录](ai-model-text-recovery-acceptance-2026-10-16.md)。公开 Schema 的透传字段不等于该模型的功能已经验收。
|
逐模型参数、公开声明、实际适配与已有测试记录见 [2026-10-16 模型合同核查](ai-model-contracts-2026-10-16.md)。本次恢复验收与未完成项见 [资料分析和文案恢复验收记录](ai-model-text-recovery-acceptance-2026-10-16.md)。公开 Schema 的透传字段不等于该模型的功能已经验收。
|
||||||
|
|
||||||
|
2026-10-16 下午通过 98k 对外接口复核恢复:公开目录、鉴权模型列表和此前已提交的异步图片任务结果均可读取,原任务已由生产 Worker 恢复完成,未重发生图。隔离验收中,Nano Banana 2、GPT Image 2.5 均通过四张独立参考图逐张内容校验,原结果可重新下载;GPT 6.1 的一次联合请求分别通过分析与文案校验,测试后的用途启用、默认值保存及权限拦截均已检查。GPT Image 2 的新测试返回 524,未返回客户请求号,保持结果未知且禁止自动重发;这不代表所有线路都已恢复。本轮隔离测试不改写生产用途验证记录,也不把测试数据库的记录复制为生产通过证据。证据见 `output/98k-integration-recovery-20261016/`。
|
||||||
|
|||||||
@ -157,3 +157,10 @@ test('an ID-only private model entry can still withdraw a publicly available cap
|
|||||||
const merged = mergeAiModelCapabilityMetadata(unavailable, publicMetadata(model({ id: 'gemini-2.5-flash' })))
|
const merged = mergeAiModelCapabilityMetadata(unavailable, publicMetadata(model({ id: 'gemini-2.5-flash' })))
|
||||||
expect(getAiModelCapabilities(base, 'gemini-2.5-flash', merged)).toMatchObject({ kind: 'text', allowedStages: [] })
|
expect(getAiModelCapabilities(base, 'gemini-2.5-flash', merged)).toMatchObject({ kind: 'text', allowedStages: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('admin image diagnostics use the same bounded parameters as the execution adapter', () => {
|
||||||
|
const evidence = { version: 1, inputModalities: ['image', 'text'], outputModalities: ['image'], endpoints: ['/extensions/v1/tasks'], sourceUrl: null,
|
||||||
|
inputDeclared: true, outputDeclared: true, protocols: [{ id: 'async-image-task', path: '/extensions/v1/tasks', headers: {}, supportsImageInput: true, inputField: 'file_urls', maxPromptLength: 32000, requestParameters: { size: 'auto', resolution: '1K' } }] }
|
||||||
|
expect(getAiModelCapabilities(base, 'arbitrary-model', evidence).protocols?.image).toMatchObject({ inputField: 'file_urls', referenceTransport: 'json-inline', maxPromptLength: 32000, requestParameters: { size: 'auto', resolution: '1K' } })
|
||||||
|
expect(JSON.stringify(getAiModelCapabilities(base, 'arbitrary-model', evidence))).not.toContain('headers')
|
||||||
|
})
|
||||||
|
|||||||
@ -41,6 +41,14 @@ test('retained response endpoint only permits an authenticated GET on the config
|
|||||||
expect(sent).toHaveLength(1)
|
expect(sent).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('accepted task results reject a body, a new idempotency key or route overrides before transport', async () => {
|
||||||
|
const path = '/extensions/v1/tasks/original-task/result'
|
||||||
|
for (const [body, key, headers] of [
|
||||||
|
['{}', undefined, {}], [undefined, 'new-generation', {}], [undefined, undefined, { 'X-Relay-Route-Option': 'different-route' }],
|
||||||
|
] as const) await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', path, body, key, headers)).rejects.toMatchObject({ code: 'protocol_invalid' })
|
||||||
|
expect(lookup).not.toHaveBeenCalled(); expect(httpsRequest).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
test.each(['/v1/unknown', '//169.254.169.254/metadata', '/extensions/v1/tasks/../../secret/result', '/extensions/v1/tasks/task-1/result?url=private'])('rejects unsupported or injected operation %s before DNS', async path => {
|
test.each(['/v1/unknown', '//169.254.169.254/metadata', '/extensions/v1/tasks/../../secret/result', '/extensions/v1/tasks/task-1/result?url=private'])('rejects unsupported or injected operation %s before DNS', async path => {
|
||||||
await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', path, '{}', 'request-1', {})).rejects.toMatchObject({ code: 'protocol_unsupported' })
|
await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', path, '{}', 'request-1', {})).rejects.toMatchObject({ code: 'protocol_unsupported' })
|
||||||
expect(lookup).not.toHaveBeenCalled(); expect(httpsRequest).not.toHaveBeenCalled()
|
expect(lookup).not.toHaveBeenCalled(); expect(httpsRequest).not.toHaveBeenCalled()
|
||||||
|
|||||||
@ -13,6 +13,74 @@ let png: Buffer
|
|||||||
beforeAll(async () => { png = await sharp({ create: { width: 32, height: 32, channels: 3, background: '#ff0000' } }).png().toBuffer() })
|
beforeAll(async () => { png = await sharp({ create: { width: 32, height: 32, channels: 3, background: '#ff0000' } }).png().toBuffer() })
|
||||||
beforeEach(() => { jest.clearAllMocks() })
|
beforeEach(() => { jest.clearAllMocks() })
|
||||||
|
|
||||||
|
const parameterizedAsyncModel = (properties: Record<string, any> = {}, required = ['model', 'file_urls']) => ({
|
||||||
|
type: 'image', modality: 'image', capabilities: ['image_to_image'], input_modes: ['text', 'image_base64'],
|
||||||
|
invocation: { method: 'POST', path: '/extensions/v1/tasks', content_type: 'application/json', headers: {},
|
||||||
|
input_schema: { type: 'object', additionalProperties: false, required, properties: {
|
||||||
|
model: { type: 'string' }, prompt: { type: 'string' }, file_urls: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string', pattern: '^(https://|data:image/)' } }, ...properties,
|
||||||
|
} }, request_example: { resolution: '4K' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
test('async image tasks persist and transmit their declared parameters without adding Images-only fields', async () => {
|
||||||
|
const protocol = parseAiModelProtocols(parameterizedAsyncModel({
|
||||||
|
size: { enum: ['auto', '1024x1024'], default: 'auto' }, resolution: { enum: ['1K', '2K', '4K'], default: '1K' },
|
||||||
|
aspect_ratio: { enum: ['1:1', '3:4'], default: '3:4' }, quality: { enum: ['medium', 'high'], default: 'medium' },
|
||||||
|
n: false, response_format: false,
|
||||||
|
}))[0]
|
||||||
|
expect(protocol).toMatchObject({ requestParameters: { size: 'auto', resolution: '1K', aspect_ratio: '3:4', quality: 'medium' } })
|
||||||
|
expect(validateStoredAiModelProtocols(JSON.parse(JSON.stringify([protocol])))).toEqual([protocol])
|
||||||
|
send.mockResolvedValueOnce(Response.json({ id: 'accepted-parameter-task' })).mockResolvedValueOnce(Response.json({ ready: true, status: 'succeeded', result: { outputs: [{ type: 'image', b64_json: png.toString('base64') }] } }))
|
||||||
|
const result = await callAiModelProtocol({ ...base, protocol, images: [{ buffer: png, mimeType: 'image/png' }], onTaskSubmitted: jest.fn() })
|
||||||
|
const body = JSON.parse(send.mock.calls[0][3])
|
||||||
|
expect(body).toMatchObject({ size: 'auto', resolution: '1K', aspect_ratio: '3:4', quality: 'medium', file_urls: [expect.stringMatching(/^data:image\/png;base64,/)] })
|
||||||
|
expect(body.n).toBeUndefined(); expect(body.response_format).toBeUndefined()
|
||||||
|
expect(send.mock.calls[1].slice(2, 6)).toEqual(['/extensions/v1/tasks/accepted-parameter-task/result', undefined, undefined, {}])
|
||||||
|
expect(result.summary.requestParameters).toEqual(protocol.requestParameters)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[{ n: { const: 2 } }, ['model', 'file_urls']],
|
||||||
|
[{ callback_url: { type: 'string' } }, ['model', 'file_urls', 'callback_url']],
|
||||||
|
[{ size: { const: '1024x1024' }, resolution: { const: '4K' } }, ['model', 'file_urls']],
|
||||||
|
] as const)('an async route cannot omit or contradict required public parameters: %j', (properties, required) => {
|
||||||
|
expect(parseAiModelProtocols(parameterizedAsyncModel(properties, [...required]))).toEqual([])
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a canonical pixel size omits the optional resolution example instead of sending conflicting aliases', () => {
|
||||||
|
const protocol = parseAiModelProtocols(parameterizedAsyncModel({ size: { const: '1024x1024' }, resolution: { enum: ['1K', '2K', '4K'] } }))[0]
|
||||||
|
expect(protocol.requestParameters).toEqual({ size: '1024x1024' })
|
||||||
|
expect(validateStoredAiModelProtocols([{ ...asyncImage, requestParameters: { resolution: '8K' } }])).toEqual([])
|
||||||
|
expect(validateStoredAiModelProtocols([{ ...chat, requestParameters: { resolution: '1K' } }])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an explicitly published async image operation is bound before its synchronous counterpart', () => {
|
||||||
|
const image = { type: 'array', items: { type: 'string', pattern: '^(https://|data:image/)' } }
|
||||||
|
const model = { type: 'image', modality: 'image', capabilities: ['image_to_image'], input_modes: ['text', 'image_base64'],
|
||||||
|
default_service_scope_id: 'same-scope', default_route_option_id: 'same-route', service_offers: [
|
||||||
|
{ service_scope: { id: 'same-scope' }, public_operation_id: 'openai.images.generations.create', route_options: [{ id: 'same-route', invocation: invocation('/v1/images/generations', 'same-route', { properties: { image } }) }] },
|
||||||
|
{ service_scope: { id: 'same-scope' }, public_operation_id: 'media.tasks.create', route_options: [{ id: 'same-route', invocation: invocation('/extensions/v1/tasks', 'same-route', { properties: { file_urls: image } }) }] },
|
||||||
|
] }
|
||||||
|
expect(parseAiModelProtocols(model).map(protocol => protocol.id)).toEqual(['async-image-task', 'openai-image-generate'])
|
||||||
|
expect(parseAiModelProtocols(model).every(protocol => protocol.routeId === 'same-route' && protocol.scopeId === 'same-scope')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recoverable tasks are preferred inside one offer but never across service scopes', () => {
|
||||||
|
const image = { type: 'array', items: { type: 'string', pattern: '^(https://|data:image/)' } }
|
||||||
|
const model = { type: 'image', modality: 'image', capabilities: ['image_to_image'], input_modes: ['text', 'image_base64'], default_service_scope_id: 'same-scope', default_route_option_id: 'sync-route', service_offers: [
|
||||||
|
{ service_scope: { id: 'same-scope' }, route_options: [
|
||||||
|
{ id: 'sync-route', invocation: invocation('/v1/images/generations', 'sync-route', { properties: { image } }) },
|
||||||
|
{ id: 'async-route', invocation: invocation('/extensions/v1/tasks', 'async-route', { properties: { file_urls: image } }) },
|
||||||
|
] },
|
||||||
|
] }
|
||||||
|
expect(parseAiModelProtocols(model)).toMatchObject([{ id: 'async-image-task', routeId: 'async-route', scopeId: 'same-scope' }])
|
||||||
|
const separateScope = { ...model, service_offers: [
|
||||||
|
{ service_scope: { id: 'same-scope' }, route_options: model.service_offers[0].route_options.slice(0, 1) },
|
||||||
|
{ service_scope: { id: 'other-scope' }, route_options: model.service_offers[0].route_options.slice(1) },
|
||||||
|
] }
|
||||||
|
expect(parseAiModelProtocols(separateScope)).toMatchObject([{ id: 'openai-image-generate', routeId: 'sync-route', scopeId: 'same-scope' }])
|
||||||
|
})
|
||||||
|
|
||||||
test('retained image response is fetched read-only and decoded through the existing private asset guard', async () => {
|
test('retained image response is fetched read-only and decoded through the existing private asset guard', async () => {
|
||||||
const requestId = '1c8c2fa5-5ad0-436b-977f-a047a64fa845', guard = jest.fn()
|
const requestId = '1c8c2fa5-5ad0-436b-977f-a047a64fa845', guard = jest.fn()
|
||||||
send.mockResolvedValue(Response.json({ data: [{ url: 'https://models.example.com/api/external/assets/' + 'a'.repeat(64) }] }))
|
send.mockResolvedValue(Response.json({ data: [{ url: 'https://models.example.com/api/external/assets/' + 'a'.repeat(64) }] }))
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { AI_MODEL_KINDS, type AiModelCapabilities, type AiModelStage, type AiModelKind } from './ai-model-types'
|
import { AI_MODEL_KINDS, type AiModelCapabilities, type AiModelStage, type AiModelKind } from './ai-model-types'
|
||||||
import { AiModelError } from './ai-model-access'
|
import { AiModelError } from './ai-model-access'
|
||||||
import { parseAiModelProtocols, validateStoredAiModelProtocols, type AiModelProtocol } from './ai-model-protocol'
|
import { aiModelImageRequestParameters, parseAiModelProtocols, validateStoredAiModelProtocols, type AiModelProtocol } from './ai-model-protocol'
|
||||||
|
|
||||||
/** Server-owned evidence. Never accepted from configuration writes or inferred from model names. */
|
/** Server-owned evidence. Never accepted from configuration writes or inferred from model names. */
|
||||||
export interface AiModelCapabilityMetadata {
|
export interface AiModelCapabilityMetadata {
|
||||||
@ -156,7 +156,10 @@ export function getAiModelCapabilities(baseUrl: string, modelId: string, metadat
|
|||||||
: '该类型不适用于详情页的分析、文案或生图'
|
: '该类型不适用于详情页的分析、文案或生图'
|
||||||
return { kind, allowedStages, combinedTextTest, source: 'upstream', protocols: Object.fromEntries(allowedStages.map(stage => {
|
return { kind, allowedStages, combinedTextTest, source: 'upstream', protocols: Object.fromEntries(allowedStages.map(stage => {
|
||||||
const protocol = getAiModelStageProtocol(metadata, stage)!
|
const protocol = getAiModelStageProtocol(metadata, stage)!
|
||||||
return [stage, { id: protocol.id, path: protocol.path, scopeId: protocol.scopeId, routeId: protocol.routeId, operationId: protocol.operationId, ...(protocol.maxImages ? { maxImages: protocol.maxImages } : {}), ...(protocol.referenceContract === 'probe-required' ? { referenceLimitSource: 'verification' } : {}) }]
|
return [stage, { id: protocol.id, path: protocol.path, scopeId: protocol.scopeId, routeId: protocol.routeId, operationId: protocol.operationId,
|
||||||
|
...(protocol.maxImages ? { maxImages: protocol.maxImages } : {}), ...(protocol.maxPromptLength ? { maxPromptLength: protocol.maxPromptLength } : {}),
|
||||||
|
...(stage === 'image' ? { inputField: protocol.inputField, referenceTransport: protocol.referenceTransport || (protocol.id === 'openai-image-edit' ? 'multipart' : 'json-inline'), requestParameters: aiModelImageRequestParameters(protocol) } : {}),
|
||||||
|
...(protocol.referenceContract === 'probe-required' ? { referenceLimitSource: 'verification' } : {}) }]
|
||||||
})), reason }
|
})), reason }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,7 +39,8 @@ export async function requestAiModel(baseUrl: string, apiKey: string, endpoint:
|
|||||||
export async function requestAiModelOperation(baseUrl: string, apiKey: string, path: string, body: string | FormData | undefined, idempotencyKey: string | undefined, protocolHeaders: Record<string, string>, beforeRequest?: () => Promise<void>): Promise<Response> {
|
export async function requestAiModelOperation(baseUrl: string, apiKey: string, path: string, body: string | FormData | undefined, idempotencyKey: string | undefined, protocolHeaders: Record<string, string>, beforeRequest?: () => Promise<void>): Promise<Response> {
|
||||||
const normalized = normalizeAiModelBaseUrl(baseUrl)
|
const normalized = normalizeAiModelBaseUrl(baseUrl)
|
||||||
const retainedResult = /^\/api\/external\/requests\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\/result$/i.test(path)
|
const retainedResult = /^\/api\/external\/requests\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\/result$/i.test(path)
|
||||||
if (retainedResult && (body !== undefined || idempotencyKey !== undefined || Object.keys(protocolHeaders).length)) throw new AiModelError('原请求结果仅允许只读查询', 400, 'protocol_invalid')
|
const taskResult = /^\/extensions\/v1\/tasks\/[A-Za-z0-9_-]{1,160}\/result$/.test(path)
|
||||||
|
if ((retainedResult || taskResult) && (body !== undefined || idempotencyKey !== undefined || Object.keys(protocolHeaders).length)) throw new AiModelError('原请求结果仅允许只读查询', 400, 'protocol_invalid')
|
||||||
if (!retainedResult && !['/v1/chat/completions', '/v1/messages', '/v1/images/edits', '/v1/images/generations', '/extensions/v1/tasks'].includes(path) && !/^\/extensions\/v1\/tasks\/[A-Za-z0-9_-]{1,160}\/result$/.test(path)) throw new AiModelError('模型接口尚未适配', 400, 'protocol_unsupported')
|
if (!retainedResult && !['/v1/chat/completions', '/v1/messages', '/v1/images/edits', '/v1/images/generations', '/extensions/v1/tasks'].includes(path) && !/^\/extensions\/v1\/tasks\/[A-Za-z0-9_-]{1,160}\/result$/.test(path)) throw new AiModelError('模型接口尚未适配', 400, 'protocol_unsupported')
|
||||||
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` }
|
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` }
|
||||||
if (idempotencyKey) {
|
if (idempotencyKey) {
|
||||||
|
|||||||
@ -42,6 +42,7 @@ const imageParameterChecks: Record<string, (value: unknown) => boolean> = {
|
|||||||
background: value => typeof value === 'string' && ['transparent', 'opaque', 'auto'].includes(value),
|
background: value => typeof value === 'string' && ['transparent', 'opaque', 'auto'].includes(value),
|
||||||
output_format: value => typeof value === 'string' && ['png', 'jpeg', 'webp'].includes(value),
|
output_format: value => typeof value === 'string' && ['png', 'jpeg', 'webp'].includes(value),
|
||||||
aspect_ratio: value => typeof value === 'string' && (value === 'auto' || /^\d{1,2}:\d{1,2}$/.test(value)),
|
aspect_ratio: value => typeof value === 'string' && (value === 'auto' || /^\d{1,2}:\d{1,2}$/.test(value)),
|
||||||
|
resolution: value => typeof value === 'string' && ['0.5K', '1K', '2K', '4K'].includes(value),
|
||||||
version: value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value),
|
version: value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value),
|
||||||
thinkingLevel: value => typeof value === 'string' && ['minimal', 'low', 'medium', 'high'].includes(value),
|
thinkingLevel: value => typeof value === 'string' && ['minimal', 'low', 'medium', 'high'].includes(value),
|
||||||
}
|
}
|
||||||
@ -90,20 +91,24 @@ function acceptsSchemaValue(value: unknown, schema: unknown, depth = 0): boolean
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
/** Keep only bounded public request fields; never carry arbitrary metadata or callback URLs. */
|
/** Keep only bounded public request fields; never carry arbitrary metadata or callback URLs. */
|
||||||
function imageRequestParameters(schema: Record<string, any> | null, example: unknown): AiModelProtocol['requestParameters'] | null {
|
function imageRequestParameters(schema: Record<string, any> | null, example: unknown, standardImages = true): AiModelProtocol['requestParameters'] | null {
|
||||||
if (!inspectableSchema(schema)) return null
|
if (!inspectableSchema(schema)) return null
|
||||||
const parts = schemaParts(schema), sample = record(example) ?? {}, parameters: Record<string, string | number | boolean> = {}
|
const parts = schemaParts(schema), sample = record(example) ?? {}, parameters: Record<string, string | number | boolean> = {}
|
||||||
|
const implicit = standardImages ? ['n', 'response_format'] : []
|
||||||
const names = new Set(parts.flatMap(part => Object.keys(record(part.properties) ?? {})))
|
const names = new Set(parts.flatMap(part => Object.keys(record(part.properties) ?? {})))
|
||||||
for (const name of Object.keys(imageParameterChecks)) {
|
for (const name of Object.keys(imageParameterChecks)) {
|
||||||
const constraints = parts.flatMap(part => Object.prototype.hasOwnProperty.call(record(part.properties) ?? {}, name) ? [part.properties[name]] : [])
|
const constraints = parts.flatMap(part => Object.prototype.hasOwnProperty.call(record(part.properties) ?? {}, name) ? [part.properties[name]] : [])
|
||||||
if (constraints.some(item => item === false)) {
|
if (constraints.some(item => item === false)) {
|
||||||
if (['n', 'response_format'].includes(name)) return null
|
if (implicit.includes(name)) return null
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!constraints.length && !['n', 'response_format'].includes(name)) continue
|
if (!constraints.length && !implicit.includes(name)) continue
|
||||||
if (!constraints.length && parts.some(part => part.additionalProperties === false && !part.properties?.[name])) return null
|
if (!constraints.length && parts.some(part => part.additionalProperties === false && !part.properties?.[name])) return null
|
||||||
const required = parts.some(part => list(part.required).includes(name)) || ['n', 'response_format'].includes(name)
|
const required = parts.some(part => list(part.required).includes(name)) || implicit.includes(name)
|
||||||
const declared = constraints.flatMap(item => schemaParts(item))
|
const declared = constraints.flatMap(item => schemaParts(item))
|
||||||
|
// A concrete size is the canonical resolution. Do not also send an optional
|
||||||
|
// resolution alias from an example, which may contradict that size.
|
||||||
|
if (name === 'resolution' && typeof parameters.size === 'string' && parameters.size !== 'auto' && !required && !declared.some(item => item.const !== undefined)) continue
|
||||||
const choices = declared.flatMap(item => Array.isArray(item.enum) ? item.enum : [])
|
const choices = declared.flatMap(item => Array.isArray(item.enum) ? item.enum : [])
|
||||||
if (name === 'size') choices.sort((a, b) => {
|
if (name === 'size') choices.sort((a, b) => {
|
||||||
const area = (value: unknown) => typeof value === 'string' && /^\d+x\d+$/.test(value) ? value.split('x').map(Number).reduce((x, y) => x * y) : Number.MAX_SAFE_INTEGER
|
const area = (value: unknown) => typeof value === 'string' && /^\d+x\d+$/.test(value) ? value.split('x').map(Number).reduce((x, y) => x * y) : Number.MAX_SAFE_INTEGER
|
||||||
@ -120,6 +125,10 @@ function imageRequestParameters(schema: Record<string, any> | null, example: unk
|
|||||||
}
|
}
|
||||||
const required = parts.flatMap(part => list(part.required))
|
const required = parts.flatMap(part => list(part.required))
|
||||||
if (required.some(name => !['model', 'prompt', 'image', 'images', 'file_urls'].includes(name) && parameters[name] === undefined)) return null
|
if (required.some(name => !['model', 'prompt', 'image', 'images', 'file_urls'].includes(name) && parameters[name] === undefined)) return null
|
||||||
|
if (typeof parameters.size === 'string' && parameters.size !== 'auto' && typeof parameters.resolution === 'string') {
|
||||||
|
const pixels = { '0.5K': 512, '1K': 1024, '2K': 2048, '4K': 4096 }[parameters.resolution]
|
||||||
|
if (Math.max(...parameters.size.split('x').map(Number)) !== pixels) return null
|
||||||
|
}
|
||||||
// These aliases describe the same resolution. Use the canonical size from the published route.
|
// These aliases describe the same resolution. Use the canonical size from the published route.
|
||||||
if (names.has('size') && parameters.size === undefined && parts.some(part => part.properties?.size?.enum || part.properties?.size?.const)) return null
|
if (names.has('size') && parameters.size === undefined && parts.some(part => part.properties?.size?.enum || part.properties?.size?.const)) return null
|
||||||
return safeImageParameters(parameters)
|
return safeImageParameters(parameters)
|
||||||
@ -154,7 +163,7 @@ function parseInvocation(model: Record<string, any>, offer: Record<string, any>,
|
|||||||
const schemaNodes = schemaParts(schema)
|
const schemaNodes = schemaParts(schema)
|
||||||
// Whole-request alternatives/negations are not implemented by the field mapper.
|
// Whole-request alternatives/negations are not implemented by the field mapper.
|
||||||
// Refuse such contracts rather than checking individual fields and bypassing them.
|
// Refuse such contracts rather than checking individual fields and bypassing them.
|
||||||
if (id === 'openai-image-generate' && schema && (!inspectableSchema(schema) || schemaNodes.some(part => ['not', 'oneOf', 'anyOf'].some(name => part[name] !== undefined)))) return null
|
if (['openai-image-generate', 'async-image-task'].includes(id) && schema && (!inspectableSchema(schema) || schemaNodes.some(part => ['not', 'oneOf', 'anyOf'].some(name => part[name] !== undefined)))) return null
|
||||||
const referenceSchemas = (field: string) => schemaNodes.flatMap(part => Object.prototype.hasOwnProperty.call(record(part.properties) ?? {}, field) ? [part.properties[field]] : [])
|
const referenceSchemas = (field: string) => schemaNodes.flatMap(part => Object.prototype.hasOwnProperty.call(record(part.properties) ?? {}, field) ? [part.properties[field]] : [])
|
||||||
const modes = list(route.input_modes ?? offer.input_modes ?? model.input_modes)
|
const modes = list(route.input_modes ?? offer.input_modes ?? model.input_modes)
|
||||||
const capabilities = list(offer.capabilities ?? model.capabilities)
|
const capabilities = list(offer.capabilities ?? model.capabilities)
|
||||||
@ -205,9 +214,10 @@ function parseInvocation(model: Record<string, any>, offer: Record<string, any>,
|
|||||||
referenceTransport = 'json-inline'
|
referenceTransport = 'json-inline'
|
||||||
referenceContract = 'probe-required'
|
referenceContract = 'probe-required'
|
||||||
}
|
}
|
||||||
if (referenceTransport) {
|
if (referenceTransport || id === 'async-image-task') {
|
||||||
requestParameters = imageRequestParameters(schema, invocation.request_example) ?? undefined
|
const parameters = imageRequestParameters(schema, invocation.request_example, id !== 'async-image-task')
|
||||||
if (!requestParameters) return null
|
if (!parameters) return null
|
||||||
|
if (referenceTransport || Object.keys(parameters).length) requestParameters = parameters
|
||||||
}
|
}
|
||||||
if (id === 'async-image-task' && !supportsImageInput) return null
|
if (id === 'async-image-task' && !supportsImageInput) return null
|
||||||
}
|
}
|
||||||
@ -232,7 +242,8 @@ function parseInvocation(model: Record<string, any>, offer: Record<string, any>,
|
|||||||
...(Number.isInteger(maxPromptLength) && maxPromptLength > 0 ? { maxPromptLength } : {}),
|
...(Number.isInteger(maxPromptLength) && maxPromptLength > 0 ? { maxPromptLength } : {}),
|
||||||
...(imageQuality ? { imageQuality } : {}),
|
...(imageQuality ? { imageQuality } : {}),
|
||||||
...(referenceContract ? { referenceContract } : {}),
|
...(referenceContract ? { referenceContract } : {}),
|
||||||
...(referenceTransport ? { referenceTransport, requestParameters } : {}) }
|
...(referenceTransport ? { referenceTransport } : {}),
|
||||||
|
...(requestParameters ? { requestParameters } : {}) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** At setup time pick a concrete compatible route in the default scope; no runtime routing fallback. */
|
/** At setup time pick a concrete compatible route in the default scope; no runtime routing fallback. */
|
||||||
@ -248,10 +259,12 @@ export function parseAiModelProtocols(value: unknown): AiModelProtocol[] {
|
|||||||
const routes = Array.isArray(offer.route_options) ? offer.route_options.map(record).filter(Boolean) as Record<string, any>[] : []
|
const routes = Array.isArray(offer.route_options) ? offer.route_options.map(record).filter(Boolean) as Record<string, any>[] : []
|
||||||
const eligible = routes.filter(route => route.available !== false).sort((a, b) => Number(b.id === model.default_route_option_id) - Number(a.id === model.default_route_option_id))
|
const eligible = routes.filter(route => route.available !== false).sort((a, b) => Number(b.id === model.default_route_option_id) - Number(a.id === model.default_route_option_id))
|
||||||
const protocols = eligible.map(route => parseInvocation(model, offer, route)).filter(Boolean) as AiModelProtocol[]
|
const protocols = eligible.map(route => parseInvocation(model, offer, route)).filter(Boolean) as AiModelProtocol[]
|
||||||
const chosen = protocols.find(protocol => !protocol.referenceContract && protocol.referenceTransport === 'json-inline') ?? protocols.find(protocol => !protocol.referenceContract && protocol.supportsImageInput) ?? protocols.find(protocol => protocol.supportsImageInput) ?? protocols[0]
|
const chosen = protocols.find(protocol => protocol.id === 'async-image-task' && protocol.supportsImageInput) ?? protocols.find(protocol => !protocol.referenceContract && protocol.referenceTransport === 'json-inline') ?? protocols.find(protocol => !protocol.referenceContract && protocol.supportsImageInput) ?? protocols.find(protocol => protocol.supportsImageInput) ?? protocols[0]
|
||||||
if (chosen && !found.some(protocol => JSON.stringify(protocol) === JSON.stringify(chosen))) found.push(chosen)
|
if (chosen && !found.some(protocol => JSON.stringify(protocol) === JSON.stringify(chosen))) found.push(chosen)
|
||||||
}
|
}
|
||||||
return found.sort((a, b) => Number(b.supportsImageInput) - Number(a.supportsImageInput) || Number(Boolean(a.referenceContract)) - Number(Boolean(b.referenceContract)) || Number(b.referenceTransport === 'json-inline') - Number(a.referenceTransport === 'json-inline'))
|
// An explicitly published task operation can persist its ID before a long
|
||||||
|
// generation finishes. Prefer that recoverable contract at sync time.
|
||||||
|
return found.sort((a, b) => Number(b.supportsImageInput) - Number(a.supportsImageInput) || Number(Boolean(a.referenceContract)) - Number(Boolean(b.referenceContract)) || Number(b.id === 'async-image-task') - Number(a.id === 'async-image-task') || Number(b.referenceTransport === 'json-inline') - Number(a.referenceTransport === 'json-inline'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateStoredAiModelProtocols(value: unknown): AiModelProtocol[] {
|
export function validateStoredAiModelProtocols(value: unknown): AiModelProtocol[] {
|
||||||
@ -268,7 +281,7 @@ export function validateStoredAiModelProtocols(value: unknown): AiModelProtocol[
|
|||||||
if (raw.referenceTransport !== undefined && (!['json-inline', 'multipart'].includes(raw.referenceTransport) || raw.id !== 'openai-image-generate' || !raw.supportsImageInput || !['image', 'images', 'file_urls'].includes(raw.inputField) || raw.referenceTransport === 'multipart' && raw.inputField !== 'image')) return []
|
if (raw.referenceTransport !== undefined && (!['json-inline', 'multipart'].includes(raw.referenceTransport) || raw.id !== 'openai-image-generate' || !raw.supportsImageInput || !['image', 'images', 'file_urls'].includes(raw.inputField) || raw.referenceTransport === 'multipart' && raw.inputField !== 'image')) return []
|
||||||
if (raw.referenceContract !== undefined && (raw.referenceContract !== 'probe-required' || raw.id !== 'openai-image-generate' || raw.referenceTransport !== 'json-inline' || raw.inputField !== 'image' || !Number.isInteger(raw.maxImages) || raw.maxImages < 1 || raw.maxImages > 8 || !raw.routeId || !raw.scopeId)) return []
|
if (raw.referenceContract !== undefined && (raw.referenceContract !== 'probe-required' || raw.id !== 'openai-image-generate' || raw.referenceTransport !== 'json-inline' || raw.inputField !== 'image' || !Number.isInteger(raw.maxImages) || raw.maxImages < 1 || raw.maxImages > 8 || !raw.routeId || !raw.scopeId)) return []
|
||||||
const requestParameters = raw.requestParameters !== undefined ? safeImageParameters(raw.requestParameters) : undefined
|
const requestParameters = raw.requestParameters !== undefined ? safeImageParameters(raw.requestParameters) : undefined
|
||||||
if (raw.referenceTransport !== undefined && (!requestParameters || requestParameters.n !== 1 || !requestParameters.response_format) || raw.requestParameters !== undefined && (raw.referenceTransport === undefined || !requestParameters)) return []
|
if (raw.referenceTransport !== undefined && (!requestParameters || requestParameters.n !== 1 || !requestParameters.response_format) || raw.requestParameters !== undefined && (raw.referenceTransport === undefined && raw.id !== 'async-image-task' || !requestParameters)) 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 []
|
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,
|
result.push({ id: raw.id, path: raw.path, headers, supportsImageInput: raw.supportsImageInput,
|
||||||
...Object.fromEntries(['inputField', 'scopeId', 'routeId', 'operationId', 'maxImages', 'maxPromptLength', 'imageQuality', 'referenceTransport', 'referenceContract'].filter(key => raw[key] !== undefined).map(key => [key, raw[key]])),
|
...Object.fromEntries(['inputField', 'scopeId', 'routeId', 'operationId', 'maxImages', 'maxPromptLength', 'imageQuality', 'referenceTransport', 'referenceContract'].filter(key => raw[key] !== undefined).map(key => [key, raw[key]])),
|
||||||
@ -277,6 +290,13 @@ export function validateStoredAiModelProtocols(value: unknown): AiModelProtocol[
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The actual bounded image settings, shared by request construction and admin diagnostics. */
|
||||||
|
export function aiModelImageRequestParameters(protocol: AiModelProtocol): Record<string, string | number | boolean> {
|
||||||
|
if (protocol.id === 'async-image-task') return { ...protocol.requestParameters }
|
||||||
|
if (!['openai-image-edit', 'openai-image-generate'].includes(protocol.id)) return {}
|
||||||
|
return { n: 1, response_format: 'b64_json', ...(protocol.imageQuality ? { quality: protocol.imageQuality } : {}), ...protocol.requestParameters }
|
||||||
|
}
|
||||||
|
|
||||||
export class AiModelProtocolError extends Error {
|
export class AiModelProtocolError extends Error {
|
||||||
readonly taskPending: boolean
|
readonly taskPending: boolean
|
||||||
constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null, readonly taskId: string | null = null, taskPending = outcomeUnknown && Boolean(taskId)) { super(message); this.name = 'AiModelProtocolError'; this.taskPending = taskPending }
|
constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null, readonly taskId: string | null = null, taskPending = outcomeUnknown && Boolean(taskId)) { super(message); this.name = 'AiModelProtocolError'; this.taskPending = taskPending }
|
||||||
@ -366,7 +386,8 @@ export async function callAiModelProtocol(input: AiModelProtocolInput): Promise<
|
|||||||
if (input.resumeRequestId && (input.resumeTaskId || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input.resumeRequestId) || !['openai-chat', 'anthropic-messages', 'openai-image-generate', 'openai-image-edit'].includes(protocol.id))) throw new AiModelProtocolError('原请求编号无效或当前协议不能取回原响应', true, input.resumeRequestId)
|
if (input.resumeRequestId && (input.resumeTaskId || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input.resumeRequestId) || !['openai-chat', 'anthropic-messages', 'openai-image-generate', 'openai-image-edit'].includes(protocol.id))) throw new AiModelProtocolError('原请求编号无效或当前协议不能取回原响应', true, input.resumeRequestId)
|
||||||
let taskId = input.resumeTaskId ?? null, requestId: string | null = null, dispatched = Boolean(input.resumeTaskId)
|
let taskId = input.resumeTaskId ?? null, requestId: string | null = null, dispatched = Boolean(input.resumeTaskId)
|
||||||
const summary: Record<string, unknown> = { adapterVersion: AI_MODEL_ADAPTER_VERSION, protocol: protocol.id, routeId: protocol.routeId ?? null,
|
const summary: Record<string, unknown> = { adapterVersion: AI_MODEL_ADAPTER_VERSION, protocol: protocol.id, routeId: protocol.routeId ?? null,
|
||||||
...(protocol.referenceTransport ? { referenceTransport: protocol.referenceTransport, requestParameters: protocol.requestParameters } : {}),
|
...(['openai-image-edit', 'openai-image-generate', 'async-image-task'].includes(protocol.id) ? { requestParameters: aiModelImageRequestParameters(protocol) } : {}),
|
||||||
|
...(protocol.referenceTransport ? { referenceTransport: protocol.referenceTransport } : {}),
|
||||||
...(protocol.referenceContract ? { referenceContract: protocol.referenceContract } : {}) }
|
...(protocol.referenceContract ? { referenceContract: protocol.referenceContract } : {}) }
|
||||||
try {
|
try {
|
||||||
if (input.resumeRequestId) {
|
if (input.resumeRequestId) {
|
||||||
@ -402,9 +423,9 @@ export async function callAiModelProtocol(input: AiModelProtocolInput): Promise<
|
|||||||
else if (protocol.id === 'anthropic-messages') body = JSON.stringify({ model: input.modelId, messages: [{ role: 'user', content: [{ type: 'text', text: input.prompt }, ...images.map(image => ({ type: 'image', source: { type: 'base64', media_type: imageMimeType, data: image.toString('base64') } }))] }], max_tokens: maxTokens, stream: false })
|
else if (protocol.id === 'anthropic-messages') body = JSON.stringify({ model: input.modelId, messages: [{ role: 'user', content: [{ type: 'text', text: input.prompt }, ...images.map(image => ({ type: 'image', source: { type: 'base64', media_type: imageMimeType, data: image.toString('base64') } }))] }], max_tokens: maxTokens, stream: false })
|
||||||
else if (protocol.id === 'openai-image-edit' || protocol.referenceTransport === 'multipart') {
|
else if (protocol.id === 'openai-image-edit' || protocol.referenceTransport === 'multipart') {
|
||||||
body = new FormData(); body.set('model', input.modelId); body.set('prompt', input.prompt)
|
body = new FormData(); body.set('model', input.modelId); body.set('prompt', input.prompt)
|
||||||
for (const [field, value] of Object.entries(protocol.requestParameters ?? { n: 1, response_format: 'b64_json' })) body.set(field, String(value))
|
for (const [field, value] of Object.entries(aiModelImageRequestParameters(protocol))) body.set(field, String(value))
|
||||||
images.forEach((image, index) => (body as FormData).append(protocol.inputField!, new Blob([new Uint8Array(image)], { type: 'image/png' }), `reference-${index + 1}.png`))
|
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', ...(protocol.imageQuality ? { quality: protocol.imageQuality } : {}), ...protocol.requestParameters } : {}) })
|
} else body = JSON.stringify({ model: input.modelId, prompt: input.prompt, ...(images.length ? { [protocol.inputField!]: dataUris } : {}), ...aiModelImageRequestParameters(protocol) })
|
||||||
dispatched = true
|
dispatched = true
|
||||||
const response = await requestAiModelOperation(input.baseUrl, input.apiKey, protocol.path, body, input.idempotencyKey, protocol.headers, input.beforeRequest)
|
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
|
const parsed = await readJson(response, input.apiKey); requestId = parsed.requestId
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export interface AiModelCapabilities {
|
|||||||
allowedStages: AiModelStage[]
|
allowedStages: AiModelStage[]
|
||||||
source: 'upstream' | 'verified' | 'unknown'
|
source: 'upstream' | 'verified' | 'unknown'
|
||||||
reason: string
|
reason: string
|
||||||
protocols?: Partial<Record<AiModelStage, { id: string; path: string; scopeId?: string; routeId?: string; operationId?: string; maxImages?: number; referenceLimitSource?: 'verification' }>>
|
protocols?: Partial<Record<AiModelStage, { id: string; path: string; scopeId?: string; routeId?: string; operationId?: string; maxImages?: number; maxPromptLength?: number; referenceLimitSource?: 'verification'; inputField?: string; referenceTransport?: 'json-inline' | 'multipart'; requestParameters?: Record<string, string | number | boolean> }>>
|
||||||
combinedTextTest?: boolean
|
combinedTextTest?: boolean
|
||||||
}
|
}
|
||||||
export interface AiModelCatalogEntry {
|
export interface AiModelCatalogEntry {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user