diff --git a/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.test.ts b/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.test.ts index b84897e56..8bbf97e3f 100644 --- a/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.test.ts @@ -809,6 +809,11 @@ describe('Alibaba official catalog', () => { expect(models.some((model) => model.id === 'qwen-image-fixture')).toBe( false, ); + const fixture = models.find( + (model) => model.id === 'qwen-catalog-fixture', + )!; + expect(fixture.puterId).toBe('alibaba:qwen/qwen-catalog-fixture'); + expect(modelLookupNames(models)).toContain('qwen-catalog-fixture'); const plus = models.find((model) => model.id === 'qwen-plus')!; const original = ALIBABA_MODELS.find( (model) => model.id === 'qwen-plus', @@ -909,6 +914,115 @@ describe('Alibaba official catalog', () => { expect(model.costs).toEqual({ tokens: 1_000_000 }); }); + it.each([ + ['qwen3.9-plus-2027-01-01', 'alibaba:qwen/qwen3.9-plus-2027-01-01'], + ['kimi/kimi-k3', 'alibaba:kimi/kimi-k3'], + [ + 'deepseek-v4.1-flash-1017', + 'alibaba:deepseek/deepseek-v4.1-flash-1017', + ], + ['glm-5.3-prime', 'alibaba:zhipu/glm-5.3-prime'], + ['decision-model-preview', 'alibaba:qwen/decision-model-preview'], + ])( + 'gives uncurated model %s a puterId in the alibaba:/ form', + (id, puterId) => { + const model = mapAlibabaCatalogModel({ + model: id, + capabilities: ['TG'], + })!; + expect(model.puterId).toBe(puterId); + }, + ); + + it('skips a vendor-prefixed spelling of a curated model', () => { + const model = mapAlibabaCatalogModel({ + model: 'zhipu/glm-5.2', + capabilities: ['TG', 'Reasoning'], + inference_metadata: { + request_modality: ['Text'], + response_modality: ['Text'], + }, + }); + expect(model).toBeNull(); + }); + + it.each([ + [ + 'ASR', + { + capabilities: ['ASR'], + inference_metadata: { + request_modality: ['Audio'], + response_modality: ['Text'], + }, + }, + ], + [ + 'rerank', + { + capabilities: ['TR'], + inference_metadata: { + request_modality: ['Text'], + response_modality: ['Text'], + }, + }, + ], + [ + 'image generation', + { + capabilities: ['IG'], + inference_metadata: { + request_modality: ['Text'], + response_modality: ['Text', 'Image'], + }, + }, + ], + [ + 'realtime omni', + { + capabilities: ['Realtime-Omni'], + inference_metadata: { + request_modality: ['Text', 'Image', 'Audio', 'Video'], + response_modality: ['Text', 'Audio'], + }, + }, + ], + [ + 'TTS', + { + capabilities: ['TTS'], + inference_metadata: { + request_modality: ['Audio'], + response_modality: ['Text'], + }, + }, + ], + ])('rejects %s models that cannot serve chat completions', (_kind, raw) => { + expect( + mapAlibabaCatalogModel({ + model: 'non-chat-fixture', + ...(raw as AlibabaApiModel), + }), + ).toBeNull(); + }); + + it('admits a vision model', () => { + const model = mapAlibabaCatalogModel({ + model: 'vision-fixture', + capabilities: ['VU'], + inference_metadata: { + request_modality: ['Text', 'Image'], + response_modality: ['Text'], + }, + })!; + expect(model.modalities?.input).toEqual(['text', 'image']); + }); + + it('does not admit an uncurated entry without capability or modality data', () => { + expect(mapAlibabaCatalogModel({ model: 'mystery-model' })).toBeNull(); + expect(mapAlibabaCatalogModel({ model: 'qwen-plus' })).not.toBeNull(); + }); + it('refuses inference with unverified catalog prices', async () => { seedPages(); const { provider } = makeProvider(); diff --git a/src/backend/drivers/ai-chat/providers/alibaba/catalog.ts b/src/backend/drivers/ai-chat/providers/alibaba/catalog.ts index 063a8071c..85bf187ff 100644 --- a/src/backend/drivers/ai-chat/providers/alibaba/catalog.ts +++ b/src/backend/drivers/ai-chat/providers/alibaba/catalog.ts @@ -82,6 +82,23 @@ const positiveLimit = (value: unknown): number | undefined => ? value : undefined; +const chatCapabilities = ['TG', 'Reasoning', 'VU']; + +const curatedPuterIds = new Set(ALIBABA_MODELS.map((model) => model.puterId)); + +const inferPuterId = (id: string): string => { + if (id.includes('/')) return `alibaba:${id}`; + const family = id.split('-')[0]; + const sibling = ALIBABA_MODELS.find( + (model) => model.puterId && model.id.split('-')[0] === family, + ); + // Unprefixed feed ids are Model Studio's own family unless a curated + // sibling points at a third-party org. + const org = + sibling?.puterId?.slice('alibaba:'.length).split('/')[0] ?? 'qwen'; + return `alibaba:${org}/${id}`; +}; + export const mapAlibabaCatalogModel = ( raw: AlibabaApiModel, previous?: IChatModel, @@ -90,16 +107,25 @@ export const mapAlibabaCatalogModel = ( const id = raw.model.trim(); const existing = previous ?? ALIBABA_MODELS.find((model) => model.id === id); + const input = raw.inference_metadata?.request_modality; const output = raw.inference_metadata?.response_modality; - if (output && !output.includes('Text')) return null; if ( - !output && - !existing && - !raw.capabilities?.some((capability) => - ['TG', 'Reasoning', 'VU'].includes(capability), + raw.capabilities && + !raw.capabilities.some((capability) => + chatCapabilities.includes(capability), ) ) return null; + if (input && !input.includes('Text')) return null; + if (output && !output.includes('Text')) return null; + if (!raw.capabilities && !input && !output && !existing) return null; + let puterId = existing?.puterId; + if (!puterId) { + puterId = inferPuterId(id); + // A feed entry spelling a curated model differently would duplicate + // the curated entry's puterId and list the model twice. + if (curatedPuterIds.has(puterId)) return null; + } const catalogPricing: AlibabaCatalogTier[] = (raw.prices ?? []).map( (tier) => { // Abbreviated ranges do not specify whether k means 1000 or 1024 tokens. @@ -149,6 +175,7 @@ export const mapAlibabaCatalogModel = ( return { ...existing, id, + puterId, name: raw.name ?? existing?.name ?? id, description: raw.description ?? existing?.description, aliases: existing?.aliases,