language-models 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Tests for model listing, resolution, and search
3
+ *
4
+ * These are pure unit tests - no external API calls needed.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach } from 'vitest'
8
+ import {
9
+ list,
10
+ get,
11
+ search,
12
+ resolve,
13
+ resolveWithProvider,
14
+ DIRECT_PROVIDERS,
15
+ type ModelInfo,
16
+ type ResolvedModel,
17
+ } from './models.js'
18
+ import { ALIASES } from './aliases.js'
19
+
20
+ describe('list', () => {
21
+ it('returns an array of models', () => {
22
+ const models = list()
23
+ expect(Array.isArray(models)).toBe(true)
24
+ })
25
+
26
+ it('returns models with required properties', () => {
27
+ const models = list()
28
+ if (models.length > 0) {
29
+ const model = models[0]
30
+ expect(model).toHaveProperty('id')
31
+ expect(model).toHaveProperty('name')
32
+ expect(model).toHaveProperty('context_length')
33
+ expect(model).toHaveProperty('pricing')
34
+ expect(model.pricing).toHaveProperty('prompt')
35
+ expect(model.pricing).toHaveProperty('completion')
36
+ }
37
+ })
38
+
39
+ it('caches results on subsequent calls', () => {
40
+ const models1 = list()
41
+ const models2 = list()
42
+ expect(models1).toBe(models2) // Same reference
43
+ })
44
+
45
+ it('returns empty array if models.json does not exist', () => {
46
+ // This test verifies graceful handling of missing data file
47
+ const models = list()
48
+ expect(Array.isArray(models)).toBe(true)
49
+ })
50
+ })
51
+
52
+ describe('get', () => {
53
+ it('returns undefined for non-existent model', () => {
54
+ const model = get('non-existent/model-id')
55
+ expect(model).toBeUndefined()
56
+ })
57
+
58
+ it('returns model info for valid model ID', () => {
59
+ const models = list()
60
+ if (models.length > 0) {
61
+ const firstModel = models[0]
62
+ const retrieved = get(firstModel.id)
63
+ expect(retrieved).toBeDefined()
64
+ expect(retrieved?.id).toBe(firstModel.id)
65
+ expect(retrieved?.name).toBe(firstModel.name)
66
+ }
67
+ })
68
+
69
+ it('performs exact match only', () => {
70
+ const models = list()
71
+ if (models.length > 0) {
72
+ const model = models[0]
73
+ const partialId = model.id.split('/')[0] // Just the provider
74
+ const result = get(partialId)
75
+ // Should not match partial ID
76
+ if (result) {
77
+ expect(result.id).toBe(partialId) // Only matches if there's an exact model with this ID
78
+ }
79
+ }
80
+ })
81
+ })
82
+
83
+ describe('search', () => {
84
+ it('returns empty array for no matches', () => {
85
+ const results = search('this-should-not-match-anything-12345')
86
+ expect(results).toEqual([])
87
+ })
88
+
89
+ it('searches by model ID', () => {
90
+ const models = list()
91
+ if (models.length > 0) {
92
+ const model = models[0]
93
+ const idPart = model.id.split('/')[0] // Provider name
94
+ const results = search(idPart)
95
+ expect(results.length).toBeGreaterThan(0)
96
+ expect(results.some(m => m.id.includes(idPart))).toBe(true)
97
+ }
98
+ })
99
+
100
+ it('searches by model name', () => {
101
+ const models = list()
102
+ if (models.length > 0) {
103
+ const model = models[0]
104
+ const namePart = model.name.split(' ')[0].toLowerCase()
105
+ const results = search(namePart)
106
+ expect(results.length).toBeGreaterThan(0)
107
+ }
108
+ })
109
+
110
+ it('is case-insensitive', () => {
111
+ const models = list()
112
+ if (models.length > 0) {
113
+ const model = models[0]
114
+ const idLower = model.id.toLowerCase()
115
+ const idUpper = model.id.toUpperCase()
116
+ const resultsLower = search(idLower)
117
+ const resultsUpper = search(idUpper)
118
+ expect(resultsLower).toEqual(resultsUpper)
119
+ }
120
+ })
121
+
122
+ it('searches in both id and name fields', () => {
123
+ const models = list()
124
+ if (models.length > 0) {
125
+ // Find a model and search for part of its name
126
+ const model = models.find(m => m.name.includes(' '))
127
+ if (model) {
128
+ const namePart = model.name.split(' ')[0].toLowerCase()
129
+ const results = search(namePart)
130
+ expect(results.some(m => m.id === model.id || m.name.toLowerCase().includes(namePart))).toBe(true)
131
+ }
132
+ }
133
+ })
134
+
135
+ it('returns multiple matches', () => {
136
+ const models = list()
137
+ if (models.length > 1) {
138
+ // Search for a common term that should match multiple models
139
+ const commonProviders = ['anthropic', 'openai', 'google', 'meta']
140
+ for (const provider of commonProviders) {
141
+ const results = search(provider)
142
+ if (results.length > 1) {
143
+ expect(results.length).toBeGreaterThan(1)
144
+ break
145
+ }
146
+ }
147
+ }
148
+ })
149
+ })
150
+
151
+ describe('resolve', () => {
152
+ beforeEach(() => {
153
+ // Ensure we have fresh data
154
+ list()
155
+ })
156
+
157
+ describe('alias resolution', () => {
158
+ it('resolves known aliases', () => {
159
+ const result = resolve('opus')
160
+ expect(result).toBe(ALIASES['opus'])
161
+ })
162
+
163
+ it('resolves claude alias', () => {
164
+ const result = resolve('claude')
165
+ expect(result).toBe(ALIASES['claude'])
166
+ })
167
+
168
+ it('resolves gpt alias', () => {
169
+ const result = resolve('gpt')
170
+ expect(result).toBe(ALIASES['gpt'])
171
+ })
172
+
173
+ it('resolves llama alias', () => {
174
+ const result = resolve('llama')
175
+ expect(result).toBe(ALIASES['llama'])
176
+ })
177
+
178
+ it('is case-insensitive for aliases', () => {
179
+ const lower = resolve('opus')
180
+ const upper = resolve('OPUS')
181
+ const mixed = resolve('Opus')
182
+ expect(lower).toBe(upper)
183
+ expect(lower).toBe(mixed)
184
+ })
185
+
186
+ it('handles whitespace in input', () => {
187
+ const result = resolve(' opus ')
188
+ expect(result).toBe(ALIASES['opus'])
189
+ })
190
+
191
+ it('resolves all documented aliases', () => {
192
+ // Test key aliases from the README
193
+ const aliasesToTest = [
194
+ ['opus', 'anthropic/claude-opus-4.5'],
195
+ ['sonnet', 'anthropic/claude-sonnet-4.5'],
196
+ ['haiku', 'anthropic/claude-haiku-4.5'],
197
+ ['gpt-4o', 'openai/gpt-4o'],
198
+ ['gemini', 'google/gemini-2.5-flash'],
199
+ ['llama-70b', 'meta-llama/llama-3.3-70b-instruct'],
200
+ ['mistral', 'mistralai/mistral-large-2411'],
201
+ ]
202
+
203
+ for (const [alias, expected] of aliasesToTest) {
204
+ const result = resolve(alias)
205
+ expect(result).toBe(expected)
206
+ }
207
+ })
208
+ })
209
+
210
+ describe('full ID passthrough', () => {
211
+ it('returns full ID as-is if it exists', () => {
212
+ const models = list()
213
+ if (models.length > 0) {
214
+ const model = models[0]
215
+ const result = resolve(model.id)
216
+ expect(result).toBe(model.id)
217
+ }
218
+ })
219
+
220
+ it('returns unknown full ID as-is', () => {
221
+ const unknownId = 'unknown-provider/unknown-model'
222
+ const result = resolve(unknownId)
223
+ expect(result).toBe(unknownId)
224
+ })
225
+
226
+ it('detects full ID by slash character', () => {
227
+ const result = resolve('custom/model-name')
228
+ expect(result).toBe('custom/model-name')
229
+ })
230
+ })
231
+
232
+ describe('partial name search', () => {
233
+ it('finds model by partial name', () => {
234
+ const models = list()
235
+ if (models.length > 0) {
236
+ const model = models[0]
237
+ const provider = model.id.split('/')[0]
238
+ const result = resolve(provider)
239
+ // Should find a model from that provider
240
+ expect(result).toContain('/')
241
+ }
242
+ })
243
+
244
+ it('returns first match for partial search', () => {
245
+ const result = resolve('claude')
246
+ // Should return an alias if it exists, or search result
247
+ expect(result).toBeTruthy()
248
+ expect(typeof result).toBe('string')
249
+ })
250
+
251
+ it('returns input as-is if no matches found', () => {
252
+ const input = 'unknown-model-xyz'
253
+ const result = resolve(input)
254
+ expect(result).toBe(input)
255
+ })
256
+ })
257
+
258
+ describe('resolution priority', () => {
259
+ it('prioritizes aliases over search', () => {
260
+ // 'opus' is an alias, so it should resolve to the alias target
261
+ // even if there are other models containing 'opus'
262
+ const result = resolve('opus')
263
+ expect(result).toBe(ALIASES['opus'])
264
+ })
265
+
266
+ it('checks full ID before partial search', () => {
267
+ const models = list()
268
+ if (models.length > 0) {
269
+ const model = models[0]
270
+ const result = resolve(model.id)
271
+ expect(result).toBe(model.id)
272
+ }
273
+ })
274
+ })
275
+ })
276
+
277
+ describe('resolveWithProvider', () => {
278
+ it('extracts provider from model ID', () => {
279
+ const result = resolveWithProvider('opus')
280
+ expect(result.provider).toBe('anthropic')
281
+ })
282
+
283
+ it('includes resolved model ID', () => {
284
+ const result = resolveWithProvider('opus')
285
+ expect(result.id).toBe(ALIASES['opus'])
286
+ })
287
+
288
+ it('identifies direct routing support', () => {
289
+ const anthropic = resolveWithProvider('opus')
290
+ expect(anthropic.supportsDirectRouting).toBe(true)
291
+
292
+ const openai = resolveWithProvider('gpt')
293
+ expect(openai.supportsDirectRouting).toBe(true)
294
+
295
+ const google = resolveWithProvider('gemini')
296
+ expect(google.supportsDirectRouting).toBe(true)
297
+ })
298
+
299
+ it('identifies non-direct providers', () => {
300
+ // Use a model from a provider not in DIRECT_PROVIDERS
301
+ const models = list()
302
+ const nonDirectModel = models.find(m => {
303
+ const provider = m.id.split('/')[0]
304
+ return !(DIRECT_PROVIDERS as readonly string[]).includes(provider)
305
+ })
306
+
307
+ if (nonDirectModel) {
308
+ const result = resolveWithProvider(nonDirectModel.id)
309
+ expect(result.supportsDirectRouting).toBe(false)
310
+ }
311
+ })
312
+
313
+ it('includes full model info if available', () => {
314
+ const result = resolveWithProvider('opus')
315
+ if (result.model) {
316
+ expect(result.model).toHaveProperty('id')
317
+ expect(result.model).toHaveProperty('name')
318
+ expect(result.model).toHaveProperty('pricing')
319
+ }
320
+ })
321
+
322
+ it('includes provider model ID if available', () => {
323
+ const result = resolveWithProvider('opus')
324
+ if (result.model?.provider_model_id) {
325
+ expect(result.providerModelId).toBeDefined()
326
+ expect(typeof result.providerModelId).toBe('string')
327
+ }
328
+ })
329
+
330
+ it('handles unknown models gracefully', () => {
331
+ const result = resolveWithProvider('unknown/model')
332
+ expect(result.id).toBe('unknown/model')
333
+ expect(result.provider).toBe('unknown')
334
+ expect(result.model).toBeUndefined()
335
+ })
336
+
337
+ it('handles models without provider prefix', () => {
338
+ const result = resolveWithProvider('opus')
339
+ expect(result.provider).toBeTruthy()
340
+ expect(result.id).toContain('/')
341
+ })
342
+ })
343
+
344
+ describe('DIRECT_PROVIDERS', () => {
345
+ it('contains expected providers', () => {
346
+ expect(DIRECT_PROVIDERS).toContain('anthropic')
347
+ expect(DIRECT_PROVIDERS).toContain('openai')
348
+ expect(DIRECT_PROVIDERS).toContain('google')
349
+ })
350
+
351
+ it('has exactly 3 providers', () => {
352
+ expect(DIRECT_PROVIDERS.length).toBe(3)
353
+ })
354
+
355
+ it('is readonly', () => {
356
+ // Type check - this should compile
357
+ const providers: readonly string[] = DIRECT_PROVIDERS
358
+ expect(providers).toBeDefined()
359
+ })
360
+ })
361
+
362
+ describe('ModelInfo type', () => {
363
+ it('models have correct structure', () => {
364
+ const models = list()
365
+ if (models.length > 0) {
366
+ const model = models[0]
367
+ expect(typeof model.id).toBe('string')
368
+ expect(typeof model.name).toBe('string')
369
+ expect(typeof model.context_length).toBe('number')
370
+ expect(typeof model.pricing.prompt).toBe('string')
371
+ expect(typeof model.pricing.completion).toBe('string')
372
+
373
+ if (model.architecture) {
374
+ expect(typeof model.architecture.modality).toBe('string')
375
+ expect(Array.isArray(model.architecture.input_modalities)).toBe(true)
376
+ expect(Array.isArray(model.architecture.output_modalities)).toBe(true)
377
+ }
378
+ }
379
+ })
380
+ })
381
+
382
+ describe('ResolvedModel type', () => {
383
+ it('returns complete resolution info', () => {
384
+ const result: ResolvedModel = resolveWithProvider('opus')
385
+ expect(result).toHaveProperty('id')
386
+ expect(result).toHaveProperty('provider')
387
+ expect(result).toHaveProperty('supportsDirectRouting')
388
+ expect(typeof result.id).toBe('string')
389
+ expect(typeof result.provider).toBe('string')
390
+ expect(typeof result.supportsDirectRouting).toBe('boolean')
391
+ })
392
+ })
package/src/models.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Model listing and resolution
3
+ */
4
+
5
+ import { createRequire } from 'module'
6
+ import { ALIASES } from './aliases.js'
7
+
8
+ const require = createRequire(import.meta.url)
9
+
10
+ /**
11
+ * Provider endpoint information for direct API access
12
+ */
13
+ export interface ProviderEndpoint {
14
+ /** Provider's API base URL (e.g., https://api.anthropic.com/v1) */
15
+ baseUrl: string
16
+ /** Provider's model ID (e.g., claude-opus-4-5-20251101) */
17
+ modelId: string
18
+ }
19
+
20
+ export interface ModelInfo {
21
+ id: string
22
+ name: string
23
+ description?: string
24
+ context_length: number
25
+ pricing: {
26
+ prompt: string
27
+ completion: string
28
+ }
29
+ architecture?: {
30
+ modality: string
31
+ input_modalities: string[]
32
+ output_modalities: string[]
33
+ }
34
+ /** Provider slug (e.g., 'anthropic', 'openai', 'google') */
35
+ provider?: string
36
+ /** Provider's native model ID for direct API calls */
37
+ provider_model_id?: string
38
+ /** Provider endpoint info for direct routing */
39
+ endpoint?: ProviderEndpoint
40
+ }
41
+
42
+ // Load models from JSON
43
+ let modelsCache: ModelInfo[] | null = null
44
+
45
+ function loadModels(): ModelInfo[] {
46
+ if (modelsCache) return modelsCache
47
+ try {
48
+ modelsCache = require('../data/models.json')
49
+ return modelsCache!
50
+ } catch {
51
+ return []
52
+ }
53
+ }
54
+
55
+ /**
56
+ * List all available models
57
+ */
58
+ export function list(): ModelInfo[] {
59
+ return loadModels()
60
+ }
61
+
62
+ /**
63
+ * Get a model by exact ID
64
+ */
65
+ export function get(id: string): ModelInfo | undefined {
66
+ return loadModels().find(m => m.id === id)
67
+ }
68
+
69
+ /**
70
+ * Search models by query string
71
+ * Searches in id and name fields
72
+ */
73
+ export function search(query: string): ModelInfo[] {
74
+ const q = query.toLowerCase()
75
+ return loadModels().filter(m =>
76
+ m.id.toLowerCase().includes(q) ||
77
+ m.name.toLowerCase().includes(q)
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Resolve a model alias or partial name to a full model ID
83
+ *
84
+ * Resolution order:
85
+ * 1. Check aliases (e.g., 'opus' -> 'anthropic/claude-opus-4.5')
86
+ * 2. Check if it's already a full ID (contains '/')
87
+ * 3. Search for first matching model
88
+ *
89
+ * @example
90
+ * resolve('opus') // 'anthropic/claude-opus-4.5'
91
+ * resolve('gpt-4o') // 'openai/gpt-4o'
92
+ * resolve('claude-sonnet') // 'anthropic/claude-sonnet-4.5'
93
+ * resolve('llama-70b') // 'meta-llama/llama-3.3-70b-instruct'
94
+ */
95
+ export function resolve(input: string): string {
96
+ const normalized = input.toLowerCase().trim()
97
+
98
+ // Check aliases first
99
+ if (ALIASES[normalized]) {
100
+ return ALIASES[normalized]
101
+ }
102
+
103
+ // Already a full ID with provider prefix
104
+ if (input.includes('/')) {
105
+ // Verify it exists or return as-is
106
+ const model = get(input)
107
+ return model?.id || input
108
+ }
109
+
110
+ // Search for matching model
111
+ const matches = search(normalized)
112
+ const firstMatch = matches[0]
113
+ if (firstMatch) {
114
+ return firstMatch.id
115
+ }
116
+
117
+ // Return as-is if nothing found
118
+ return input
119
+ }
120
+
121
+ /**
122
+ * Providers that support direct SDK access (not via OpenRouter)
123
+ * These providers have special capabilities like MCP, extended thinking, etc.
124
+ */
125
+ export const DIRECT_PROVIDERS = ['openai', 'anthropic', 'google'] as const
126
+ export type DirectProvider = typeof DIRECT_PROVIDERS[number]
127
+
128
+ /**
129
+ * Result of resolving a model with provider routing info
130
+ */
131
+ export interface ResolvedModel {
132
+ /** OpenRouter-style model ID (e.g., 'anthropic/claude-opus-4.5') */
133
+ id: string
134
+ /** Provider slug (e.g., 'anthropic', 'openai', 'google') */
135
+ provider: string
136
+ /** Provider's native model ID (e.g., 'claude-opus-4-5-20251101') */
137
+ providerModelId?: string
138
+ /** Whether this provider supports direct SDK routing */
139
+ supportsDirectRouting: boolean
140
+ /** Full model info if available */
141
+ model?: ModelInfo
142
+ }
143
+
144
+ /**
145
+ * Resolve a model alias and get full routing information
146
+ *
147
+ * @example
148
+ * const info = resolveWithProvider('opus')
149
+ * // {
150
+ * // id: 'anthropic/claude-opus-4.5',
151
+ * // provider: 'anthropic',
152
+ * // providerModelId: 'claude-opus-4-5-20251101',
153
+ * // supportsDirectRouting: true,
154
+ * // model: { ... }
155
+ * // }
156
+ */
157
+ export function resolveWithProvider(input: string): ResolvedModel {
158
+ const id = resolve(input)
159
+ const model = get(id)
160
+
161
+ // Extract provider from ID (e.g., 'anthropic' from 'anthropic/claude-opus-4.5')
162
+ const slashIndex = id.indexOf('/')
163
+ const provider = slashIndex > 0 ? id.substring(0, slashIndex) : 'unknown'
164
+
165
+ const supportsDirectRouting = (DIRECT_PROVIDERS as readonly string[]).includes(provider)
166
+
167
+ return {
168
+ id,
169
+ provider,
170
+ providerModelId: model?.provider_model_id,
171
+ supportsDirectRouting,
172
+ model
173
+ }
174
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
9
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: false,
6
+ environment: 'node',
7
+ include: ['src/**/*.test.ts'],
8
+ testTimeout: 10000,
9
+ hookTimeout: 10000,
10
+ },
11
+ })