import { beforeEach, describe, expect, it, vi } from 'vitest'; import { clearProviderModelCache, discoverProviderModels, } from '../../src/local/provider-model-catalog.js'; describe('provider model catalog discovery', () => { beforeEach(() => { clearProviderModelCache(); }); it('exposes newly returned provider models with stable provider/model ids', async () => { const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: [{ id: 'new-model-v9', name: 'New Model v9', owned_by: 'provider' }], }), { status: 200, headers: { 'content-type': 'application/json' } })); const result = await discoverProviderModels('openai', 'test-key', undefined, { fetchImpl }); expect(result.status).toBe('provider-api'); expect(result.models).toEqual([{ id: 'openai/new-model-v9', name: 'New Model v9', cost: '$$', speed: 'medium', source: 'provider-api', ownedBy: 'provider', }]); expect(fetchImpl).toHaveBeenCalledWith( 'https://api.openai.com/v1/models', expect.objectContaining({ headers: { Authorization: 'Bearer test-key' }, }), ); }); it('normalizes Google model resource names without dropping new entries', async () => { let requestedUrl = ''; let requestedInit: RequestInit | undefined; const fetchImpl = vi.fn(async (input, init) => { requestedUrl = String(input); requestedInit = init; return new Response(JSON.stringify({ models: [{ name: 'models/gemini-new', displayName: 'Gemini New' }], }), { status: 200, headers: { 'content-type': 'application/json' } }); }); const result = await discoverProviderModels('google', 'google-key', undefined, { fetchImpl }); expect(result.models[0]?.id).toBe('google/gemini-new'); expect(result.models[0]?.name).toBe('Gemini New'); expect(requestedUrl).toContain('pageSize=1000'); expect(requestedUrl).not.toContain('google-key'); expect(requestedInit?.headers).toEqual({ 'x-goog-api-key': 'google-key' }); }); it('uses Perplexity v1 discovery and preserves provider-namespaced model ids', async () => { const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: [{ id: 'future-provider/model-released-today', owned_by: 'future-provider' }], }), { status: 200 })); const result = await discoverProviderModels('perplexity', 'perplexity-key', undefined, { fetchImpl }); expect(result.models.map((model) => model.id)).toEqual([ 'perplexity/future-provider/model-released-today', ]); expect(fetchImpl).toHaveBeenCalledWith( 'https://api.perplexity.ai/v1/models', expect.objectContaining({ headers: { Authorization: 'Bearer perplexity-key' }, }), ); }); it('collects every Anthropic cursor page instead of stopping at the default first 20', async () => { const requestedUrls: string[] = []; const fetchImpl = vi.fn(async (input) => { const url = String(input); requestedUrls.push(url); const afterId = new URL(url).searchParams.get('after_id'); return new Response(JSON.stringify(afterId ? { data: [{ id: 'model-from-page-two' }], has_more: false, last_id: 'model-from-page-two' } : { data: [{ id: 'model-from-page-one' }], has_more: true, last_id: 'model-from-page-one' }), { status: 200 }); }); const result = await discoverProviderModels('anthropic', 'anthropic-key', undefined, { fetchImpl }); expect(result.models.map((model) => model.id)).toEqual([ 'anthropic/model-from-page-one', 'anthropic/model-from-page-two', ]); expect(requestedUrls).toHaveLength(2); expect(requestedUrls[0]).toContain('limit=1000'); expect(requestedUrls[1]).toContain('after_id=model-from-page-one'); }); it('collects every Gemini page token while preserving API authentication', async () => { const requestedUrls: string[] = []; const requestedHeaders: Array = []; const fetchImpl = vi.fn(async (input, init) => { const url = String(input); requestedUrls.push(url); requestedHeaders.push(init?.headers); const pageToken = new URL(url).searchParams.get('pageToken'); return new Response(JSON.stringify(pageToken ? { models: [{ name: 'models/gemini-page-two' }] } : { models: [{ name: 'models/gemini-page-one' }], nextPageToken: 'next token' }), { status: 200 }); }); const result = await discoverProviderModels('google', 'google-key', undefined, { fetchImpl }); expect(result.models.map((model) => model.id)).toEqual([ 'google/gemini-page-one', 'google/gemini-page-two', ]); expect(requestedUrls).toHaveLength(2); expect(requestedUrls[1]).toContain('pageToken=next+token'); expect(requestedUrls[1]).not.toContain('google-key'); expect(requestedHeaders[1]).toEqual({ 'x-goog-api-key': 'google-key' }); }); it('keeps the last-known catalog and marks it stale during a provider outage', async () => { let callCount = 0; const fetchImpl = vi.fn(async () => { callCount += 1; if (callCount === 1) { return new Response(JSON.stringify({ data: [{ id: 'stable-model' }] }), { status: 200 }); } throw new Error('provider offline'); }); const first = await discoverProviderModels('deepseek', 'same-key', undefined, { fetchImpl }); const second = await discoverProviderModels('deepseek', 'same-key', undefined, { fetchImpl }); expect(first.status).toBe('provider-api'); expect(second.status).toBe('stale-provider-api'); expect(second.models.map((model) => model.id)).toEqual(['deepseek/stable-model']); expect(second.error).toContain('provider offline'); }); });