fix: better models.dev support

This commit is contained in:
2026-03-01 17:03:50 +01:00
parent e2c46e94aa
commit 63674266f5
9 changed files with 2140 additions and 371 deletions

View File

@@ -67,76 +67,21 @@ describe('OpenCodeManager model discovery', () => {
vi.useRealTimers();
});
describe('formatModelName', () => {
it('formats Claude model IDs with proper spacing', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('claude-opus-4-6')).toBe('Claude Opus 4.6');
expect(format('claude-sonnet-4-5')).toBe('Claude Sonnet 4.5');
expect(format('claude-sonnet-4')).toBe('Claude Sonnet 4');
expect(format('claude-haiku-4-5')).toBe('Claude Haiku 4.5');
expect(format('claude-3-5-haiku')).toBe('Claude 3.5 Haiku');
});
it('formats GPT model IDs with uppercase prefix', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('gpt-5')).toBe('GPT 5');
expect(format('gpt-5.1')).toBe('GPT 5.1');
expect(format('gpt-5.1-codex')).toBe('GPT 5.1 Codex');
expect(format('gpt-5.1-codex-max')).toBe('GPT 5.1 Codex Max');
expect(format('gpt-5.1-codex-mini')).toBe('GPT 5.1 Codex Mini');
expect(format('gpt-5-nano')).toBe('GPT 5 Nano');
expect(format('gpt-5.3-codex')).toBe('GPT 5.3 Codex');
});
it('formats GLM model IDs with uppercase prefix', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('glm-5')).toBe('GLM 5');
expect(format('glm-4.7')).toBe('GLM 4.7');
expect(format('glm-4.6')).toBe('GLM 4.6');
});
it('formats Gemini model IDs properly', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('gemini-3-pro')).toBe('Gemini 3 Pro');
expect(format('gemini-3-flash')).toBe('Gemini 3 Flash');
expect(format('gemini-3.1-pro')).toBe('Gemini 3.1 Pro');
});
it('formats free/preview suffixes', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('gpt-5-nano')).toBe('GPT 5 Nano');
expect(format('minimax-m2.5-free')).toBe('MiniMax M2.5 Free');
expect(format('kimi-k2.5-free')).toBe('Kimi K2.5 Free');
expect(format('trinity-large-preview-free')).toBe('Trinity Large Preview Free');
});
it('formats other provider model IDs', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('minimax-m2.5')).toBe('MiniMax M2.5');
expect(format('minimax-m2.1')).toBe('MiniMax M2.1');
expect(format('kimi-k2.5')).toBe('Kimi K2.5');
expect(format('kimi-k2')).toBe('Kimi K2');
expect(format('kimi-k2-thinking')).toBe('Kimi K2 Thinking');
expect(format('qwen3-coder')).toBe('Qwen3 Coder');
expect(format('big-pickle')).toBe('Big Pickle');
});
});
describe('getAvailableModels', () => {
it('returns models from API with proper names and providers', async () => {
it('returns models from API with catalog names and catalog-derived vision', async () => {
const manager = createManager();
// Mock catalog with modality data and display names
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image', 'pdf'], outputModalities: ['text'] },
{ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', inputModalities: ['text'], outputModalities: ['text'] },
{ id: 'gemini-3-pro', name: 'Gemini 3 Pro', inputModalities: ['text', 'image', 'video'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
const zenResponse = createZenModelResponse([
'claude-sonnet-4',
'gpt-5.1-codex',
@@ -156,30 +101,45 @@ describe('OpenCodeManager model discovery', () => {
expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', vision: true });
});
it('falls back to known models when API fails', async () => {
it('falls back to model catalog when API fails', async () => {
const manager = createManager();
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
{ id: 'gpt-5', name: 'GPT 5', inputModalities: ['text', 'image'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
const models = await manager.getAvailableModels();
expect(models.length).toBeGreaterThan(0);
// Should include well-known models from the display name map
const ids = models.map((m: ChatModel) => m.id);
expect(ids).toContain('claude-sonnet-4');
expect(ids).toContain('gpt-5');
// Every model should have proper provider detection
const claudeModel = models.find((m: ChatModel) => m.id === 'claude-sonnet-4');
expect(claudeModel?.provider).toBe('anthropic');
expect(claudeModel?.name).toBe('Claude Sonnet 4');
const gptModel = models.find((m: ChatModel) => m.id === 'gpt-5');
expect(gptModel?.provider).toBe('openai');
expect(gptModel?.name).toBe('GPT 5');
});
it('falls back when API returns non-200 status', async () => {
it('falls back to model catalog when API returns non-200 status', async () => {
const manager = createManager();
(manager as any).httpRequest = vi.fn().mockResolvedValue({
statusCode: 401,
body: '{"error":"unauthorized"}',
});
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
const models = await manager.getAvailableModels();
@@ -220,7 +180,7 @@ describe('OpenCodeManager model discovery', () => {
expect(httpRequest).toHaveBeenCalledTimes(2);
});
it('handles unknown model IDs from API with auto-formatting', async () => {
it('handles unknown model IDs from API with raw IDs as fallback names', async () => {
const manager = createManager();
const zenResponse = createZenModelResponse(['some-new-model-v3']);
@@ -232,15 +192,22 @@ describe('OpenCodeManager model discovery', () => {
const models = await manager.getAvailableModels();
expect(models).toHaveLength(1);
expect(models[0].name).toBe('Some New Model V3');
expect(models[0].name).toBe('some-new-model-v3');
expect(models[0].provider).toBe('other');
});
it('falls back to known models when no API key is set', async () => {
it('falls back to model catalog when no API key is set', async () => {
const manager = createManager();
(manager as any).apiKey = '';
// Set a key so fallback filtering works (at least one provider must have a key)
manager.setMistralApiKey('test-key');
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
const models = await manager.getAvailableModels();
@@ -248,6 +215,8 @@ describe('OpenCodeManager model discovery', () => {
expect(models.length).toBeGreaterThan(0);
const providers = new Set(models.map((m: ChatModel) => m.provider));
expect(providers.has('mistral')).toBe(true);
// OpenCode/Anthropic models should be filtered out (no OpenCode key)
expect(providers.has('anthropic')).toBe(false);
});
});
});