/ src / lib / server / provider-model-discovery.test.ts
provider-model-discovery.test.ts
  1  import assert from 'node:assert/strict'
  2  import { test } from 'node:test'
  3  import {
  4    normalizeModelId,
  5    looksLikeChatModel,
  6    dedupeModels,
  7    extractCandidateModelIds,
  8    extractDiscoveredModels,
  9    ttlForDescriptor,
 10    parseErrorMessage,
 11    resolveDescriptor,
 12    type DiscoveryDescriptor,
 13  } from './provider-model-discovery'
 14  
 15  // ---------------------------------------------------------------------------
 16  // normalizeModelId
 17  // ---------------------------------------------------------------------------
 18  
 19  test('normalizeModelId strips :latest for ollama', () => {
 20    assert.equal(normalizeModelId('llama3.2:latest', 'ollama'), 'llama3.2')
 21  })
 22  
 23  test('normalizeModelId preserves non-latest tags for ollama', () => {
 24    assert.equal(normalizeModelId('codellama:7b', 'ollama'), 'codellama:7b')
 25  })
 26  
 27  test('normalizeModelId strips models/ prefix for google', () => {
 28    assert.equal(normalizeModelId('models/gemini-2.0-flash', 'google'), 'gemini-2.0-flash')
 29  })
 30  
 31  test('normalizeModelId no-ops for openai-compatible', () => {
 32    assert.equal(normalizeModelId('gpt-4o', 'openai-compatible'), 'gpt-4o')
 33  })
 34  
 35  test('normalizeModelId returns empty for whitespace', () => {
 36    assert.equal(normalizeModelId('   ', 'openai-compatible'), '')
 37  })
 38  
 39  // ---------------------------------------------------------------------------
 40  // looksLikeChatModel
 41  // ---------------------------------------------------------------------------
 42  
 43  test('looksLikeChatModel excludes embeddings', () => {
 44    assert.equal(looksLikeChatModel('openai', 'text-embedding-3-small'), false)
 45  })
 46  
 47  test('looksLikeChatModel excludes rerank', () => {
 48    assert.equal(looksLikeChatModel('together', 'rerank-v1'), false)
 49  })
 50  
 51  test('looksLikeChatModel excludes tts', () => {
 52    assert.equal(looksLikeChatModel('openai', 'tts-1'), false)
 53  })
 54  
 55  test('looksLikeChatModel excludes whisper', () => {
 56    assert.equal(looksLikeChatModel('openai', 'whisper-1'), false)
 57  })
 58  
 59  test('looksLikeChatModel excludes stable-diffusion', () => {
 60    assert.equal(looksLikeChatModel('together', 'stable-diffusion-xl'), false)
 61  })
 62  
 63  test('looksLikeChatModel: openai gpt-4o → true', () => {
 64    assert.equal(looksLikeChatModel('openai', 'gpt-4o'), true)
 65  })
 66  
 67  test('looksLikeChatModel: openai o1 → true', () => {
 68    assert.equal(looksLikeChatModel('openai', 'o1'), true)
 69  })
 70  
 71  test('looksLikeChatModel: openai o3-mini → true', () => {
 72    assert.equal(looksLikeChatModel('openai', 'o3-mini'), true)
 73  })
 74  
 75  test('looksLikeChatModel: openai o4-mini → true', () => {
 76    assert.equal(looksLikeChatModel('openai', 'o4-mini'), true)
 77  })
 78  
 79  test('looksLikeChatModel: openai chatgpt-4o → true', () => {
 80    assert.equal(looksLikeChatModel('openai', 'chatgpt-4o-latest'), true)
 81  })
 82  
 83  test('looksLikeChatModel: openai dall-e → false', () => {
 84    assert.equal(looksLikeChatModel('openai', 'dall-e-3'), false)
 85  })
 86  
 87  test('looksLikeChatModel: anthropic claude- → true', () => {
 88    assert.equal(looksLikeChatModel('anthropic', 'claude-sonnet-4-6'), true)
 89  })
 90  
 91  test('looksLikeChatModel: google gemini- → true', () => {
 92    assert.equal(looksLikeChatModel('google', 'gemini-2.0-flash'), true)
 93  })
 94  
 95  test('looksLikeChatModel: google non-gemini → false', () => {
 96    assert.equal(looksLikeChatModel('google', 'text-bison-001'), false)
 97  })
 98  
 99  test('looksLikeChatModel: deepseek deepseek-chat → true', () => {
100    assert.equal(looksLikeChatModel('deepseek', 'deepseek-chat'), true)
101  })
102  
103  test('looksLikeChatModel: xai grok-3 → true', () => {
104    assert.equal(looksLikeChatModel('xai', 'grok-3'), true)
105  })
106  
107  test('looksLikeChatModel: unknown provider passes anything not excluded', () => {
108    assert.equal(looksLikeChatModel('custom', 'my-model'), true)
109  })
110  
111  test('looksLikeChatModel: empty string → false', () => {
112    assert.equal(looksLikeChatModel('openai', ''), false)
113  })
114  
115  // ---------------------------------------------------------------------------
116  // dedupeModels
117  // ---------------------------------------------------------------------------
118  
119  test('dedupeModels removes duplicates and preserves order', () => {
120    assert.deepEqual(dedupeModels(['a', 'b', 'a', 'c', 'b']), ['a', 'b', 'c'])
121  })
122  
123  test('dedupeModels strips empty strings', () => {
124    assert.deepEqual(dedupeModels(['a', '', '  ', 'b']), ['a', 'b'])
125  })
126  
127  test('dedupeModels trims whitespace', () => {
128    assert.deepEqual(dedupeModels([' a ', 'a']), ['a'])
129  })
130  
131  // ---------------------------------------------------------------------------
132  // extractCandidateModelIds
133  // ---------------------------------------------------------------------------
134  
135  test('extractCandidateModelIds reads .data[] with .id', () => {
136    const payload = { data: [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }] }
137    assert.deepEqual(extractCandidateModelIds(payload, 'openai-compatible'), ['gpt-4o', 'gpt-4o-mini'])
138  })
139  
140  test('extractCandidateModelIds reads .models[] with .name', () => {
141    const payload = { models: [{ name: 'llama3.2:latest' }] }
142    assert.deepEqual(extractCandidateModelIds(payload, 'ollama'), ['llama3.2'])
143  })
144  
145  test('extractCandidateModelIds reads top-level array', () => {
146    const payload = [{ id: 'model-a' }, { id: 'model-b' }]
147    assert.deepEqual(extractCandidateModelIds(payload, 'openai-compatible'), ['model-a', 'model-b'])
148  })
149  
150  test('extractCandidateModelIds reads .model field', () => {
151    const payload = { data: [{ model: 'my-model' }] }
152    assert.deepEqual(extractCandidateModelIds(payload, 'openai-compatible'), ['my-model'])
153  })
154  
155  test('extractCandidateModelIds dedupes across .id and .name', () => {
156    const payload = { data: [{ id: 'foo', name: 'foo' }] }
157    assert.deepEqual(extractCandidateModelIds(payload, 'openai-compatible'), ['foo'])
158  })
159  
160  test('extractCandidateModelIds handles empty payload', () => {
161    assert.deepEqual(extractCandidateModelIds({}, 'openai-compatible'), [])
162  })
163  
164  // ---------------------------------------------------------------------------
165  // extractDiscoveredModels
166  // ---------------------------------------------------------------------------
167  
168  test('extractDiscoveredModels filters non-chat models for cloud providers', () => {
169    const payload = {
170      data: [
171        { id: 'gpt-4o' },
172        { id: 'text-embedding-3-small' },
173        { id: 'gpt-4o-mini' },
174        { id: 'dall-e-3' },
175      ],
176    }
177    const result = extractDiscoveredModels('openai', 'openai-compatible', payload)
178    assert.deepEqual(result.models, ['gpt-4o', 'gpt-4o-mini'])
179    assert.equal(result.rawCount, 4)
180  })
181  
182  test('extractDiscoveredModels skips filter for ollama', () => {
183    const payload = { models: [{ name: 'llama3.2:latest' }, { name: 'nomic-embed-text:latest' }] }
184    const result = extractDiscoveredModels('ollama', 'ollama', payload)
185    assert.deepEqual(result.models, ['llama3.2', 'nomic-embed-text'])
186    assert.equal(result.rawCount, 2)
187  })
188  
189  test('resolveDescriptor keeps explicit local Ollama discovery local even for :cloud model names', () => {
190    const descriptor = resolveDescriptor({
191      providerId: 'ollama',
192      ollamaMode: 'local',
193      credentialId: 'cred-1',
194    })
195    assert.equal(descriptor?.strategy, 'ollama')
196    assert.equal(descriptor?.endpoint, 'http://localhost:11434')
197    assert.equal(descriptor?.requiresApiKey, false)
198  })
199  
200  test('resolveDescriptor uses explicit cloud Ollama discovery only when cloud mode is selected', () => {
201    const descriptor = resolveDescriptor({
202      providerId: 'ollama',
203      ollamaMode: 'cloud',
204      endpoint: 'http://localhost:11434',
205    })
206    assert.equal(descriptor?.strategy, 'openai-compatible')
207    assert.equal(descriptor?.endpoint, 'https://ollama.com/v1')
208    assert.equal(descriptor?.requiresApiKey, true)
209  })
210  
211  test('resolveDescriptor uses OpenRouter as an OpenAI-compatible provider', () => {
212    const descriptor = resolveDescriptor({
213      providerId: 'openrouter',
214    })
215    assert.equal(descriptor?.strategy, 'openai-compatible')
216    assert.equal(descriptor?.endpoint, 'https://openrouter.ai/api/v1')
217    assert.equal(descriptor?.requiresApiKey, true)
218    assert.equal(descriptor?.optionalApiKey, false)
219  })
220  
221  test('resolveDescriptor uses Hermes as an OpenAI-compatible provider with optional auth', () => {
222    const descriptor = resolveDescriptor({
223      providerId: 'hermes',
224    })
225    assert.equal(descriptor?.strategy, 'openai-compatible')
226    assert.equal(descriptor?.endpoint, 'http://127.0.0.1:8642/v1')
227    assert.equal(descriptor?.requiresApiKey, false)
228    assert.equal(descriptor?.optionalApiKey, true)
229  })
230  
231  test('resolveDescriptor disables model discovery for local CLI-backed providers without live model catalogs', () => {
232    for (const providerId of ['copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose']) {
233      const descriptor = resolveDescriptor({ providerId })
234      assert.equal(descriptor?.supportsDiscovery, false, `${providerId} should not support discovery`)
235      assert.equal(descriptor?.endpoint, undefined, `${providerId} should not expose a discovery endpoint`)
236    }
237  })
238  
239  // ---------------------------------------------------------------------------
240  // ttlForDescriptor
241  // ---------------------------------------------------------------------------
242  
243  function makeDescriptor(overrides: Partial<DiscoveryDescriptor> = {}): DiscoveryDescriptor {
244    return {
245      providerId: 'openai',
246      providerName: 'OpenAI',
247      strategy: 'openai-compatible',
248      requiresApiKey: true,
249      optionalApiKey: false,
250      supportsDiscovery: true,
251      ...overrides,
252    }
253  }
254  
255  test('ttlForDescriptor returns 15min for cloud on success', () => {
256    assert.equal(ttlForDescriptor(makeDescriptor(), true), 15 * 60_000)
257  })
258  
259  test('ttlForDescriptor returns 1min for ollama on success', () => {
260    assert.equal(ttlForDescriptor(makeDescriptor({ strategy: 'ollama' }), true), 60_000)
261  })
262  
263  test('ttlForDescriptor returns 1min for openclaw on success', () => {
264    assert.equal(ttlForDescriptor(makeDescriptor({ strategy: 'openclaw' }), true), 60_000)
265  })
266  
267  test('ttlForDescriptor returns 30s on error', () => {
268    assert.equal(ttlForDescriptor(makeDescriptor(), false), 30_000)
269  })
270  
271  // ---------------------------------------------------------------------------
272  // parseErrorMessage (synchronous variant in provider-model-discovery)
273  // ---------------------------------------------------------------------------
274  
275  test('parseErrorMessage extracts .error.message from JSON', () => {
276    assert.equal(parseErrorMessage(JSON.stringify({ error: { message: 'bad key' } }), 'fallback'), 'bad key')
277  })
278  
279  test('parseErrorMessage extracts .error string from JSON', () => {
280    assert.equal(parseErrorMessage(JSON.stringify({ error: 'rate limited' }), 'fallback'), 'rate limited')
281  })
282  
283  test('parseErrorMessage extracts .detail from JSON', () => {
284    assert.equal(parseErrorMessage(JSON.stringify({ detail: 'not found' }), 'fallback'), 'not found')
285  })
286  
287  test('parseErrorMessage falls back to raw text for non-JSON', () => {
288    assert.equal(parseErrorMessage('Service Unavailable', 'fallback'), 'Service Unavailable')
289  })
290  
291  test('parseErrorMessage returns fallback for empty body', () => {
292    assert.equal(parseErrorMessage('', 'fallback'), 'fallback')
293  })