From d2e793c6cfd9a2bb6fb1fa52281303fe950df55e Mon Sep 17 00:00:00 2001 From: shihao <3127647737@qq.com> Date: Mon, 20 Apr 2026 10:39:59 +0800 Subject: [PATCH] fix image model alias compatibility --- src/lib/api-config.ts | 36 ++++++- src/lib/generator-api.ts | 100 ++++++++++++++++-- .../api-config/model-selection-alias.test.ts | 88 +++++++++++++++ tests/unit/generator-api.test.ts | 31 +++++- 4 files changed, 240 insertions(+), 15 deletions(-) create mode 100644 tests/unit/api-config/model-selection-alias.test.ts diff --git a/src/lib/api-config.ts b/src/lib/api-config.ts index 19623ed..1a7d907 100644 --- a/src/lib/api-config.ts +++ b/src/lib/api-config.ts @@ -59,6 +59,12 @@ interface CustomProvider { type LlmProtocolType = 'responses' | 'chat-completions' +const IMAGE_MODEL_ID_ALIASES: Record = { + 'gemini-3-pro-image': 'gemini-3-pro-image-preview', + 'gemini-3.1-flash-image': 'gemini-3.1-flash-image-preview', + 'gemini-2.5-flash-image': 'gemini-2.5-flash-image-preview', +} + function normalizeProviderBaseUrl(providerId: string, rawBaseUrl?: string): string | undefined { const providerKey = getProviderKey(providerId) if (providerKey === 'minimax') { @@ -110,6 +116,23 @@ function isLlmProtocol(value: unknown): value is LlmProtocolType { return value === 'responses' || value === 'chat-completions' } +function resolveCompatibleImageModelIds(modelId: string): string[] { + const normalized = modelId.trim() + if (!normalized) return [] + + const candidates = new Set([normalized]) + const forwardAlias = IMAGE_MODEL_ID_ALIASES[normalized] + if (forwardAlias) { + candidates.add(forwardAlias) + } + for (const [legacyModelId, canonicalModelId] of Object.entries(IMAGE_MODEL_ID_ALIASES)) { + if (canonicalModelId === normalized) { + candidates.add(legacyModelId) + } + } + return Array.from(candidates) +} + function assertModelKey(value: string, field: string): { provider: string; modelId: string; modelKey: string } { const parsed = parseModelKeyStrict(value) if (!parsed) { @@ -303,9 +326,16 @@ async function readUserConfig(userId: string): Promise<{ models: CustomModel[]; } } -function findModelByKey(models: CustomModel[], modelKey: string): CustomModel | null { +function findModelByKey(models: CustomModel[], modelKey: string, mediaType?: ModelMediaType): CustomModel | null { const parsed = assertModelKey(modelKey, 'model') - return models.find((model) => model.modelId === parsed.modelId && model.provider === parsed.provider) || null + const exactMatch = models.find((model) => model.modelId === parsed.modelId && model.provider === parsed.provider) || null + if (exactMatch) return exactMatch + + if (mediaType !== 'image') return null + const compatibleIds = resolveCompatibleImageModelIds(parsed.modelId) + if (compatibleIds.length <= 1) return null + + return models.find((model) => model.provider === parsed.provider && compatibleIds.includes(model.modelId)) || null } /** @@ -328,7 +358,7 @@ export async function resolveModelSelection( const parsed = assertModelKey(model, `${mediaType} model`) const models = await getModelsByType(userId, mediaType) - const exact = findModelByKey(models, parsed.modelKey) + const exact = findModelByKey(models, parsed.modelKey, mediaType) if (!exact) { throw new Error(`MODEL_NOT_FOUND: ${parsed.modelKey} is not enabled for ${mediaType}`) } diff --git a/src/lib/generator-api.ts b/src/lib/generator-api.ts index 3b0bc95..c5bebce 100644 --- a/src/lib/generator-api.ts +++ b/src/lib/generator-api.ts @@ -29,10 +29,90 @@ const OPENAI_COMPAT_IMAGE_MODEL_ID_ALIASES: Record = { 'gemini-2.5-flash-image': 'gemini-2.5-flash-image-preview', } -function normalizeOpenAICompatImageModelId(modelId: string): string { +function resolveOpenAICompatImageModelIdCandidates(modelId: string): string[] { const normalized = modelId.trim() - if (!normalized) return normalized - return OPENAI_COMPAT_IMAGE_MODEL_ID_ALIASES[normalized] || normalized + if (!normalized) return [] + + const candidates = new Set([normalized]) + const forwardAlias = OPENAI_COMPAT_IMAGE_MODEL_ID_ALIASES[normalized] + if (forwardAlias) { + candidates.add(forwardAlias) + } + for (const [legacyModelId, canonicalModelId] of Object.entries(OPENAI_COMPAT_IMAGE_MODEL_ID_ALIASES)) { + if (canonicalModelId === normalized) { + candidates.add(legacyModelId) + } + } + return Array.from(candidates) +} + +function shouldRetryOpenAICompatImageModel(error: unknown): boolean { + const message = (error instanceof Error ? error.message : String(error || '')).trim().toLowerCase() + if (!message) return false + return ( + message === 'openai_error' + || message.includes('invalid model') + || message.includes('unknown model') + || message.includes('unrecognized model') + || message.includes('unsupported model') + || message.includes('model not supported') + || message.includes('model_not_found') + || message.includes('model not found') + || message.includes('param=model') + || message.includes('parameter=model') + ) +} + +async function generateOpenAICompatImageWithFallback(input: { + userId: string + providerId: string + modelId: string + modelKey: string + prompt: string + referenceImages?: string[] + options: Record + compatTemplate?: unknown +}): Promise { + const modelIdCandidates = resolveOpenAICompatImageModelIdCandidates(input.modelId) + let lastError: unknown = null + + for (let index = 0; index < modelIdCandidates.length; index += 1) { + const candidateModelId = modelIdCandidates[index] + try { + if (input.compatTemplate) { + return await generateImageViaOpenAICompatTemplate({ + userId: input.userId, + providerId: input.providerId, + modelId: candidateModelId, + modelKey: input.modelKey, + prompt: input.prompt, + referenceImages: input.referenceImages, + options: input.options, + profile: 'openai-compatible', + template: input.compatTemplate as Parameters[0]['template'], + }) + } + + return await generateImageViaOpenAICompat({ + userId: input.userId, + providerId: input.providerId, + modelId: candidateModelId, + prompt: input.prompt, + referenceImages: input.referenceImages, + options: input.options, + profile: 'openai-compatible', + }) + } catch (error) { + lastError = error + const hasMoreCandidates = index < modelIdCandidates.length - 1 + if (!hasMoreCandidates || !shouldRetryOpenAICompatImageModel(error)) { + throw error + } + _ulogInfo(`[generateImage] retrying openai-compatible image model fallback: ${input.modelId} -> ${modelIdCandidates[index + 1]}`) + } + } + + throw lastError instanceof Error ? lastError : new Error('OPENAI_COMPAT_IMAGE_GENERATION_FAILED') } /** @@ -117,15 +197,14 @@ export async function generateImage( const { referenceImages, ...generatorOptions } = options || {} if (gatewayRoute === 'openai-compat') { const compatTemplate = selection.compatMediaTemplate - const normalizedCompatModelId = normalizeOpenAICompatImageModelId(selection.modelId) if (providerKey === 'openai-compatible' && !compatTemplate) { throw new Error(`MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED: ${selection.modelKey}`) } if (compatTemplate) { - return await generateImageViaOpenAICompatTemplate({ + return await generateOpenAICompatImageWithFallback({ userId, providerId: selection.provider, - modelId: normalizedCompatModelId, + modelId: selection.modelId, modelKey: selection.modelKey, prompt, referenceImages, @@ -135,8 +214,7 @@ export async function generateImage( modelId: selection.modelId, modelKey: selection.modelKey, }, - profile: 'openai-compatible', - template: compatTemplate, + compatTemplate, }) } @@ -151,10 +229,11 @@ export async function generateImage( delete openaiCompatOptions.aspectRatio } - return await generateImageViaOpenAICompat({ + return await generateOpenAICompatImageWithFallback({ userId, providerId: selection.provider, - modelId: normalizedCompatModelId, + modelId: selection.modelId, + modelKey: selection.modelKey, prompt, referenceImages, options: { @@ -163,7 +242,6 @@ export async function generateImage( modelId: selection.modelId, modelKey: selection.modelKey, }, - profile: 'openai-compatible', }) } diff --git a/tests/unit/api-config/model-selection-alias.test.ts b/tests/unit/api-config/model-selection-alias.test.ts new file mode 100644 index 0000000..ff2a2f1 --- /dev/null +++ b/tests/unit/api-config/model-selection-alias.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const prismaMock = vi.hoisted(() => ({ + userPreference: { + findUnique: vi.fn<(...args: unknown[]) => Promise<{ customModels: string; customProviders: string | null } | null>>(async () => null), + }, +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: prismaMock, +})) + +import { resolveModelSelection } from '@/lib/api-config' + +const compatImageTemplate = { + version: 1 as const, + mediaType: 'image' as const, + mode: 'sync' as const, + create: { + method: 'POST' as const, + path: '/images/generations', + contentType: 'application/json' as const, + bodyTemplate: { + model: '{{model}}', + prompt: '{{prompt}}', + }, + }, + response: { + outputUrlPath: '$.data[0].url', + errorPath: '$.error.message', + }, +} + +describe('api-config image model alias resolution', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('matches a preview image model when the caller still uses the legacy model id', async () => { + prismaMock.userPreference.findUnique.mockResolvedValueOnce({ + customProviders: null, + customModels: JSON.stringify([ + { + modelId: 'gemini-3.1-flash-image-preview', + modelKey: 'openai-compatible:oa-1::gemini-3.1-flash-image-preview', + name: 'Nano Banana 2', + type: 'image', + provider: 'openai-compatible:oa-1', + compatMediaTemplate: compatImageTemplate, + }, + ]), + }) + + const selection = await resolveModelSelection( + 'user-1', + 'openai-compatible:oa-1::gemini-3.1-flash-image', + 'image', + ) + + expect(selection.modelId).toBe('gemini-3.1-flash-image-preview') + expect(selection.modelKey).toBe('openai-compatible:oa-1::gemini-3.1-flash-image-preview') + }) + + it('matches a legacy image model when the caller already uses the preview model id', async () => { + prismaMock.userPreference.findUnique.mockResolvedValueOnce({ + customProviders: null, + customModels: JSON.stringify([ + { + modelId: 'gemini-3.1-flash-image', + modelKey: 'openai-compatible:oa-1::gemini-3.1-flash-image', + name: 'Nano Banana 2', + type: 'image', + provider: 'openai-compatible:oa-1', + compatMediaTemplate: compatImageTemplate, + }, + ]), + }) + + const selection = await resolveModelSelection( + 'user-1', + 'openai-compatible:oa-1::gemini-3.1-flash-image-preview', + 'image', + ) + + expect(selection.modelId).toBe('gemini-3.1-flash-image') + expect(selection.modelKey).toBe('openai-compatible:oa-1::gemini-3.1-flash-image') + }) +}) diff --git a/tests/unit/generator-api.test.ts b/tests/unit/generator-api.test.ts index 4357d59..1e5000e 100644 --- a/tests/unit/generator-api.test.ts +++ b/tests/unit/generator-api.test.ts @@ -118,7 +118,7 @@ describe('generator-api gateway routing', () => { expect(result).toEqual({ success: true, imageUrl: 'compat-template-image' }) }) - it('normalizes legacy openai-compatible gemini image model ids before template gateway call', async () => { + it('prefers the resolved openai-compatible image model id before trying aliases', async () => { resolveModelSelectionMock.mockResolvedValueOnce({ provider: 'openai-compatible:oa-1', modelId: 'gemini-3.1-flash-image', @@ -137,6 +137,35 @@ describe('generator-api gateway routing', () => { await generateImage('user-1', 'openai-compatible:oa-1::gemini-3.1-flash-image', 'draw hero') expect(generateImageViaOpenAICompatTemplateMock).toHaveBeenCalledWith(expect.objectContaining({ + modelId: 'gemini-3.1-flash-image', + })) + }) + + it('retries openai-compatible image generation with a compatible alias after a model-id rejection', async () => { + resolveModelSelectionMock.mockResolvedValueOnce({ + provider: 'openai-compatible:oa-1', + modelId: 'gemini-3.1-flash-image', + modelKey: 'openai-compatible:oa-1::gemini-3.1-flash-image', + mediaType: 'image', + compatMediaTemplate: { + version: 1, + mediaType: 'image', + mode: 'sync', + create: { method: 'POST', path: '/v1/images/generations' }, + response: { outputUrlPath: '$.data[0].url' }, + }, + }) + resolveModelGatewayRouteMock.mockReturnValueOnce('openai-compat') + generateImageViaOpenAICompatTemplateMock + .mockRejectedValueOnce(new Error('Template request failed with status 400: invalid model')) + .mockResolvedValueOnce({ success: true, imageUrl: 'compat-template-image' }) + + await generateImage('user-1', 'openai-compatible:oa-1::gemini-3.1-flash-image', 'draw hero') + + expect(generateImageViaOpenAICompatTemplateMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + modelId: 'gemini-3.1-flash-image', + })) + expect(generateImageViaOpenAICompatTemplateMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ modelId: 'gemini-3.1-flash-image-preview', })) })