fix image model alias compatibility
Some checks failed
Build & Push Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build & Push Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
@@ -59,6 +59,12 @@ interface CustomProvider {
|
|||||||
|
|
||||||
type LlmProtocolType = 'responses' | 'chat-completions'
|
type LlmProtocolType = 'responses' | 'chat-completions'
|
||||||
|
|
||||||
|
const IMAGE_MODEL_ID_ALIASES: Record<string, string> = {
|
||||||
|
'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 {
|
function normalizeProviderBaseUrl(providerId: string, rawBaseUrl?: string): string | undefined {
|
||||||
const providerKey = getProviderKey(providerId)
|
const providerKey = getProviderKey(providerId)
|
||||||
if (providerKey === 'minimax') {
|
if (providerKey === 'minimax') {
|
||||||
@@ -110,6 +116,23 @@ function isLlmProtocol(value: unknown): value is LlmProtocolType {
|
|||||||
return value === 'responses' || value === 'chat-completions'
|
return value === 'responses' || value === 'chat-completions'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCompatibleImageModelIds(modelId: string): string[] {
|
||||||
|
const normalized = modelId.trim()
|
||||||
|
if (!normalized) return []
|
||||||
|
|
||||||
|
const candidates = new Set<string>([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 } {
|
function assertModelKey(value: string, field: string): { provider: string; modelId: string; modelKey: string } {
|
||||||
const parsed = parseModelKeyStrict(value)
|
const parsed = parseModelKeyStrict(value)
|
||||||
if (!parsed) {
|
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')
|
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 parsed = assertModelKey(model, `${mediaType} model`)
|
||||||
const models = await getModelsByType(userId, mediaType)
|
const models = await getModelsByType(userId, mediaType)
|
||||||
|
|
||||||
const exact = findModelByKey(models, parsed.modelKey)
|
const exact = findModelByKey(models, parsed.modelKey, mediaType)
|
||||||
if (!exact) {
|
if (!exact) {
|
||||||
throw new Error(`MODEL_NOT_FOUND: ${parsed.modelKey} is not enabled for ${mediaType}`)
|
throw new Error(`MODEL_NOT_FOUND: ${parsed.modelKey} is not enabled for ${mediaType}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,90 @@ const OPENAI_COMPAT_IMAGE_MODEL_ID_ALIASES: Record<string, string> = {
|
|||||||
'gemini-2.5-flash-image': 'gemini-2.5-flash-image-preview',
|
'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()
|
const normalized = modelId.trim()
|
||||||
if (!normalized) return normalized
|
if (!normalized) return []
|
||||||
return OPENAI_COMPAT_IMAGE_MODEL_ID_ALIASES[normalized] || normalized
|
|
||||||
|
const candidates = new Set<string>([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<string, unknown>
|
||||||
|
compatTemplate?: unknown
|
||||||
|
}): Promise<GenerateResult> {
|
||||||
|
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<typeof generateImageViaOpenAICompatTemplate>[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 || {}
|
const { referenceImages, ...generatorOptions } = options || {}
|
||||||
if (gatewayRoute === 'openai-compat') {
|
if (gatewayRoute === 'openai-compat') {
|
||||||
const compatTemplate = selection.compatMediaTemplate
|
const compatTemplate = selection.compatMediaTemplate
|
||||||
const normalizedCompatModelId = normalizeOpenAICompatImageModelId(selection.modelId)
|
|
||||||
if (providerKey === 'openai-compatible' && !compatTemplate) {
|
if (providerKey === 'openai-compatible' && !compatTemplate) {
|
||||||
throw new Error(`MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED: ${selection.modelKey}`)
|
throw new Error(`MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED: ${selection.modelKey}`)
|
||||||
}
|
}
|
||||||
if (compatTemplate) {
|
if (compatTemplate) {
|
||||||
return await generateImageViaOpenAICompatTemplate({
|
return await generateOpenAICompatImageWithFallback({
|
||||||
userId,
|
userId,
|
||||||
providerId: selection.provider,
|
providerId: selection.provider,
|
||||||
modelId: normalizedCompatModelId,
|
modelId: selection.modelId,
|
||||||
modelKey: selection.modelKey,
|
modelKey: selection.modelKey,
|
||||||
prompt,
|
prompt,
|
||||||
referenceImages,
|
referenceImages,
|
||||||
@@ -135,8 +214,7 @@ export async function generateImage(
|
|||||||
modelId: selection.modelId,
|
modelId: selection.modelId,
|
||||||
modelKey: selection.modelKey,
|
modelKey: selection.modelKey,
|
||||||
},
|
},
|
||||||
profile: 'openai-compatible',
|
compatTemplate,
|
||||||
template: compatTemplate,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,10 +229,11 @@ export async function generateImage(
|
|||||||
delete openaiCompatOptions.aspectRatio
|
delete openaiCompatOptions.aspectRatio
|
||||||
}
|
}
|
||||||
|
|
||||||
return await generateImageViaOpenAICompat({
|
return await generateOpenAICompatImageWithFallback({
|
||||||
userId,
|
userId,
|
||||||
providerId: selection.provider,
|
providerId: selection.provider,
|
||||||
modelId: normalizedCompatModelId,
|
modelId: selection.modelId,
|
||||||
|
modelKey: selection.modelKey,
|
||||||
prompt,
|
prompt,
|
||||||
referenceImages,
|
referenceImages,
|
||||||
options: {
|
options: {
|
||||||
@@ -163,7 +242,6 @@ export async function generateImage(
|
|||||||
modelId: selection.modelId,
|
modelId: selection.modelId,
|
||||||
modelKey: selection.modelKey,
|
modelKey: selection.modelKey,
|
||||||
},
|
},
|
||||||
profile: 'openai-compatible',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
tests/unit/api-config/model-selection-alias.test.ts
Normal file
88
tests/unit/api-config/model-selection-alias.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -118,7 +118,7 @@ describe('generator-api gateway routing', () => {
|
|||||||
expect(result).toEqual({ success: true, imageUrl: 'compat-template-image' })
|
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({
|
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||||
provider: 'openai-compatible:oa-1',
|
provider: 'openai-compatible:oa-1',
|
||||||
modelId: 'gemini-3.1-flash-image',
|
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')
|
await generateImage('user-1', 'openai-compatible:oa-1::gemini-3.1-flash-image', 'draw hero')
|
||||||
|
|
||||||
expect(generateImageViaOpenAICompatTemplateMock).toHaveBeenCalledWith(expect.objectContaining({
|
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',
|
modelId: 'gemini-3.1-flash-image-preview',
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user