import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildMockRequest } from '../../../helpers/request' import { installAuthMocks, mockAuthenticated, resetAuthMockState, } from '../../../helpers/auth' type UserPreferenceSnapshot = { customProviders: string | null customModels: string | null analysisModel?: string | null characterModel?: string | null locationModel?: string | null storyboardModel?: string | null editModel?: string | null videoModel?: string | null audioModel?: string | null lipSyncModel?: string | null capabilityDefaults?: string | null analysisConcurrency?: number | null imageConcurrency?: number | null videoConcurrency?: number | null } type SavedProvider = { id: string name: string baseUrl?: string apiKey?: string hidden?: boolean apiMode?: 'gemini-sdk' | 'openai-official' gatewayRoute?: 'official' | 'openai-compat' } const prismaMock = vi.hoisted(() => ({ user: { findUnique: vi.fn<(...args: unknown[]) => Promise<{ id: string } | null>>(), }, userPreference: { findUnique: vi.fn<(...args: unknown[]) => Promise>(), upsert: vi.fn<(...args: unknown[]) => Promise>(), }, })) const encryptApiKeyMock = vi.hoisted(() => vi.fn((value: string) => `enc:${value}`)) const decryptApiKeyMock = vi.hoisted(() => vi.fn((value: string) => value.replace(/^enc:/, ''))) const getBillingModeMock = vi.hoisted(() => vi.fn(async () => 'OFF')) vi.mock('@/lib/prisma', () => ({ prisma: prismaMock, })) vi.mock('@/lib/crypto-utils', () => ({ encryptApiKey: encryptApiKeyMock, decryptApiKey: decryptApiKeyMock, })) vi.mock('@/lib/billing/mode', () => ({ getBillingMode: getBillingModeMock, })) const routeContext = { params: Promise.resolve({}) } function readSavedProvidersFromUpsert(): SavedProvider[] { const firstCall = prismaMock.userPreference.upsert.mock.calls[0] if (!firstCall) { throw new Error('expected prisma.userPreference.upsert to be called at least once') } const payload = firstCall[0] as { update?: { customProviders?: unknown } } const rawProviders = payload.update?.customProviders if (typeof rawProviders !== 'string') { throw new Error('expected update.customProviders to be a JSON string') } const parsed = JSON.parse(rawProviders) as unknown if (!Array.isArray(parsed)) { throw new Error('expected update.customProviders to parse as an array') } return parsed as SavedProvider[] } function readSavedModelsFromUpsert(): Array> { const firstCall = prismaMock.userPreference.upsert.mock.calls[0] if (!firstCall) { throw new Error('expected prisma.userPreference.upsert to be called at least once') } const payload = firstCall[0] as { update?: { customModels?: unknown } } const rawModels = payload.update?.customModels if (typeof rawModels !== 'string') { throw new Error('expected update.customModels to be a JSON string') } const parsed = JSON.parse(rawModels) as unknown if (!Array.isArray(parsed)) { throw new Error('expected update.customModels to parse as an array') } return parsed as Array> } describe('api specific - user api-config PUT provider uniqueness', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() resetAuthMockState() prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1' }) prismaMock.userPreference.findUnique.mockResolvedValue({ customProviders: null, customModels: null, }) prismaMock.userPreference.upsert.mockResolvedValue({ id: 'pref-1' }) getBillingModeMock.mockResolvedValue('OFF') }) it('returns unauthorized when session user no longer exists before saving providers', async () => { installAuthMocks() mockAuthenticated('user-missing') prismaMock.user.findUnique.mockResolvedValueOnce(null) const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI A', baseUrl: 'https://oa-a.test', apiKey: 'oa-key-a' }, ], }, }) const res = await route.PUT(req, routeContext) const body = await res.json() as { error?: { code?: string message?: string } } expect(res.status).toBe(401) expect(body.error?.code).toBe('UNAUTHORIZED') expect(body.error?.message).toBe('登录状态已失效,请重新登录') expect(prismaMock.userPreference.findUnique).not.toHaveBeenCalled() expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('allows multiple providers with the same api type when provider ids differ', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI A', baseUrl: 'https://oa-a.test', apiKey: 'oa-key-a' }, { id: 'openai-compatible:oa-2', name: 'OpenAI B', baseUrl: 'https://oa-b.test', apiKey: 'oa-key-b' }, { id: 'gemini-compatible:gm-1', name: 'Gemini A', baseUrl: 'https://gm-a.test', apiKey: 'gm-key-a' }, { id: 'gemini-compatible:gm-2', name: 'Gemini B', baseUrl: 'https://gm-b.test', apiKey: 'gm-key-b' }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) expect(prismaMock.userPreference.upsert).toHaveBeenCalledTimes(1) const savedProviders = readSavedProvidersFromUpsert() expect(savedProviders.map((provider) => provider.id)).toEqual([ 'openai-compatible:oa-1', 'openai-compatible:oa-2', 'gemini-compatible:gm-1', 'gemini-compatible:gm-2', ]) }) it('regression: preserves reordered providers array order when persisting', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'google', name: 'Google AI Studio', apiKey: 'google-key' }, { id: 'ark', name: 'Volcengine Ark', apiKey: 'ark-key' }, { id: 'openai-compatible:oa-2', name: 'OpenAI B', baseUrl: 'https://oa-b.test', apiKey: 'oa-key-b' }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) expect(prismaMock.userPreference.upsert).toHaveBeenCalledTimes(1) const savedProviders = readSavedProvidersFromUpsert() expect(savedProviders.map((provider) => provider.id)).toEqual([ 'google', 'ark', 'openai-compatible:oa-2', ]) }) it('persists provider hidden flag', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'google', name: 'Google AI Studio', apiKey: 'google-key', hidden: true }, { id: 'ark', name: 'Volcengine Ark', apiKey: 'ark-key', hidden: false }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedProviders = readSavedProvidersFromUpsert() const googleProvider = savedProviders.find((provider) => provider.id === 'google') const arkProvider = savedProviders.find((provider) => provider.id === 'ark') expect(googleProvider?.hidden).toBe(true) expect(arkProvider?.hidden).toBe(false) }) it('rejects non-boolean provider hidden flag', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'google', name: 'Google AI Studio', apiKey: 'google-key', hidden: 'yes' }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('pins minimax provider baseUrl to official endpoint when baseUrl is omitted', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'minimax', name: 'MiniMax Hailuo', apiKey: 'mm-key' }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedProviders = readSavedProvidersFromUpsert() expect(savedProviders).toHaveLength(1) expect(savedProviders[0]).toMatchObject({ id: 'minimax', baseUrl: 'https://api.minimaxi.com/v1', }) }) it('rejects minimax provider custom baseUrl', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'minimax', name: 'MiniMax Hailuo', baseUrl: 'https://custom.minimax.proxy/v1', apiKey: 'mm-key' }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('keeps new provider apiKey empty instead of reusing another same-type provider apiKey', async () => { installAuthMocks() mockAuthenticated('user-1') prismaMock.userPreference.findUnique.mockResolvedValue({ customProviders: JSON.stringify([ { id: 'openai-compatible:old', name: 'Old', baseUrl: 'https://old.test', apiKey: 'enc:legacy', }, ] satisfies SavedProvider[]), customModels: null, }) const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:old', name: 'Old', baseUrl: 'https://old.test' }, { id: 'openai-compatible:new', name: 'New', baseUrl: 'https://new.test' }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedProviders = readSavedProvidersFromUpsert() const oldProvider = savedProviders.find((provider) => provider.id === 'openai-compatible:old') const newProvider = savedProviders.find((provider) => provider.id === 'openai-compatible:new') expect(oldProvider?.apiKey).toBe('enc:legacy') expect(newProvider).toBeDefined() expect(Object.prototype.hasOwnProperty.call(newProvider as object, 'apiKey')).toBe(false) }) it('rejects duplicated provider ids', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:dup', name: 'Provider A', baseUrl: 'https://a.test', apiKey: 'key-a' }, { id: 'openai-compatible:dup', name: 'Provider B', baseUrl: 'https://b.test', apiKey: 'key-b' }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('rejects duplicated provider ids even when only case differs', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'OpenAI-Compatible:CaseDup', name: 'Provider A', baseUrl: 'https://a.test', apiKey: 'key-a' }, { id: 'openai-compatible:casedup', name: 'Provider B', baseUrl: 'https://b.test', apiKey: 'key-b' }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('requires explicit provider id on models when multiple same-type providers exist', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI A', baseUrl: 'https://oa-a.test', apiKey: 'oa-key-a' }, { id: 'openai-compatible:oa-2', name: 'OpenAI B', baseUrl: 'https://oa-b.test', apiKey: 'oa-key-b' }, ], models: [ { type: 'llm', provider: 'openai-compatible', modelId: 'gpt-4.1', modelKey: 'openai-compatible::gpt-4.1', name: 'GPT 4.1', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('accepts openai-compatible provider image/video models', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'oa-key', apiMode: 'openai-official', }, ], models: [ { type: 'image', provider: 'openai-compatible:oa-1', modelId: 'gpt-image-1', modelKey: 'openai-compatible:oa-1::gpt-image-1', name: 'Image Model', }, { type: 'video', provider: 'openai-compatible:oa-1', modelId: 'sora-2', modelKey: 'openai-compatible:oa-1::sora-2', name: 'Video Model', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) expect(prismaMock.userPreference.upsert).toHaveBeenCalledTimes(1) }) it('requires llmProtocol when adding a new openai-compatible llm model', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'oa-key' }, ], models: [ { type: 'llm', provider: 'openai-compatible:oa-1', modelId: 'gpt-4.1-mini', modelKey: 'openai-compatible:oa-1::gpt-4.1-mini', name: 'GPT 4.1 Mini', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('persists llmProtocol for openai-compatible llm models', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'oa-key' }, ], models: [ { type: 'llm', provider: 'openai-compatible:oa-1', modelId: 'gpt-4.1-mini', modelKey: 'openai-compatible:oa-1::gpt-4.1-mini', name: 'GPT 4.1 Mini', llmProtocol: 'responses', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedModels = readSavedModelsFromUpsert() expect(savedModels).toHaveLength(1) expect(savedModels[0]?.llmProtocol).toBe('responses') expect(typeof savedModels[0]?.llmProtocolCheckedAt).toBe('string') }) it('rejects llmProtocol on non-openai-compatible or non-llm models', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'gemini-compatible:gm-1', name: 'Gemini Compat', baseUrl: 'https://gm.test', apiKey: 'gm-key' }, ], models: [ { type: 'llm', provider: 'gemini-compatible:gm-1', modelId: 'gemini-3-pro-preview', modelKey: 'gemini-compatible:gm-1::gemini-3-pro-preview', name: 'Gemini 3 Pro', llmProtocol: 'chat-completions', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('backfills historical openai-compatible llm models missing llmProtocol during PUT', async () => { installAuthMocks() mockAuthenticated('user-1') prismaMock.userPreference.findUnique.mockResolvedValue({ customProviders: JSON.stringify([ { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'enc:oa-key' }, ]), customModels: JSON.stringify([ { type: 'llm', provider: 'openai-compatible:oa-1', modelId: 'gpt-4.1-mini', modelKey: 'openai-compatible:oa-1::gpt-4.1-mini', name: 'GPT 4.1 Mini', }, ]), }) const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1' }, ], models: [ { type: 'llm', provider: 'openai-compatible:oa-1', modelId: 'gpt-4.1-mini', modelKey: 'openai-compatible:oa-1::gpt-4.1-mini', name: 'GPT 4.1 Mini', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedModels = readSavedModelsFromUpsert() expect(savedModels).toHaveLength(1) expect(savedModels[0]?.llmProtocol).toBe('chat-completions') expect(typeof savedModels[0]?.llmProtocolCheckedAt).toBe('string') }) it('rejects invalid custom pricing structure', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'oa-key' }, ], models: [ { type: 'image', provider: 'openai-compatible:oa-1', modelId: 'gpt-image-1', modelKey: 'openai-compatible:oa-1::gpt-image-1', name: 'Image Model', customPricing: { image: { basePrice: -1, }, }, }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('rejects custom pricing option mappings with unsupported capability values', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'ark', name: 'Volcengine Ark', apiKey: 'ark-key' }, ], models: [ { type: 'video', provider: 'ark', modelId: 'doubao-seedance-1-0-pro-fast-251015', modelKey: 'ark::doubao-seedance-1-0-pro-fast-251015', name: 'Ark Video', customPricing: { video: { basePrice: 0.5, optionPrices: { resolution: { '2k': 1.2, }, }, }, }, }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('maps legacy customPricing input/output to llm pricing on GET', async () => { installAuthMocks() mockAuthenticated('user-1') prismaMock.userPreference.findUnique.mockResolvedValue({ customProviders: JSON.stringify([ { id: 'openai-compatible:oa-1', name: 'OpenAI', baseUrl: 'https://oa.test/v1', apiKey: 'enc:key' }, ]), customModels: JSON.stringify([ { type: 'llm', provider: 'openai-compatible:oa-1', modelId: 'gpt-4.1-mini', modelKey: 'openai-compatible:oa-1::gpt-4.1-mini', name: 'GPT', customPricing: { input: 2.5, output: 5.5, }, }, ]), }) const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'GET', }) const res = await route.GET(req, routeContext) expect(res.status).toBe(200) const json = await res.json() as { models?: Array<{ customPricing?: { llm?: { inputPerMillion?: number; outputPerMillion?: number } } }> } const model = Array.isArray(json.models) ? json.models[0] : null expect(model?.customPricing?.llm?.inputPerMillion).toBe(2.5) expect(model?.customPricing?.llm?.outputPerMillion).toBe(5.5) }) it('defaults gemini-compatible provider to official route when apiMode is gemini-sdk', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'gemini-compatible:gm-1', name: 'Gemini Official Mode', baseUrl: 'https://gm.test', apiKey: 'gm-key', apiMode: 'gemini-sdk', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedProviders = readSavedProvidersFromUpsert() expect(savedProviders).toHaveLength(1) expect(savedProviders[0]?.gatewayRoute).toBe('official') expect(savedProviders[0]?.apiMode).toBe('gemini-sdk') }) it('rejects gemini-compatible provider when apiMode is openai-official', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'gemini-compatible:gm-1', name: 'Gemini OpenAI Mode', baseUrl: 'https://gm.test', apiKey: 'gm-key', apiMode: 'openai-official', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('rejects legacy litellm gatewayRoute value', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'oa-key', gatewayRoute: 'litellm', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('forces openai-compatible provider to openai-compat route', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'oa-key', apiMode: 'openai-official', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedProviders = readSavedProvidersFromUpsert() expect(savedProviders).toHaveLength(1) expect(savedProviders[0]?.gatewayRoute).toBe('openai-compat') }) it('bailian provider always persists gatewayRoute as official', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'bailian', name: 'Alibaba Bailian', apiKey: 'bl-key', gatewayRoute: 'official', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedProviders = readSavedProvidersFromUpsert() expect(savedProviders[0]?.gatewayRoute).toBe('official') }) it('accepts bailian lipsync models and persists them', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'bailian', name: 'Alibaba Bailian', apiKey: 'bl-key', }, ], models: [ { type: 'lipsync', provider: 'bailian', modelId: 'videoretalk', modelKey: 'bailian::videoretalk', name: 'VideoRetalk Lip Sync', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedModels = readSavedModelsFromUpsert() expect(savedModels).toHaveLength(1) expect(savedModels[0]).toMatchObject({ type: 'lipsync', provider: 'bailian', modelId: 'videoretalk', modelKey: 'bailian::videoretalk', }) }) it('siliconflow provider rejects litellm gatewayRoute', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'siliconflow', name: 'SiliconFlow', apiKey: 'sf-key', gatewayRoute: 'litellm', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('allows bailian default model in ENFORCE mode without built-in pricing entry', async () => { installAuthMocks() mockAuthenticated('user-1') getBillingModeMock.mockResolvedValue('ENFORCE') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'bailian', name: 'Alibaba Bailian', apiKey: 'bl-key' }, ], models: [ { type: 'llm', provider: 'bailian', modelId: 'qwen3.5-flash', modelKey: 'bailian::qwen3.5-flash', name: 'Qwen 3.5 Flash', }, ], defaultModels: { analysisModel: 'bailian::qwen3.5-flash', }, }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const firstCall = prismaMock.userPreference.upsert.mock.calls[0]?.[0] as { update?: { analysisModel?: unknown } } expect(firstCall?.update?.analysisModel).toBe('bailian::qwen3.5-flash') }) it('allows bailian lipsync model in ENFORCE mode', async () => { installAuthMocks() mockAuthenticated('user-1') getBillingModeMock.mockResolvedValue('ENFORCE') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'bailian', name: 'Alibaba Bailian', apiKey: 'bl-key' }, ], models: [ { type: 'lipsync', provider: 'bailian', modelId: 'videoretalk', modelKey: 'bailian::videoretalk', name: 'VideoRetalk Lip Sync', }, ], defaultModels: { lipSyncModel: 'bailian::videoretalk', }, }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const firstCall = prismaMock.userPreference.upsert.mock.calls[0]?.[0] as { update?: { lipSyncModel?: unknown } } expect(firstCall?.update?.lipSyncModel).toBe('bailian::videoretalk') }) it('saves default audio model in user preference', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { defaultModels: { audioModel: 'bailian::qwen3-tts-vd-2026-01-26', }, }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const firstCall = prismaMock.userPreference.upsert.mock.calls[0]?.[0] as { update?: { audioModel?: unknown } } expect(firstCall?.update?.audioModel).toBe('bailian::qwen3-tts-vd-2026-01-26') }) it('keeps bailian model and default model in GET sanitize flow under ENFORCE mode', async () => { installAuthMocks() mockAuthenticated('user-1') getBillingModeMock.mockResolvedValue('ENFORCE') prismaMock.userPreference.findUnique.mockResolvedValue({ customProviders: JSON.stringify([ { id: 'bailian', name: 'Alibaba Bailian', apiKey: 'enc:bl-key', gatewayRoute: 'official' }, ]), customModels: JSON.stringify([ { type: 'llm', provider: 'bailian', modelId: 'qwen3.5-flash', modelKey: 'bailian::qwen3.5-flash', name: 'Qwen 3.5 Flash', }, ]), analysisModel: 'bailian::qwen3.5-flash', }) const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'GET', }) const res = await route.GET(req, routeContext) expect(res.status).toBe(200) const json = await res.json() as { defaultModels?: { analysisModel?: string } models?: Array<{ modelKey?: string }> } expect(json.defaultModels?.analysisModel).toBe('bailian::qwen3.5-flash') expect(json.models?.some((model) => model.modelKey === 'bailian::qwen3.5-flash')).toBe(true) }) it('accepts workflow concurrency payload and returns normalized values on GET', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const putReq = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { workflowConcurrency: { analysis: 3, image: 4, video: 6, }, }, }) const putRes = await route.PUT(putReq, routeContext) expect(putRes.status).toBe(200) expect(prismaMock.userPreference.upsert).toHaveBeenCalledTimes(1) const upsertPayload = prismaMock.userPreference.upsert.mock.calls[0]?.[0] as { update: { analysisConcurrency?: number imageConcurrency?: number videoConcurrency?: number } } expect(upsertPayload.update.analysisConcurrency).toBe(3) expect(upsertPayload.update.imageConcurrency).toBe(4) expect(upsertPayload.update.videoConcurrency).toBe(6) prismaMock.userPreference.findUnique.mockResolvedValueOnce({ customProviders: null, customModels: null, analysisConcurrency: 5, imageConcurrency: 7, videoConcurrency: 9, }) const getReq = buildMockRequest({ path: '/api/user/api-config', method: 'GET', }) const getRes = await route.GET(getReq, routeContext) expect(getRes.status).toBe(200) const payload = await getRes.json() as { workflowConcurrency?: { analysis: number image: number video: number } } expect(payload.workflowConcurrency).toEqual({ analysis: 5, image: 7, video: 9, }) }) it('migrated bailian provider id is accepted and qwen is rejected', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const acceptedReq = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'bailian', name: 'Alibaba Bailian', apiKey: 'bl-key' }, ], }, }) const acceptedRes = await route.PUT(acceptedReq, routeContext) expect(acceptedRes.status).toBe(200) vi.clearAllMocks() prismaMock.userPreference.findUnique.mockResolvedValue({ customProviders: null, customModels: null, }) const rejectedReq = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'qwen', name: 'Qwen', apiKey: 'old-key' }, ], }, }) const rejectedRes = await route.PUT(rejectedReq, routeContext) expect(rejectedRes.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('rejects compatMediaTemplate on non-openai-compatible media model', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'google', name: 'Google AI Studio', apiKey: 'google-key' }, ], models: [ { modelId: 'veo-3.1-fast-generate-preview', modelKey: 'google::veo-3.1-fast-generate-preview', name: 'Veo Fast', type: 'video', provider: 'google', compatMediaTemplate: { version: 1, mediaType: 'video', mode: 'sync', create: { method: 'POST', path: '/videos' }, response: { outputUrlPath: '$.url' }, }, }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(400) expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) it('backfills default compatMediaTemplate for openai-compatible image model when missing', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI Compat', baseUrl: 'https://compat.test/v1', apiKey: 'oa-key' }, ], models: [ { modelId: 'gpt-image-1', modelKey: 'openai-compatible:oa-1::gpt-image-1', name: 'Image One', type: 'image', provider: 'openai-compatible:oa-1', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedModels = readSavedModelsFromUpsert() const savedModel = savedModels.find((item) => item.modelKey === 'openai-compatible:oa-1::gpt-image-1') expect(savedModel?.compatMediaTemplate).toMatchObject({ version: 1, mediaType: 'image', mode: 'sync', create: { path: '/images/generations', }, }) expect(savedModel?.compatMediaTemplateSource).toBe('manual') expect(typeof savedModel?.compatMediaTemplateCheckedAt).toBe('string') }) it('backfills default compatMediaTemplate for openai-compatible video model when missing', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI Compat', baseUrl: 'https://compat.test/v1', apiKey: 'oa-key' }, ], models: [ { modelId: 'veo-2', modelKey: 'openai-compatible:oa-1::veo-2', name: 'Veo 2', type: 'video', provider: 'openai-compatible:oa-1', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedModels = readSavedModelsFromUpsert() const savedModel = savedModels.find((item) => item.modelKey === 'openai-compatible:oa-1::veo-2') expect(savedModel?.compatMediaTemplate).toMatchObject({ version: 1, mediaType: 'video', mode: 'async', create: { path: '/videos', contentType: 'multipart/form-data', multipartFileFields: ['input_reference'], bodyTemplate: { model: '{{model}}', prompt: '{{prompt}}', seconds: '{{duration}}', size: '{{size}}', input_reference: '{{image}}', }, }, status: { path: '/videos/{{task_id}}', }, content: { path: '/videos/{{task_id}}/content', }, response: { taskIdPath: '$.id', statusPath: '$.status', }, }) expect(savedModel?.compatMediaTemplateSource).toBe('manual') expect(typeof savedModel?.compatMediaTemplateCheckedAt).toBe('string') }) it('keeps explicit compatMediaTemplate for openai-compatible video model', async () => { installAuthMocks() mockAuthenticated('user-1') const route = await import('@/app/api/user/api-config/route') const req = buildMockRequest({ path: '/api/user/api-config', method: 'PUT', body: { providers: [ { id: 'openai-compatible:oa-1', name: 'OpenAI Compat', baseUrl: 'https://compat.test/v1', apiKey: 'oa-key' }, ], models: [ { modelId: 'veo3.1', modelKey: 'openai-compatible:oa-1::veo3.1', name: 'Veo 3.1', type: 'video', provider: 'openai-compatible:oa-1', compatMediaTemplate: { version: 1, mediaType: 'video', mode: 'async', create: { method: 'POST', path: '/v2/videos/generations', contentType: 'application/json', bodyTemplate: { model: '{{model}}', prompt: '{{prompt}}', }, }, status: { method: 'GET', path: '/v2/videos/generations/{{task_id}}', }, response: { taskIdPath: '$.task_id', statusPath: '$.status', outputUrlPath: '$.video_url', }, polling: { intervalMs: 3000, timeoutMs: 180000, doneStates: ['succeeded'], failStates: ['failed'], }, }, compatMediaTemplateSource: 'ai', }, ], }, }) const res = await route.PUT(req, routeContext) expect(res.status).toBe(200) const savedModels = readSavedModelsFromUpsert() const savedModel = savedModels.find((item) => item.modelKey === 'openai-compatible:oa-1::veo3.1') expect(savedModel?.compatMediaTemplate).toMatchObject({ mediaType: 'video', mode: 'async', create: { path: '/v2/videos/generations', }, }) expect(savedModel?.compatMediaTemplateSource).toBe('ai') }) })