language-models 2.0.1 → 2.1.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.
- package/.turbo/turbo-build.log +4 -5
- package/.turbo/turbo-test.log +7 -0
- package/CHANGELOG.md +11 -0
- package/package.json +2 -4
- package/scripts/fetch-models.js +90 -0
- package/src/aliases.js +40 -0
- package/src/aliases.test.js +264 -0
- package/src/index.js +9 -0
- package/src/index.test.js +320 -0
- package/src/models.js +108 -0
- package/src/models.test.js +335 -0
- package/vitest.config.js +10 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
>
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
|
|
2
|
+
> language-models@2.0.3 build /Users/nathanclevenger/projects/primitives.org.ai/packages/language-models
|
|
3
|
+
> tsc -p tsconfig.json && cp -r data dist/
|
|
4
|
+
|
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "language-models",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "Model listing and resolution for LLM providers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,5 @@
|
|
|
28
28
|
"primitives"
|
|
29
29
|
],
|
|
30
30
|
"license": "MIT",
|
|
31
|
-
"dependencies": {
|
|
32
|
-
"rpc.do": "^0.1.0"
|
|
33
|
-
}
|
|
31
|
+
"dependencies": {}
|
|
34
32
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Fetch models from OpenRouter API and save to data/models.json
|
|
4
|
+
*
|
|
5
|
+
* Fetches from both:
|
|
6
|
+
* - /api/v1/models - Basic model info
|
|
7
|
+
* - /api/frontend/models - Provider routing info (provider_model_id, endpoint)
|
|
8
|
+
*
|
|
9
|
+
* Usage: npx tsx scripts/fetch-models.ts
|
|
10
|
+
*/
|
|
11
|
+
import { writeFileSync } from 'fs';
|
|
12
|
+
import { dirname, join } from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const OUTPUT_PATH = join(__dirname, '..', 'data', 'models.json');
|
|
16
|
+
async function fetchModels() {
|
|
17
|
+
console.log('Fetching models from OpenRouter API...');
|
|
18
|
+
// Fetch basic model info
|
|
19
|
+
const modelsResponse = await fetch('https://openrouter.ai/api/v1/models');
|
|
20
|
+
if (!modelsResponse.ok) {
|
|
21
|
+
throw new Error(`Failed to fetch models: ${modelsResponse.status} ${modelsResponse.statusText}`);
|
|
22
|
+
}
|
|
23
|
+
const modelsData = await modelsResponse.json();
|
|
24
|
+
const models = modelsData.data || modelsData;
|
|
25
|
+
console.log(`Found ${models.length} models from v1 API`);
|
|
26
|
+
// Fetch frontend models for provider routing info
|
|
27
|
+
console.log('Fetching provider routing info from frontend API...');
|
|
28
|
+
let frontendModels = [];
|
|
29
|
+
try {
|
|
30
|
+
const frontendResponse = await fetch('https://openrouter.ai/api/frontend/models');
|
|
31
|
+
if (frontendResponse.ok) {
|
|
32
|
+
const frontendData = await frontendResponse.json();
|
|
33
|
+
frontendModels = Array.isArray(frontendData) ? frontendData : (frontendData.data || []);
|
|
34
|
+
console.log(`Found ${frontendModels.length} models from frontend API`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
console.warn('Could not fetch frontend models, continuing without provider routing info');
|
|
39
|
+
}
|
|
40
|
+
// Create a map of frontend models by slug for quick lookup
|
|
41
|
+
const frontendMap = new Map();
|
|
42
|
+
for (const fm of frontendModels) {
|
|
43
|
+
if (fm.slug) {
|
|
44
|
+
frontendMap.set(fm.slug, fm);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Merge frontend data into models
|
|
48
|
+
let enrichedCount = 0;
|
|
49
|
+
for (const model of models) {
|
|
50
|
+
const frontend = frontendMap.get(model.id);
|
|
51
|
+
if (frontend?.endpoint) {
|
|
52
|
+
// Add provider routing info from nested endpoint
|
|
53
|
+
if (frontend.endpoint.provider_model_id) {
|
|
54
|
+
model.provider_model_id = frontend.endpoint.provider_model_id;
|
|
55
|
+
}
|
|
56
|
+
if (frontend.endpoint.provider_slug) {
|
|
57
|
+
model.provider = frontend.endpoint.provider_slug;
|
|
58
|
+
}
|
|
59
|
+
if (frontend.endpoint.provider_info?.baseUrl) {
|
|
60
|
+
model.endpoint = {
|
|
61
|
+
baseUrl: frontend.endpoint.provider_info.baseUrl,
|
|
62
|
+
modelId: frontend.endpoint.provider_model_id || model.id.split('/')[1]
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
enrichedCount++;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Extract provider from ID as fallback
|
|
69
|
+
const slashIndex = model.id.indexOf('/');
|
|
70
|
+
if (slashIndex > 0) {
|
|
71
|
+
model.provider = model.id.substring(0, slashIndex);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
console.log(`Enriched ${enrichedCount} models with provider routing info`);
|
|
76
|
+
// Sort by created date (newest first)
|
|
77
|
+
models.sort((a, b) => (b.created || 0) - (a.created || 0));
|
|
78
|
+
writeFileSync(OUTPUT_PATH, JSON.stringify(models, null, 2));
|
|
79
|
+
console.log(`Saved to ${OUTPUT_PATH}`);
|
|
80
|
+
// Print some stats
|
|
81
|
+
const providers = new Set(models.map((m) => m.id.split('/')[0]));
|
|
82
|
+
console.log(`\nProviders: ${[...providers].join(', ')}`);
|
|
83
|
+
// Show which direct providers have routing info
|
|
84
|
+
const directProviders = ['openai', 'anthropic', 'google'];
|
|
85
|
+
for (const provider of directProviders) {
|
|
86
|
+
const withRouting = models.filter((m) => m.id.startsWith(`${provider}/`) && m.provider_model_id);
|
|
87
|
+
console.log(`${provider}: ${withRouting.length} models with provider_model_id`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
fetchModels().catch(console.error);
|
package/src/aliases.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model aliases - map simple names to full model IDs
|
|
3
|
+
*/
|
|
4
|
+
export const ALIASES = {
|
|
5
|
+
// Claude (Anthropic)
|
|
6
|
+
'opus': 'anthropic/claude-opus-4.5',
|
|
7
|
+
'sonnet': 'anthropic/claude-sonnet-4.5',
|
|
8
|
+
'haiku': 'anthropic/claude-haiku-4.5',
|
|
9
|
+
'claude': 'anthropic/claude-sonnet-4.5',
|
|
10
|
+
// GPT (OpenAI)
|
|
11
|
+
'gpt': 'openai/gpt-4o',
|
|
12
|
+
'gpt-4o': 'openai/gpt-4o',
|
|
13
|
+
'gpt-4o-mini': 'openai/gpt-4o-mini',
|
|
14
|
+
'4o': 'openai/gpt-4o',
|
|
15
|
+
'o1': 'openai/o1',
|
|
16
|
+
'o3': 'openai/o3',
|
|
17
|
+
'o3-mini': 'openai/o3-mini',
|
|
18
|
+
'o4-mini': 'openai/o4-mini',
|
|
19
|
+
// Gemini (Google)
|
|
20
|
+
'gemini': 'google/gemini-2.5-flash',
|
|
21
|
+
'flash': 'google/gemini-2.5-flash',
|
|
22
|
+
'gemini-flash': 'google/gemini-2.5-flash',
|
|
23
|
+
'gemini-pro': 'google/gemini-2.5-pro',
|
|
24
|
+
// Llama (Meta)
|
|
25
|
+
'llama': 'meta-llama/llama-4-maverick',
|
|
26
|
+
'llama-4': 'meta-llama/llama-4-maverick',
|
|
27
|
+
'llama-70b': 'meta-llama/llama-3.3-70b-instruct',
|
|
28
|
+
// DeepSeek
|
|
29
|
+
'deepseek': 'deepseek/deepseek-chat',
|
|
30
|
+
'r1': 'deepseek/deepseek-r1',
|
|
31
|
+
// Mistral
|
|
32
|
+
'mistral': 'mistralai/mistral-large-2411',
|
|
33
|
+
'codestral': 'mistralai/codestral-2501',
|
|
34
|
+
// Qwen
|
|
35
|
+
'qwen': 'qwen/qwen3-235b-a22b',
|
|
36
|
+
// Grok
|
|
37
|
+
'grok': 'x-ai/grok-3',
|
|
38
|
+
// Perplexity
|
|
39
|
+
'sonar': 'perplexity/sonar-pro',
|
|
40
|
+
};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for model aliases
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the alias mapping and ensure all documented
|
|
5
|
+
* aliases are present and correctly mapped.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { ALIASES } from './aliases.js';
|
|
9
|
+
describe('ALIASES', () => {
|
|
10
|
+
it('is an object', () => {
|
|
11
|
+
expect(typeof ALIASES).toBe('object');
|
|
12
|
+
expect(ALIASES).not.toBeNull();
|
|
13
|
+
});
|
|
14
|
+
it('has string keys and values', () => {
|
|
15
|
+
for (const [key, value] of Object.entries(ALIASES)) {
|
|
16
|
+
expect(typeof key).toBe('string');
|
|
17
|
+
expect(typeof value).toBe('string');
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
it('has no empty keys or values', () => {
|
|
21
|
+
for (const [key, value] of Object.entries(ALIASES)) {
|
|
22
|
+
expect(key.length).toBeGreaterThan(0);
|
|
23
|
+
expect(value.length).toBeGreaterThan(0);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
it('all values are valid model IDs with provider prefix', () => {
|
|
27
|
+
for (const [key, value] of Object.entries(ALIASES)) {
|
|
28
|
+
expect(value).toContain('/');
|
|
29
|
+
const [provider, modelName] = value.split('/');
|
|
30
|
+
expect(provider.length).toBeGreaterThan(0);
|
|
31
|
+
expect(modelName.length).toBeGreaterThan(0);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
describe('Claude (Anthropic) aliases', () => {
|
|
35
|
+
it('has opus alias', () => {
|
|
36
|
+
expect(ALIASES['opus']).toBe('anthropic/claude-opus-4.5');
|
|
37
|
+
});
|
|
38
|
+
it('has sonnet alias', () => {
|
|
39
|
+
expect(ALIASES['sonnet']).toBe('anthropic/claude-sonnet-4.5');
|
|
40
|
+
});
|
|
41
|
+
it('has haiku alias', () => {
|
|
42
|
+
expect(ALIASES['haiku']).toBe('anthropic/claude-haiku-4.5');
|
|
43
|
+
});
|
|
44
|
+
it('has claude default alias', () => {
|
|
45
|
+
expect(ALIASES['claude']).toBe('anthropic/claude-sonnet-4.5');
|
|
46
|
+
});
|
|
47
|
+
it('claude aliases point to anthropic provider', () => {
|
|
48
|
+
expect(ALIASES['opus']).toContain('anthropic/');
|
|
49
|
+
expect(ALIASES['sonnet']).toContain('anthropic/');
|
|
50
|
+
expect(ALIASES['haiku']).toContain('anthropic/');
|
|
51
|
+
expect(ALIASES['claude']).toContain('anthropic/');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('GPT (OpenAI) aliases', () => {
|
|
55
|
+
it('has gpt alias', () => {
|
|
56
|
+
expect(ALIASES['gpt']).toBe('openai/gpt-4o');
|
|
57
|
+
});
|
|
58
|
+
it('has gpt-4o alias', () => {
|
|
59
|
+
expect(ALIASES['gpt-4o']).toBe('openai/gpt-4o');
|
|
60
|
+
});
|
|
61
|
+
it('has gpt-4o-mini alias', () => {
|
|
62
|
+
expect(ALIASES['gpt-4o-mini']).toBe('openai/gpt-4o-mini');
|
|
63
|
+
});
|
|
64
|
+
it('has 4o shorthand', () => {
|
|
65
|
+
expect(ALIASES['4o']).toBe('openai/gpt-4o');
|
|
66
|
+
});
|
|
67
|
+
it('has o1 alias', () => {
|
|
68
|
+
expect(ALIASES['o1']).toBe('openai/o1');
|
|
69
|
+
});
|
|
70
|
+
it('has o3 alias', () => {
|
|
71
|
+
expect(ALIASES['o3']).toBe('openai/o3');
|
|
72
|
+
});
|
|
73
|
+
it('has o3-mini alias', () => {
|
|
74
|
+
expect(ALIASES['o3-mini']).toBe('openai/o3-mini');
|
|
75
|
+
});
|
|
76
|
+
it('has o4-mini alias', () => {
|
|
77
|
+
expect(ALIASES['o4-mini']).toBe('openai/o4-mini');
|
|
78
|
+
});
|
|
79
|
+
it('openai aliases point to openai provider', () => {
|
|
80
|
+
expect(ALIASES['gpt']).toContain('openai/');
|
|
81
|
+
expect(ALIASES['gpt-4o']).toContain('openai/');
|
|
82
|
+
expect(ALIASES['4o']).toContain('openai/');
|
|
83
|
+
expect(ALIASES['o1']).toContain('openai/');
|
|
84
|
+
expect(ALIASES['o3']).toContain('openai/');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('Gemini (Google) aliases', () => {
|
|
88
|
+
it('has gemini alias', () => {
|
|
89
|
+
expect(ALIASES['gemini']).toBe('google/gemini-2.5-flash');
|
|
90
|
+
});
|
|
91
|
+
it('has flash alias', () => {
|
|
92
|
+
expect(ALIASES['flash']).toBe('google/gemini-2.5-flash');
|
|
93
|
+
});
|
|
94
|
+
it('has gemini-flash alias', () => {
|
|
95
|
+
expect(ALIASES['gemini-flash']).toBe('google/gemini-2.5-flash');
|
|
96
|
+
});
|
|
97
|
+
it('has gemini-pro alias', () => {
|
|
98
|
+
expect(ALIASES['gemini-pro']).toBe('google/gemini-2.5-pro');
|
|
99
|
+
});
|
|
100
|
+
it('google aliases point to google provider', () => {
|
|
101
|
+
expect(ALIASES['gemini']).toContain('google/');
|
|
102
|
+
expect(ALIASES['flash']).toContain('google/');
|
|
103
|
+
expect(ALIASES['gemini-flash']).toContain('google/');
|
|
104
|
+
expect(ALIASES['gemini-pro']).toContain('google/');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe('Llama (Meta) aliases', () => {
|
|
108
|
+
it('has llama alias', () => {
|
|
109
|
+
expect(ALIASES['llama']).toBe('meta-llama/llama-4-maverick');
|
|
110
|
+
});
|
|
111
|
+
it('has llama-4 alias', () => {
|
|
112
|
+
expect(ALIASES['llama-4']).toBe('meta-llama/llama-4-maverick');
|
|
113
|
+
});
|
|
114
|
+
it('has llama-70b alias', () => {
|
|
115
|
+
expect(ALIASES['llama-70b']).toBe('meta-llama/llama-3.3-70b-instruct');
|
|
116
|
+
});
|
|
117
|
+
it('llama aliases point to meta-llama provider', () => {
|
|
118
|
+
expect(ALIASES['llama']).toContain('meta-llama/');
|
|
119
|
+
expect(ALIASES['llama-4']).toContain('meta-llama/');
|
|
120
|
+
expect(ALIASES['llama-70b']).toContain('meta-llama/');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('DeepSeek aliases', () => {
|
|
124
|
+
it('has deepseek alias', () => {
|
|
125
|
+
expect(ALIASES['deepseek']).toBe('deepseek/deepseek-chat');
|
|
126
|
+
});
|
|
127
|
+
it('has r1 alias', () => {
|
|
128
|
+
expect(ALIASES['r1']).toBe('deepseek/deepseek-r1');
|
|
129
|
+
});
|
|
130
|
+
it('deepseek aliases point to deepseek provider', () => {
|
|
131
|
+
expect(ALIASES['deepseek']).toContain('deepseek/');
|
|
132
|
+
expect(ALIASES['r1']).toContain('deepseek/');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe('Mistral aliases', () => {
|
|
136
|
+
it('has mistral alias', () => {
|
|
137
|
+
expect(ALIASES['mistral']).toBe('mistralai/mistral-large-2411');
|
|
138
|
+
});
|
|
139
|
+
it('has codestral alias', () => {
|
|
140
|
+
expect(ALIASES['codestral']).toBe('mistralai/codestral-2501');
|
|
141
|
+
});
|
|
142
|
+
it('mistral aliases point to mistralai provider', () => {
|
|
143
|
+
expect(ALIASES['mistral']).toContain('mistralai/');
|
|
144
|
+
expect(ALIASES['codestral']).toContain('mistralai/');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe('Qwen aliases', () => {
|
|
148
|
+
it('has qwen alias', () => {
|
|
149
|
+
expect(ALIASES['qwen']).toBe('qwen/qwen3-235b-a22b');
|
|
150
|
+
});
|
|
151
|
+
it('qwen alias points to qwen provider', () => {
|
|
152
|
+
expect(ALIASES['qwen']).toContain('qwen/');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe('Grok (X.AI) aliases', () => {
|
|
156
|
+
it('has grok alias', () => {
|
|
157
|
+
expect(ALIASES['grok']).toBe('x-ai/grok-3');
|
|
158
|
+
});
|
|
159
|
+
it('grok alias points to x-ai provider', () => {
|
|
160
|
+
expect(ALIASES['grok']).toContain('x-ai/');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe('Perplexity aliases', () => {
|
|
164
|
+
it('has sonar alias', () => {
|
|
165
|
+
expect(ALIASES['sonar']).toBe('perplexity/sonar-pro');
|
|
166
|
+
});
|
|
167
|
+
it('sonar alias points to perplexity provider', () => {
|
|
168
|
+
expect(ALIASES['sonar']).toContain('perplexity/');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
describe('alias uniqueness', () => {
|
|
172
|
+
it('has no duplicate keys', () => {
|
|
173
|
+
const keys = Object.keys(ALIASES);
|
|
174
|
+
const uniqueKeys = new Set(keys);
|
|
175
|
+
expect(keys.length).toBe(uniqueKeys.size);
|
|
176
|
+
});
|
|
177
|
+
it('has unique lowercase keys', () => {
|
|
178
|
+
const lowerKeys = Object.keys(ALIASES).map(k => k.toLowerCase());
|
|
179
|
+
const uniqueLowerKeys = new Set(lowerKeys);
|
|
180
|
+
expect(lowerKeys.length).toBe(uniqueLowerKeys.size);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe('alias conventions', () => {
|
|
184
|
+
it('uses lowercase keys', () => {
|
|
185
|
+
for (const key of Object.keys(ALIASES)) {
|
|
186
|
+
expect(key).toBe(key.toLowerCase());
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
it('uses lowercase provider names in values', () => {
|
|
190
|
+
for (const value of Object.values(ALIASES)) {
|
|
191
|
+
const provider = value.split('/')[0];
|
|
192
|
+
expect(provider).toBe(provider.toLowerCase());
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
it('uses kebab-case or lowercase for model names', () => {
|
|
196
|
+
for (const value of Object.values(ALIASES)) {
|
|
197
|
+
const modelName = value.split('/')[1];
|
|
198
|
+
// Model names should not have uppercase letters or spaces
|
|
199
|
+
expect(modelName).not.toMatch(/[A-Z]/);
|
|
200
|
+
expect(modelName).not.toContain(' ');
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe('provider coverage', () => {
|
|
205
|
+
it('covers major AI providers', () => {
|
|
206
|
+
const providers = new Set(Object.values(ALIASES).map(v => v.split('/')[0]));
|
|
207
|
+
expect(providers.has('anthropic')).toBe(true);
|
|
208
|
+
expect(providers.has('openai')).toBe(true);
|
|
209
|
+
expect(providers.has('google')).toBe(true);
|
|
210
|
+
expect(providers.has('meta-llama')).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
it('has multiple aliases per major provider', () => {
|
|
213
|
+
const providerCounts = {};
|
|
214
|
+
for (const value of Object.values(ALIASES)) {
|
|
215
|
+
const provider = value.split('/')[0];
|
|
216
|
+
providerCounts[provider] = (providerCounts[provider] || 0) + 1;
|
|
217
|
+
}
|
|
218
|
+
// Major providers should have multiple aliases
|
|
219
|
+
expect(providerCounts['anthropic']).toBeGreaterThanOrEqual(3);
|
|
220
|
+
expect(providerCounts['openai']).toBeGreaterThanOrEqual(5);
|
|
221
|
+
expect(providerCounts['google']).toBeGreaterThanOrEqual(3);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
describe('README documentation alignment', () => {
|
|
225
|
+
it('matches all aliases documented in README', () => {
|
|
226
|
+
// These are the aliases listed in the README.md table
|
|
227
|
+
const documentedAliases = {
|
|
228
|
+
'opus': 'anthropic/claude-opus-4.5',
|
|
229
|
+
'sonnet': 'anthropic/claude-sonnet-4.5',
|
|
230
|
+
'haiku': 'anthropic/claude-haiku-4.5',
|
|
231
|
+
'claude': 'anthropic/claude-sonnet-4.5',
|
|
232
|
+
'gpt': 'openai/gpt-4o',
|
|
233
|
+
'gpt-4o': 'openai/gpt-4o',
|
|
234
|
+
'4o': 'openai/gpt-4o',
|
|
235
|
+
'o1': 'openai/o1',
|
|
236
|
+
'o3': 'openai/o3',
|
|
237
|
+
'o3-mini': 'openai/o3-mini',
|
|
238
|
+
'gemini': 'google/gemini-2.5-flash',
|
|
239
|
+
'flash': 'google/gemini-2.5-flash',
|
|
240
|
+
'gemini-pro': 'google/gemini-2.5-pro',
|
|
241
|
+
'llama': 'meta-llama/llama-4-maverick',
|
|
242
|
+
'llama-4': 'meta-llama/llama-4-maverick',
|
|
243
|
+
'llama-70b': 'meta-llama/llama-3.3-70b-instruct',
|
|
244
|
+
'mistral': 'mistralai/mistral-large-2411',
|
|
245
|
+
'codestral': 'mistralai/codestral-2501',
|
|
246
|
+
'deepseek': 'deepseek/deepseek-chat',
|
|
247
|
+
'r1': 'deepseek/deepseek-r1',
|
|
248
|
+
'qwen': 'qwen/qwen3-235b-a22b',
|
|
249
|
+
'grok': 'x-ai/grok-3',
|
|
250
|
+
'sonar': 'perplexity/sonar-pro',
|
|
251
|
+
};
|
|
252
|
+
for (const [alias, expectedId] of Object.entries(documentedAliases)) {
|
|
253
|
+
expect(ALIASES[alias]).toBe(expectedId);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
describe('alias count', () => {
|
|
258
|
+
it('has reasonable number of aliases', () => {
|
|
259
|
+
const count = Object.keys(ALIASES).length;
|
|
260
|
+
expect(count).toBeGreaterThanOrEqual(20);
|
|
261
|
+
expect(count).toBeLessThanOrEqual(100);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* language-models - Model listing and resolution
|
|
3
|
+
*
|
|
4
|
+
* Lists all available models and resolves aliases to full model IDs.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
export { resolve, resolveWithProvider, list, get, search, DIRECT_PROVIDERS } from './models.js';
|
|
9
|
+
export { ALIASES } from './aliases.js';
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the language-models package
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the public API exports and end-to-end functionality.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { resolve, resolveWithProvider, list, get, search, DIRECT_PROVIDERS, ALIASES, } from './index.js';
|
|
8
|
+
describe('package exports', () => {
|
|
9
|
+
it('exports resolve function', () => {
|
|
10
|
+
expect(typeof resolve).toBe('function');
|
|
11
|
+
});
|
|
12
|
+
it('exports resolveWithProvider function', () => {
|
|
13
|
+
expect(typeof resolveWithProvider).toBe('function');
|
|
14
|
+
});
|
|
15
|
+
it('exports list function', () => {
|
|
16
|
+
expect(typeof list).toBe('function');
|
|
17
|
+
});
|
|
18
|
+
it('exports get function', () => {
|
|
19
|
+
expect(typeof get).toBe('function');
|
|
20
|
+
});
|
|
21
|
+
it('exports search function', () => {
|
|
22
|
+
expect(typeof search).toBe('function');
|
|
23
|
+
});
|
|
24
|
+
it('exports DIRECT_PROVIDERS constant', () => {
|
|
25
|
+
expect(DIRECT_PROVIDERS).toBeDefined();
|
|
26
|
+
expect(Array.isArray(DIRECT_PROVIDERS)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it('exports ALIASES constant', () => {
|
|
29
|
+
expect(ALIASES).toBeDefined();
|
|
30
|
+
expect(typeof ALIASES).toBe('object');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe('type exports', () => {
|
|
34
|
+
it('ModelInfo type is available', () => {
|
|
35
|
+
// Type check - if this compiles, the type is exported correctly
|
|
36
|
+
const model = {
|
|
37
|
+
id: 'test/model',
|
|
38
|
+
name: 'Test Model',
|
|
39
|
+
context_length: 8192,
|
|
40
|
+
pricing: {
|
|
41
|
+
prompt: '0',
|
|
42
|
+
completion: '0',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
expect(model).toBeDefined();
|
|
46
|
+
});
|
|
47
|
+
it('ProviderEndpoint type is available', () => {
|
|
48
|
+
const endpoint = {
|
|
49
|
+
baseUrl: 'https://api.example.com',
|
|
50
|
+
modelId: 'test-model-id',
|
|
51
|
+
};
|
|
52
|
+
expect(endpoint).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
it('ResolvedModel type is available', () => {
|
|
55
|
+
const resolved = {
|
|
56
|
+
id: 'test/model',
|
|
57
|
+
provider: 'test',
|
|
58
|
+
supportsDirectRouting: false,
|
|
59
|
+
};
|
|
60
|
+
expect(resolved).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
it('DirectProvider type is available', () => {
|
|
63
|
+
const provider = 'anthropic';
|
|
64
|
+
expect(provider).toBeDefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('end-to-end workflows', () => {
|
|
68
|
+
describe('simple alias resolution', () => {
|
|
69
|
+
it('resolves opus and gets model info', () => {
|
|
70
|
+
const modelId = resolve('opus');
|
|
71
|
+
expect(modelId).toBe('anthropic/claude-opus-4.5');
|
|
72
|
+
const model = get(modelId);
|
|
73
|
+
if (model) {
|
|
74
|
+
expect(model.id).toBe(modelId);
|
|
75
|
+
expect(model.name).toContain('Claude');
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
it('resolves gpt and gets model info', () => {
|
|
79
|
+
const modelId = resolve('gpt');
|
|
80
|
+
expect(modelId).toBe('openai/gpt-4o');
|
|
81
|
+
const model = get(modelId);
|
|
82
|
+
if (model) {
|
|
83
|
+
expect(model.id).toBe(modelId);
|
|
84
|
+
expect(model.name).toContain('GPT');
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe('advanced resolution with provider info', () => {
|
|
89
|
+
it('resolves opus with full provider details', () => {
|
|
90
|
+
const resolved = resolveWithProvider('opus');
|
|
91
|
+
expect(resolved.id).toBe('anthropic/claude-opus-4.5');
|
|
92
|
+
expect(resolved.provider).toBe('anthropic');
|
|
93
|
+
expect(resolved.supportsDirectRouting).toBe(true);
|
|
94
|
+
if (resolved.model) {
|
|
95
|
+
expect(resolved.model.name).toBeTruthy();
|
|
96
|
+
expect(resolved.model.context_length).toBeGreaterThan(0);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
it('resolves with provider model ID if available', () => {
|
|
100
|
+
const resolved = resolveWithProvider('opus');
|
|
101
|
+
if (resolved.model?.provider_model_id) {
|
|
102
|
+
expect(resolved.providerModelId).toBe(resolved.model.provider_model_id);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('search and select workflow', () => {
|
|
107
|
+
it('searches for claude models and selects one', () => {
|
|
108
|
+
const matches = search('claude');
|
|
109
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
110
|
+
const first = matches[0];
|
|
111
|
+
expect(first.id).toContain('/');
|
|
112
|
+
expect(first.name).toBeTruthy();
|
|
113
|
+
const retrieved = get(first.id);
|
|
114
|
+
expect(retrieved).toBeDefined();
|
|
115
|
+
expect(retrieved?.id).toBe(first.id);
|
|
116
|
+
});
|
|
117
|
+
it('searches for openai models', () => {
|
|
118
|
+
const matches = search('openai');
|
|
119
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
120
|
+
for (const model of matches) {
|
|
121
|
+
expect(model.id).toContain('openai/');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('listing and filtering', () => {
|
|
126
|
+
it('lists all models and filters by provider', () => {
|
|
127
|
+
const allModels = list();
|
|
128
|
+
expect(allModels.length).toBeGreaterThanOrEqual(0);
|
|
129
|
+
if (allModels.length > 0) {
|
|
130
|
+
const anthropicModels = allModels.filter(m => m.id.startsWith('anthropic/'));
|
|
131
|
+
const openaiModels = allModels.filter(m => m.id.startsWith('openai/'));
|
|
132
|
+
if (anthropicModels.length > 0) {
|
|
133
|
+
expect(anthropicModels[0].id).toContain('anthropic/');
|
|
134
|
+
}
|
|
135
|
+
if (openaiModels.length > 0) {
|
|
136
|
+
expect(openaiModels[0].id).toContain('openai/');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
it('lists models and checks for direct routing', () => {
|
|
141
|
+
const allModels = list();
|
|
142
|
+
if (allModels.length > 0) {
|
|
143
|
+
const directModels = allModels.filter(m => {
|
|
144
|
+
const provider = m.id.split('/')[0];
|
|
145
|
+
return DIRECT_PROVIDERS.includes(provider);
|
|
146
|
+
});
|
|
147
|
+
for (const model of directModels) {
|
|
148
|
+
const resolved = resolveWithProvider(model.id);
|
|
149
|
+
expect(resolved.supportsDirectRouting).toBe(true);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe('case insensitivity', () => {
|
|
155
|
+
it('resolves aliases case-insensitively', () => {
|
|
156
|
+
const lower = resolve('opus');
|
|
157
|
+
const upper = resolve('OPUS');
|
|
158
|
+
const mixed = resolve('OpUs');
|
|
159
|
+
expect(lower).toBe(upper);
|
|
160
|
+
expect(lower).toBe(mixed);
|
|
161
|
+
});
|
|
162
|
+
it('searches case-insensitively', () => {
|
|
163
|
+
const lower = search('claude');
|
|
164
|
+
const upper = search('CLAUDE');
|
|
165
|
+
expect(lower).toEqual(upper);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('unknown inputs', () => {
|
|
169
|
+
it('handles unknown alias gracefully', () => {
|
|
170
|
+
const result = resolve('unknown-alias-xyz');
|
|
171
|
+
expect(result).toBe('unknown-alias-xyz');
|
|
172
|
+
});
|
|
173
|
+
it('handles unknown model ID gracefully', () => {
|
|
174
|
+
const model = get('unknown/model-id');
|
|
175
|
+
expect(model).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
it('handles unknown search gracefully', () => {
|
|
178
|
+
const results = search('this-should-not-exist-xyz123');
|
|
179
|
+
expect(results).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
it('resolves unknown with provider info', () => {
|
|
182
|
+
const resolved = resolveWithProvider('unknown/model');
|
|
183
|
+
expect(resolved.id).toBe('unknown/model');
|
|
184
|
+
expect(resolved.provider).toBe('unknown');
|
|
185
|
+
expect(resolved.model).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe('README examples', () => {
|
|
189
|
+
it('works with README quick start examples', () => {
|
|
190
|
+
// Examples from README.md Quick Start section
|
|
191
|
+
const opus = resolve('opus');
|
|
192
|
+
expect(opus).toBe('anthropic/claude-opus-4.5');
|
|
193
|
+
const gpt4o = resolve('gpt-4o');
|
|
194
|
+
expect(gpt4o).toBe('openai/gpt-4o');
|
|
195
|
+
const llama70b = resolve('llama-70b');
|
|
196
|
+
expect(llama70b).toBe('meta-llama/llama-3.3-70b-instruct');
|
|
197
|
+
const mistral = resolve('mistral');
|
|
198
|
+
expect(mistral).toBe('mistralai/mistral-large-2411');
|
|
199
|
+
});
|
|
200
|
+
it('works with README API examples', () => {
|
|
201
|
+
// Examples from README.md API section
|
|
202
|
+
const opus = resolve('opus');
|
|
203
|
+
expect(opus).toBe('anthropic/claude-opus-4.5');
|
|
204
|
+
const sonnet = resolve('sonnet');
|
|
205
|
+
expect(sonnet).toBe('anthropic/claude-sonnet-4.5');
|
|
206
|
+
const gpt = resolve('gpt');
|
|
207
|
+
expect(gpt).toBe('openai/gpt-4o');
|
|
208
|
+
const llama = resolve('llama');
|
|
209
|
+
expect(llama).toBe('meta-llama/llama-4-maverick');
|
|
210
|
+
// Pass-through example
|
|
211
|
+
const fullId = resolve('anthropic/claude-opus-4.5');
|
|
212
|
+
expect(fullId).toBe('anthropic/claude-opus-4.5');
|
|
213
|
+
});
|
|
214
|
+
it('list returns array as shown in README', () => {
|
|
215
|
+
const models = list();
|
|
216
|
+
expect(Array.isArray(models)).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
it('search returns matching models as shown in README', () => {
|
|
219
|
+
const claudeModels = search('claude');
|
|
220
|
+
expect(Array.isArray(claudeModels)).toBe(true);
|
|
221
|
+
if (claudeModels.length > 0) {
|
|
222
|
+
expect(claudeModels.some(m => m.id.includes('claude') || m.name.toLowerCase().includes('claude'))).toBe(true);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
describe('data directory integration', () => {
|
|
227
|
+
it('loads models from data/models.json', () => {
|
|
228
|
+
const models = list();
|
|
229
|
+
// Should load from data directory, even if empty
|
|
230
|
+
expect(Array.isArray(models)).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
it('handles missing data file gracefully', () => {
|
|
233
|
+
// Even without data/models.json, functions should not throw
|
|
234
|
+
expect(() => list()).not.toThrow();
|
|
235
|
+
expect(() => get('test/model')).not.toThrow();
|
|
236
|
+
expect(() => search('test')).not.toThrow();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
describe('model metadata completeness', () => {
|
|
240
|
+
it('models have pricing information', () => {
|
|
241
|
+
const models = list();
|
|
242
|
+
if (models.length > 0) {
|
|
243
|
+
const model = models[0];
|
|
244
|
+
expect(model.pricing).toBeDefined();
|
|
245
|
+
expect(model.pricing.prompt).toBeDefined();
|
|
246
|
+
expect(model.pricing.completion).toBeDefined();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
it('models have context length', () => {
|
|
250
|
+
const models = list();
|
|
251
|
+
if (models.length > 0) {
|
|
252
|
+
for (const model of models.slice(0, 5)) {
|
|
253
|
+
expect(typeof model.context_length).toBe('number');
|
|
254
|
+
expect(model.context_length).toBeGreaterThan(0);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
it('models may have architecture info', () => {
|
|
259
|
+
const models = list();
|
|
260
|
+
if (models.length > 0) {
|
|
261
|
+
const modelWithArch = models.find(m => m.architecture);
|
|
262
|
+
if (modelWithArch?.architecture) {
|
|
263
|
+
expect(modelWithArch.architecture.modality).toBeDefined();
|
|
264
|
+
expect(Array.isArray(modelWithArch.architecture.input_modalities)).toBe(true);
|
|
265
|
+
expect(Array.isArray(modelWithArch.architecture.output_modalities)).toBe(true);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
describe('provider routing', () => {
|
|
271
|
+
it('correctly identifies direct routing providers', () => {
|
|
272
|
+
const directProviders = ['anthropic', 'openai', 'google'];
|
|
273
|
+
for (const provider of directProviders) {
|
|
274
|
+
const models = list().filter(m => m.id.startsWith(`${provider}/`));
|
|
275
|
+
if (models.length > 0) {
|
|
276
|
+
const resolved = resolveWithProvider(models[0].id);
|
|
277
|
+
expect(resolved.supportsDirectRouting).toBe(true);
|
|
278
|
+
expect(resolved.provider).toBe(provider);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
it('identifies non-direct routing providers', () => {
|
|
283
|
+
const models = list();
|
|
284
|
+
const nonDirectModel = models.find(m => {
|
|
285
|
+
const provider = m.id.split('/')[0];
|
|
286
|
+
return !DIRECT_PROVIDERS.includes(provider);
|
|
287
|
+
});
|
|
288
|
+
if (nonDirectModel) {
|
|
289
|
+
const resolved = resolveWithProvider(nonDirectModel.id);
|
|
290
|
+
expect(resolved.supportsDirectRouting).toBe(false);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
describe('consistency checks', () => {
|
|
296
|
+
it('all aliases resolve to valid format', () => {
|
|
297
|
+
for (const [alias, modelId] of Object.entries(ALIASES)) {
|
|
298
|
+
expect(modelId).toContain('/');
|
|
299
|
+
const [provider, model] = modelId.split('/');
|
|
300
|
+
expect(provider.length).toBeGreaterThan(0);
|
|
301
|
+
expect(model.length).toBeGreaterThan(0);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
it('resolve and resolveWithProvider are consistent', () => {
|
|
305
|
+
const testCases = ['opus', 'gpt', 'gemini', 'llama'];
|
|
306
|
+
for (const alias of testCases) {
|
|
307
|
+
const simpleResolve = resolve(alias);
|
|
308
|
+
const fullResolve = resolveWithProvider(alias);
|
|
309
|
+
expect(fullResolve.id).toBe(simpleResolve);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
it('search results are all valid models', () => {
|
|
313
|
+
const results = search('claude');
|
|
314
|
+
for (const model of results) {
|
|
315
|
+
const retrieved = get(model.id);
|
|
316
|
+
// All search results should be retrievable
|
|
317
|
+
expect(retrieved?.id).toBe(model.id);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
});
|
package/src/models.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model listing and resolution
|
|
3
|
+
*/
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
import { ALIASES } from './aliases.js';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
// Load models from JSON
|
|
8
|
+
let modelsCache = null;
|
|
9
|
+
function loadModels() {
|
|
10
|
+
if (modelsCache)
|
|
11
|
+
return modelsCache;
|
|
12
|
+
try {
|
|
13
|
+
modelsCache = require('../data/models.json');
|
|
14
|
+
return modelsCache;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* List all available models
|
|
22
|
+
*/
|
|
23
|
+
export function list() {
|
|
24
|
+
return loadModels();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Get a model by exact ID
|
|
28
|
+
*/
|
|
29
|
+
export function get(id) {
|
|
30
|
+
return loadModels().find(m => m.id === id);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Search models by query string
|
|
34
|
+
* Searches in id and name fields
|
|
35
|
+
*/
|
|
36
|
+
export function search(query) {
|
|
37
|
+
const q = query.toLowerCase();
|
|
38
|
+
return loadModels().filter(m => m.id.toLowerCase().includes(q) ||
|
|
39
|
+
m.name.toLowerCase().includes(q));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a model alias or partial name to a full model ID
|
|
43
|
+
*
|
|
44
|
+
* Resolution order:
|
|
45
|
+
* 1. Check aliases (e.g., 'opus' -> 'anthropic/claude-opus-4.5')
|
|
46
|
+
* 2. Check if it's already a full ID (contains '/')
|
|
47
|
+
* 3. Search for first matching model
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* resolve('opus') // 'anthropic/claude-opus-4.5'
|
|
51
|
+
* resolve('gpt-4o') // 'openai/gpt-4o'
|
|
52
|
+
* resolve('claude-sonnet') // 'anthropic/claude-sonnet-4.5'
|
|
53
|
+
* resolve('llama-70b') // 'meta-llama/llama-3.3-70b-instruct'
|
|
54
|
+
*/
|
|
55
|
+
export function resolve(input) {
|
|
56
|
+
const normalized = input.toLowerCase().trim();
|
|
57
|
+
// Check aliases first
|
|
58
|
+
if (ALIASES[normalized]) {
|
|
59
|
+
return ALIASES[normalized];
|
|
60
|
+
}
|
|
61
|
+
// Already a full ID with provider prefix
|
|
62
|
+
if (input.includes('/')) {
|
|
63
|
+
// Verify it exists or return as-is
|
|
64
|
+
const model = get(input);
|
|
65
|
+
return model?.id || input;
|
|
66
|
+
}
|
|
67
|
+
// Search for matching model
|
|
68
|
+
const matches = search(normalized);
|
|
69
|
+
const firstMatch = matches[0];
|
|
70
|
+
if (firstMatch) {
|
|
71
|
+
return firstMatch.id;
|
|
72
|
+
}
|
|
73
|
+
// Return as-is if nothing found
|
|
74
|
+
return input;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Providers that support direct SDK access (not via OpenRouter)
|
|
78
|
+
* These providers have special capabilities like MCP, extended thinking, etc.
|
|
79
|
+
*/
|
|
80
|
+
export const DIRECT_PROVIDERS = ['openai', 'anthropic', 'google'];
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a model alias and get full routing information
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* const info = resolveWithProvider('opus')
|
|
86
|
+
* // {
|
|
87
|
+
* // id: 'anthropic/claude-opus-4.5',
|
|
88
|
+
* // provider: 'anthropic',
|
|
89
|
+
* // providerModelId: 'claude-opus-4-5-20251101',
|
|
90
|
+
* // supportsDirectRouting: true,
|
|
91
|
+
* // model: { ... }
|
|
92
|
+
* // }
|
|
93
|
+
*/
|
|
94
|
+
export function resolveWithProvider(input) {
|
|
95
|
+
const id = resolve(input);
|
|
96
|
+
const model = get(id);
|
|
97
|
+
// Extract provider from ID (e.g., 'anthropic' from 'anthropic/claude-opus-4.5')
|
|
98
|
+
const slashIndex = id.indexOf('/');
|
|
99
|
+
const provider = slashIndex > 0 ? id.substring(0, slashIndex) : 'unknown';
|
|
100
|
+
const supportsDirectRouting = DIRECT_PROVIDERS.includes(provider);
|
|
101
|
+
return {
|
|
102
|
+
id,
|
|
103
|
+
provider,
|
|
104
|
+
providerModelId: model?.provider_model_id,
|
|
105
|
+
supportsDirectRouting,
|
|
106
|
+
model
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for model listing, resolution, and search
|
|
3
|
+
*
|
|
4
|
+
* These are pure unit tests - no external API calls needed.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
7
|
+
import { list, get, search, resolve, resolveWithProvider, DIRECT_PROVIDERS, } from './models.js';
|
|
8
|
+
import { ALIASES } from './aliases.js';
|
|
9
|
+
describe('list', () => {
|
|
10
|
+
it('returns an array of models', () => {
|
|
11
|
+
const models = list();
|
|
12
|
+
expect(Array.isArray(models)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
it('returns models with required properties', () => {
|
|
15
|
+
const models = list();
|
|
16
|
+
if (models.length > 0) {
|
|
17
|
+
const model = models[0];
|
|
18
|
+
expect(model).toHaveProperty('id');
|
|
19
|
+
expect(model).toHaveProperty('name');
|
|
20
|
+
expect(model).toHaveProperty('context_length');
|
|
21
|
+
expect(model).toHaveProperty('pricing');
|
|
22
|
+
expect(model.pricing).toHaveProperty('prompt');
|
|
23
|
+
expect(model.pricing).toHaveProperty('completion');
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
it('caches results on subsequent calls', () => {
|
|
27
|
+
const models1 = list();
|
|
28
|
+
const models2 = list();
|
|
29
|
+
expect(models1).toBe(models2); // Same reference
|
|
30
|
+
});
|
|
31
|
+
it('returns empty array if models.json does not exist', () => {
|
|
32
|
+
// This test verifies graceful handling of missing data file
|
|
33
|
+
const models = list();
|
|
34
|
+
expect(Array.isArray(models)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('get', () => {
|
|
38
|
+
it('returns undefined for non-existent model', () => {
|
|
39
|
+
const model = get('non-existent/model-id');
|
|
40
|
+
expect(model).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
it('returns model info for valid model ID', () => {
|
|
43
|
+
const models = list();
|
|
44
|
+
if (models.length > 0) {
|
|
45
|
+
const firstModel = models[0];
|
|
46
|
+
const retrieved = get(firstModel.id);
|
|
47
|
+
expect(retrieved).toBeDefined();
|
|
48
|
+
expect(retrieved?.id).toBe(firstModel.id);
|
|
49
|
+
expect(retrieved?.name).toBe(firstModel.name);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
it('performs exact match only', () => {
|
|
53
|
+
const models = list();
|
|
54
|
+
if (models.length > 0) {
|
|
55
|
+
const model = models[0];
|
|
56
|
+
const partialId = model.id.split('/')[0]; // Just the provider
|
|
57
|
+
const result = get(partialId);
|
|
58
|
+
// Should not match partial ID
|
|
59
|
+
if (result) {
|
|
60
|
+
expect(result.id).toBe(partialId); // Only matches if there's an exact model with this ID
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('search', () => {
|
|
66
|
+
it('returns empty array for no matches', () => {
|
|
67
|
+
const results = search('this-should-not-match-anything-12345');
|
|
68
|
+
expect(results).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
it('searches by model ID', () => {
|
|
71
|
+
const models = list();
|
|
72
|
+
if (models.length > 0) {
|
|
73
|
+
const model = models[0];
|
|
74
|
+
const idPart = model.id.split('/')[0]; // Provider name
|
|
75
|
+
const results = search(idPart);
|
|
76
|
+
expect(results.length).toBeGreaterThan(0);
|
|
77
|
+
expect(results.some(m => m.id.includes(idPart))).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
it('searches by model name', () => {
|
|
81
|
+
const models = list();
|
|
82
|
+
if (models.length > 0) {
|
|
83
|
+
const model = models[0];
|
|
84
|
+
const namePart = model.name.split(' ')[0].toLowerCase();
|
|
85
|
+
const results = search(namePart);
|
|
86
|
+
expect(results.length).toBeGreaterThan(0);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
it('is case-insensitive', () => {
|
|
90
|
+
const models = list();
|
|
91
|
+
if (models.length > 0) {
|
|
92
|
+
const model = models[0];
|
|
93
|
+
const idLower = model.id.toLowerCase();
|
|
94
|
+
const idUpper = model.id.toUpperCase();
|
|
95
|
+
const resultsLower = search(idLower);
|
|
96
|
+
const resultsUpper = search(idUpper);
|
|
97
|
+
expect(resultsLower).toEqual(resultsUpper);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it('searches in both id and name fields', () => {
|
|
101
|
+
const models = list();
|
|
102
|
+
if (models.length > 0) {
|
|
103
|
+
// Find a model and search for part of its name
|
|
104
|
+
const model = models.find(m => m.name.includes(' '));
|
|
105
|
+
if (model) {
|
|
106
|
+
const namePart = model.name.split(' ')[0].toLowerCase();
|
|
107
|
+
const results = search(namePart);
|
|
108
|
+
expect(results.some(m => m.id === model.id || m.name.toLowerCase().includes(namePart))).toBe(true);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
it('returns multiple matches', () => {
|
|
113
|
+
const models = list();
|
|
114
|
+
if (models.length > 1) {
|
|
115
|
+
// Search for a common term that should match multiple models
|
|
116
|
+
const commonProviders = ['anthropic', 'openai', 'google', 'meta'];
|
|
117
|
+
for (const provider of commonProviders) {
|
|
118
|
+
const results = search(provider);
|
|
119
|
+
if (results.length > 1) {
|
|
120
|
+
expect(results.length).toBeGreaterThan(1);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('resolve', () => {
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
// Ensure we have fresh data
|
|
130
|
+
list();
|
|
131
|
+
});
|
|
132
|
+
describe('alias resolution', () => {
|
|
133
|
+
it('resolves known aliases', () => {
|
|
134
|
+
const result = resolve('opus');
|
|
135
|
+
expect(result).toBe(ALIASES['opus']);
|
|
136
|
+
});
|
|
137
|
+
it('resolves claude alias', () => {
|
|
138
|
+
const result = resolve('claude');
|
|
139
|
+
expect(result).toBe(ALIASES['claude']);
|
|
140
|
+
});
|
|
141
|
+
it('resolves gpt alias', () => {
|
|
142
|
+
const result = resolve('gpt');
|
|
143
|
+
expect(result).toBe(ALIASES['gpt']);
|
|
144
|
+
});
|
|
145
|
+
it('resolves llama alias', () => {
|
|
146
|
+
const result = resolve('llama');
|
|
147
|
+
expect(result).toBe(ALIASES['llama']);
|
|
148
|
+
});
|
|
149
|
+
it('is case-insensitive for aliases', () => {
|
|
150
|
+
const lower = resolve('opus');
|
|
151
|
+
const upper = resolve('OPUS');
|
|
152
|
+
const mixed = resolve('Opus');
|
|
153
|
+
expect(lower).toBe(upper);
|
|
154
|
+
expect(lower).toBe(mixed);
|
|
155
|
+
});
|
|
156
|
+
it('handles whitespace in input', () => {
|
|
157
|
+
const result = resolve(' opus ');
|
|
158
|
+
expect(result).toBe(ALIASES['opus']);
|
|
159
|
+
});
|
|
160
|
+
it('resolves all documented aliases', () => {
|
|
161
|
+
// Test key aliases from the README
|
|
162
|
+
const aliasesToTest = [
|
|
163
|
+
['opus', 'anthropic/claude-opus-4.5'],
|
|
164
|
+
['sonnet', 'anthropic/claude-sonnet-4.5'],
|
|
165
|
+
['haiku', 'anthropic/claude-haiku-4.5'],
|
|
166
|
+
['gpt-4o', 'openai/gpt-4o'],
|
|
167
|
+
['gemini', 'google/gemini-2.5-flash'],
|
|
168
|
+
['llama-70b', 'meta-llama/llama-3.3-70b-instruct'],
|
|
169
|
+
['mistral', 'mistralai/mistral-large-2411'],
|
|
170
|
+
];
|
|
171
|
+
for (const [alias, expected] of aliasesToTest) {
|
|
172
|
+
const result = resolve(alias);
|
|
173
|
+
expect(result).toBe(expected);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe('full ID passthrough', () => {
|
|
178
|
+
it('returns full ID as-is if it exists', () => {
|
|
179
|
+
const models = list();
|
|
180
|
+
if (models.length > 0) {
|
|
181
|
+
const model = models[0];
|
|
182
|
+
const result = resolve(model.id);
|
|
183
|
+
expect(result).toBe(model.id);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
it('returns unknown full ID as-is', () => {
|
|
187
|
+
const unknownId = 'unknown-provider/unknown-model';
|
|
188
|
+
const result = resolve(unknownId);
|
|
189
|
+
expect(result).toBe(unknownId);
|
|
190
|
+
});
|
|
191
|
+
it('detects full ID by slash character', () => {
|
|
192
|
+
const result = resolve('custom/model-name');
|
|
193
|
+
expect(result).toBe('custom/model-name');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
describe('partial name search', () => {
|
|
197
|
+
it('finds model by partial name', () => {
|
|
198
|
+
const models = list();
|
|
199
|
+
if (models.length > 0) {
|
|
200
|
+
const model = models[0];
|
|
201
|
+
const provider = model.id.split('/')[0];
|
|
202
|
+
const result = resolve(provider);
|
|
203
|
+
// Should find a model from that provider
|
|
204
|
+
expect(result).toContain('/');
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
it('returns first match for partial search', () => {
|
|
208
|
+
const result = resolve('claude');
|
|
209
|
+
// Should return an alias if it exists, or search result
|
|
210
|
+
expect(result).toBeTruthy();
|
|
211
|
+
expect(typeof result).toBe('string');
|
|
212
|
+
});
|
|
213
|
+
it('returns input as-is if no matches found', () => {
|
|
214
|
+
const input = 'unknown-model-xyz';
|
|
215
|
+
const result = resolve(input);
|
|
216
|
+
expect(result).toBe(input);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
describe('resolution priority', () => {
|
|
220
|
+
it('prioritizes aliases over search', () => {
|
|
221
|
+
// 'opus' is an alias, so it should resolve to the alias target
|
|
222
|
+
// even if there are other models containing 'opus'
|
|
223
|
+
const result = resolve('opus');
|
|
224
|
+
expect(result).toBe(ALIASES['opus']);
|
|
225
|
+
});
|
|
226
|
+
it('checks full ID before partial search', () => {
|
|
227
|
+
const models = list();
|
|
228
|
+
if (models.length > 0) {
|
|
229
|
+
const model = models[0];
|
|
230
|
+
const result = resolve(model.id);
|
|
231
|
+
expect(result).toBe(model.id);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
describe('resolveWithProvider', () => {
|
|
237
|
+
it('extracts provider from model ID', () => {
|
|
238
|
+
const result = resolveWithProvider('opus');
|
|
239
|
+
expect(result.provider).toBe('anthropic');
|
|
240
|
+
});
|
|
241
|
+
it('includes resolved model ID', () => {
|
|
242
|
+
const result = resolveWithProvider('opus');
|
|
243
|
+
expect(result.id).toBe(ALIASES['opus']);
|
|
244
|
+
});
|
|
245
|
+
it('identifies direct routing support', () => {
|
|
246
|
+
const anthropic = resolveWithProvider('opus');
|
|
247
|
+
expect(anthropic.supportsDirectRouting).toBe(true);
|
|
248
|
+
const openai = resolveWithProvider('gpt');
|
|
249
|
+
expect(openai.supportsDirectRouting).toBe(true);
|
|
250
|
+
const google = resolveWithProvider('gemini');
|
|
251
|
+
expect(google.supportsDirectRouting).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
it('identifies non-direct providers', () => {
|
|
254
|
+
// Use a model from a provider not in DIRECT_PROVIDERS
|
|
255
|
+
const models = list();
|
|
256
|
+
const nonDirectModel = models.find(m => {
|
|
257
|
+
const provider = m.id.split('/')[0];
|
|
258
|
+
return !DIRECT_PROVIDERS.includes(provider);
|
|
259
|
+
});
|
|
260
|
+
if (nonDirectModel) {
|
|
261
|
+
const result = resolveWithProvider(nonDirectModel.id);
|
|
262
|
+
expect(result.supportsDirectRouting).toBe(false);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
it('includes full model info if available', () => {
|
|
266
|
+
const result = resolveWithProvider('opus');
|
|
267
|
+
if (result.model) {
|
|
268
|
+
expect(result.model).toHaveProperty('id');
|
|
269
|
+
expect(result.model).toHaveProperty('name');
|
|
270
|
+
expect(result.model).toHaveProperty('pricing');
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
it('includes provider model ID if available', () => {
|
|
274
|
+
const result = resolveWithProvider('opus');
|
|
275
|
+
if (result.model?.provider_model_id) {
|
|
276
|
+
expect(result.providerModelId).toBeDefined();
|
|
277
|
+
expect(typeof result.providerModelId).toBe('string');
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
it('handles unknown models gracefully', () => {
|
|
281
|
+
const result = resolveWithProvider('unknown/model');
|
|
282
|
+
expect(result.id).toBe('unknown/model');
|
|
283
|
+
expect(result.provider).toBe('unknown');
|
|
284
|
+
expect(result.model).toBeUndefined();
|
|
285
|
+
});
|
|
286
|
+
it('handles models without provider prefix', () => {
|
|
287
|
+
const result = resolveWithProvider('opus');
|
|
288
|
+
expect(result.provider).toBeTruthy();
|
|
289
|
+
expect(result.id).toContain('/');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
describe('DIRECT_PROVIDERS', () => {
|
|
293
|
+
it('contains expected providers', () => {
|
|
294
|
+
expect(DIRECT_PROVIDERS).toContain('anthropic');
|
|
295
|
+
expect(DIRECT_PROVIDERS).toContain('openai');
|
|
296
|
+
expect(DIRECT_PROVIDERS).toContain('google');
|
|
297
|
+
});
|
|
298
|
+
it('has exactly 3 providers', () => {
|
|
299
|
+
expect(DIRECT_PROVIDERS.length).toBe(3);
|
|
300
|
+
});
|
|
301
|
+
it('is readonly', () => {
|
|
302
|
+
// Type check - this should compile
|
|
303
|
+
const providers = DIRECT_PROVIDERS;
|
|
304
|
+
expect(providers).toBeDefined();
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
describe('ModelInfo type', () => {
|
|
308
|
+
it('models have correct structure', () => {
|
|
309
|
+
const models = list();
|
|
310
|
+
if (models.length > 0) {
|
|
311
|
+
const model = models[0];
|
|
312
|
+
expect(typeof model.id).toBe('string');
|
|
313
|
+
expect(typeof model.name).toBe('string');
|
|
314
|
+
expect(typeof model.context_length).toBe('number');
|
|
315
|
+
expect(typeof model.pricing.prompt).toBe('string');
|
|
316
|
+
expect(typeof model.pricing.completion).toBe('string');
|
|
317
|
+
if (model.architecture) {
|
|
318
|
+
expect(typeof model.architecture.modality).toBe('string');
|
|
319
|
+
expect(Array.isArray(model.architecture.input_modalities)).toBe(true);
|
|
320
|
+
expect(Array.isArray(model.architecture.output_modalities)).toBe(true);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
describe('ResolvedModel type', () => {
|
|
326
|
+
it('returns complete resolution info', () => {
|
|
327
|
+
const result = resolveWithProvider('opus');
|
|
328
|
+
expect(result).toHaveProperty('id');
|
|
329
|
+
expect(result).toHaveProperty('provider');
|
|
330
|
+
expect(result).toHaveProperty('supportsDirectRouting');
|
|
331
|
+
expect(typeof result.id).toBe('string');
|
|
332
|
+
expect(typeof result.provider).toBe('string');
|
|
333
|
+
expect(typeof result.supportsDirectRouting).toBe('boolean');
|
|
334
|
+
});
|
|
335
|
+
});
|