diff --git a/src/utils/provider-models.test.ts b/src/utils/provider-models.test.ts index 74a464b5..cb351a68 100644 --- a/src/utils/provider-models.test.ts +++ b/src/utils/provider-models.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildModelCatalog } from './provider-models.js'; +import { buildModelCatalog, extractGrokModelIdsFromLanguageModelsResponse } from './provider-models.js'; describe('buildModelCatalog', () => { it('prefers the provider recommended OpenAI model when available', () => { @@ -117,3 +117,48 @@ describe('buildModelCatalog', () => { expect(catalog.recommendedModel).toBe('model-a'); }); }); + +describe('extractGrokModelIdsFromLanguageModelsResponse', () => { + it('extracts grok-* model ids from {models: [...]}', () => { + const ids = extractGrokModelIdsFromLanguageModelsResponse({ + models: [{ id: 'grok-4', output_modalities: ['text'] }], + }); + + expect(ids).toEqual(['grok-4']); + }); + + it('extracts grok-* model ids from {data: [...]}', () => { + const ids = extractGrokModelIdsFromLanguageModelsResponse({ + data: [{ id: 'grok-3-mini', output_modalities: ['text'] }], + }); + + expect(ids).toEqual(['grok-3-mini']); + }); + + it('extracts grok-* model ids from {data: {data: [...]}}', () => { + const ids = extractGrokModelIdsFromLanguageModelsResponse({ + data: { data: [{ id: 'grok-4', output_modalities: ['text'] }] }, + }); + + expect(ids).toEqual(['grok-4']); + }); + + it('includes grok-* aliases', () => { + const ids = extractGrokModelIdsFromLanguageModelsResponse({ + models: [{ id: 'grok-4-1-fast', aliases: ['grok-4', 'not-grok'], output_modalities: ['text'] }], + }); + + expect(ids).toEqual(['grok-4-1-fast', 'grok-4']); + }); + + it('filters out models without text output', () => { + const ids = extractGrokModelIdsFromLanguageModelsResponse({ + models: [ + { id: 'grok-4', output_modalities: ['image'] }, + { id: 'grok-3-mini', output_modalities: ['text'] }, + ], + }); + + expect(ids).toEqual(['grok-3-mini']); + }); +}); diff --git a/src/utils/provider-models.ts b/src/utils/provider-models.ts index 8a4df084..c73d8d03 100644 --- a/src/utils/provider-models.ts +++ b/src/utils/provider-models.ts @@ -87,10 +87,19 @@ interface AnthropicModelResponse { } interface XAIModelResponse { + models?: Array<{ + id?: string; + aliases?: string[]; + input_modalities?: string[]; + output_modalities?: string[]; + outputModalities?: string[]; + }>; data?: Array<{ id?: string; + aliases?: string[]; input_modalities?: string[]; output_modalities?: string[]; + outputModalities?: string[]; }>; } @@ -224,6 +233,40 @@ export function buildModelCatalog( }; } +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +export function extractGrokModelIdsFromLanguageModelsResponse(payload: unknown): string[] { + const candidates: unknown[] = []; + if (isRecord(payload)) { + if (Array.isArray(payload.models)) { + candidates.push(...payload.models); + } + if (Array.isArray(payload.data)) { + candidates.push(...payload.data); + } + if (isRecord(payload.data) && Array.isArray(payload.data.data)) { + candidates.push(...payload.data.data); + } + } + + return candidates.flatMap((model) => { + if (!isRecord(model)) return []; + const outputModalities = model.output_modalities ?? model.outputModalities; + if (!(Array.isArray(outputModalities) ? outputModalities.includes('text') : outputModalities == null)) return []; + + const primary = typeof model.id === 'string' ? model.id.trim() : ''; + const aliases = Array.isArray(model.aliases) + ? model.aliases + .map((alias) => (typeof alias === 'string' ? alias.trim() : '')) + .filter(Boolean) + : []; + + return [primary, ...aliases].filter((id) => id.startsWith('grok-')); + }); +} + async function fetchOpenAICompatModels(provider: ProviderName, config: ProviderConfig): Promise { const headers: Record = {}; if (config.apiKey) { @@ -292,10 +335,7 @@ async function fetchGrokModels(config: ProviderConfig): Promise model.output_modalities?.includes('text') || model.output_modalities == null) - .map((model) => model.id?.trim() ?? '') - .filter((id) => id.startsWith('grok-')); + const ids = extractGrokModelIdsFromLanguageModelsResponse(data); return buildModelCatalog('grok', ids, config.model); }