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 })