fix: better models.dev support
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
* ModelCatalogEngine Tests
|
||||
*
|
||||
* Tests the model catalog engine that fetches and caches
|
||||
* model metadata from models.dev for the OpenCode provider.
|
||||
* model metadata from models.dev for ALL providers.
|
||||
* Three normalised tables: providers → models → modalities.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
@@ -19,12 +20,37 @@ function createSelectChain(mockData: unknown[] = []) {
|
||||
return chain;
|
||||
}
|
||||
|
||||
let selectMockData: unknown[] = [];
|
||||
// Per-table mock data keyed by table name reference
|
||||
let modelMockData: unknown[] = [];
|
||||
let modalityMockData: unknown[] = [];
|
||||
let providerMockData: unknown[] = [];
|
||||
let metaMockData: unknown[] = [];
|
||||
const insertedValues: unknown[] = [];
|
||||
|
||||
function createDrizzleMock() {
|
||||
return {
|
||||
select: vi.fn(() => createSelectChain(selectMockData)),
|
||||
select: vi.fn(() => {
|
||||
// Returns a chain whose `.from()` picks the right dataset by table reference
|
||||
const chain: Record<string, unknown> = {
|
||||
from: vi.fn().mockImplementation((table: unknown) => {
|
||||
let data: unknown[];
|
||||
if (table === modelCatalogModalities) {
|
||||
data = modalityMockData;
|
||||
} else if (table === modelCatalogProviders) {
|
||||
data = providerMockData;
|
||||
} else if (table === modelCatalogMeta) {
|
||||
data = metaMockData;
|
||||
} else {
|
||||
data = modelMockData;
|
||||
}
|
||||
const inner = createSelectChain(data);
|
||||
return inner;
|
||||
}),
|
||||
where: vi.fn().mockImplementation(() => chain),
|
||||
then: (resolve: (v: unknown) => void) => Promise.resolve(modelMockData).then(resolve),
|
||||
};
|
||||
return chain;
|
||||
}),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn((data: unknown) => {
|
||||
insertedValues.push(data);
|
||||
@@ -49,13 +75,19 @@ vi.mock('../../src/main/database', () => ({
|
||||
}));
|
||||
|
||||
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from '../../src/main/engine/ModelCatalogEngine';
|
||||
import { modelCatalog, modelCatalogModalities, modelCatalogProviders, modelCatalogMeta } from '../../src/main/database/schema';
|
||||
|
||||
// ── Sample models.dev response ──
|
||||
// ── Sample models.dev response (multi-provider) ──
|
||||
|
||||
function sampleModelsDevResponse() {
|
||||
return {
|
||||
opencode: {
|
||||
id: 'opencode',
|
||||
name: 'OpenCode Zen',
|
||||
env: ['OPENCODE_API_KEY'],
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
api: 'https://opencode.ai/zen/v1',
|
||||
doc: 'https://opencode.ai/docs/zen',
|
||||
models: {
|
||||
'claude-sonnet-4-5': {
|
||||
id: 'claude-sonnet-4-5',
|
||||
@@ -64,6 +96,7 @@ function sampleModelsDevResponse() {
|
||||
attachment: true,
|
||||
reasoning: false,
|
||||
tool_call: true,
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
cost: { input: 3, output: 15, cache_read: 0.3 },
|
||||
limit: { context: 200000, output: 64000 },
|
||||
},
|
||||
@@ -74,6 +107,7 @@ function sampleModelsDevResponse() {
|
||||
attachment: true,
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
cost: { input: 1.07, output: 8.5, cache_read: 0.107 },
|
||||
limit: { context: 400000, input: 272000, output: 128000 },
|
||||
},
|
||||
@@ -81,10 +115,32 @@ function sampleModelsDevResponse() {
|
||||
id: 'model-no-cost',
|
||||
name: 'Free Model',
|
||||
family: 'free',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
limit: { context: 32000, output: 4096 },
|
||||
},
|
||||
},
|
||||
},
|
||||
mistral: {
|
||||
id: 'mistral',
|
||||
name: 'Mistral AI',
|
||||
env: ['MISTRAL_API_KEY'],
|
||||
npm: '@mistralai/mistralai',
|
||||
api: 'https://api.mistral.ai/v1',
|
||||
doc: 'https://docs.mistral.ai',
|
||||
models: {
|
||||
'mistral-large-latest': {
|
||||
id: 'mistral-large-latest',
|
||||
name: 'Mistral Large',
|
||||
family: 'mistral',
|
||||
attachment: true,
|
||||
reasoning: false,
|
||||
tool_call: true,
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
cost: { input: 2, output: 6 },
|
||||
limit: { context: 128000, output: 8192 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,53 +149,75 @@ describe('ModelCatalogEngine', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
selectMockData = [];
|
||||
modelMockData = [];
|
||||
modalityMockData = [];
|
||||
providerMockData = [];
|
||||
metaMockData = [];
|
||||
insertedValues.length = 0;
|
||||
engine = new ModelCatalogEngine();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('returns all cached model catalog entries', async () => {
|
||||
selectMockData = [
|
||||
it('returns all cached model catalog entries with modalities', async () => {
|
||||
modelMockData = [
|
||||
{
|
||||
id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet',
|
||||
provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5',
|
||||
family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true,
|
||||
structuredOutput: false, temperature: false, knowledge: null,
|
||||
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
|
||||
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3,
|
||||
supportsAttachments: true, supportsReasoning: false, supportsToolCall: true,
|
||||
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
|
||||
interleaved: null, status: null, providerNpm: null,
|
||||
},
|
||||
];
|
||||
modalityMockData = [
|
||||
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'text' },
|
||||
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'image' },
|
||||
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'output', modality: 'text' },
|
||||
];
|
||||
|
||||
const result = await engine.getAll();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('claude-sonnet-4-5');
|
||||
expect(result[0].provider).toBe('opencode');
|
||||
expect(result[0].maxOutputTokens).toBe(64000);
|
||||
expect(result[0].inputPrice).toBe(3);
|
||||
expect(result[0].inputModalities).toEqual(['text', 'image']);
|
||||
expect(result[0].outputModalities).toEqual(['text']);
|
||||
});
|
||||
|
||||
it('returns empty array when no catalog entries exist', async () => {
|
||||
selectMockData = [];
|
||||
const result = await engine.getAll();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModel', () => {
|
||||
it('returns a specific model by ID', async () => {
|
||||
selectMockData = [{
|
||||
id: 'gpt-5', name: 'GPT 5', family: 'gpt',
|
||||
it('returns a specific model by ID (cross-provider search)', async () => {
|
||||
modelMockData = [{
|
||||
provider: 'opencode', modelId: 'gpt-5', name: 'GPT 5', family: 'gpt',
|
||||
attachment: true, reasoning: true, toolCall: true,
|
||||
structuredOutput: false, temperature: false, knowledge: null,
|
||||
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||
contextWindow: 400000, maxInputTokens: 272000, maxOutputTokens: 128000,
|
||||
inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107,
|
||||
supportsAttachments: true, supportsReasoning: true, supportsToolCall: true,
|
||||
inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107, cacheWritePrice: null,
|
||||
interleaved: null, status: null, providerNpm: null,
|
||||
}];
|
||||
modalityMockData = [
|
||||
{ provider: 'opencode', modelId: 'gpt-5', direction: 'input', modality: 'text' },
|
||||
{ provider: 'opencode', modelId: 'gpt-5', direction: 'input', modality: 'image' },
|
||||
];
|
||||
|
||||
const result = await engine.getModel('gpt-5');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('GPT 5');
|
||||
expect(result!.maxOutputTokens).toBe(128000);
|
||||
expect(result!.inputModalities).toEqual(['text', 'image']);
|
||||
});
|
||||
|
||||
it('returns null for unknown model', async () => {
|
||||
selectMockData = [];
|
||||
modelMockData = [];
|
||||
modalityMockData = [];
|
||||
const result = await engine.getModel('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -147,38 +225,92 @@ describe('ModelCatalogEngine', () => {
|
||||
|
||||
describe('getMaxOutputTokens', () => {
|
||||
it('returns output tokens from catalog when available', async () => {
|
||||
selectMockData = [{
|
||||
id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet',
|
||||
modelMockData = [{
|
||||
provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5',
|
||||
family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true,
|
||||
structuredOutput: false, temperature: false, knowledge: null,
|
||||
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
|
||||
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3,
|
||||
supportsAttachments: true, supportsReasoning: false, supportsToolCall: true,
|
||||
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
|
||||
interleaved: null, status: null, providerNpm: null,
|
||||
}];
|
||||
modalityMockData = [];
|
||||
|
||||
const result = await engine.getMaxOutputTokens('claude-sonnet-4-5');
|
||||
expect(result).toBe(64000);
|
||||
});
|
||||
|
||||
it('returns DEFAULT_MAX_OUTPUT_TOKENS for uncatalogued model', async () => {
|
||||
selectMockData = [];
|
||||
modelMockData = [];
|
||||
modalityMockData = [];
|
||||
const result = await engine.getMaxOutputTokens('unknown-model');
|
||||
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
||||
});
|
||||
|
||||
it('returns DEFAULT_MAX_OUTPUT_TOKENS when model has null maxOutputTokens', async () => {
|
||||
selectMockData = [{
|
||||
id: 'weird-model', name: 'Weird', family: null,
|
||||
modelMockData = [{
|
||||
provider: 'opencode', modelId: 'weird-model', name: 'Weird', family: null,
|
||||
attachment: false, reasoning: false, toolCall: false,
|
||||
structuredOutput: false, temperature: false, knowledge: null,
|
||||
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||
contextWindow: null, maxInputTokens: null, maxOutputTokens: null,
|
||||
inputPrice: null, outputPrice: null, cacheReadPrice: null,
|
||||
supportsAttachments: false, supportsReasoning: false, supportsToolCall: false,
|
||||
inputPrice: null, outputPrice: null, cacheReadPrice: null, cacheWritePrice: null,
|
||||
interleaved: null, status: null, providerNpm: null,
|
||||
}];
|
||||
modalityMockData = [];
|
||||
|
||||
const result = await engine.getMaxOutputTokens('weird-model');
|
||||
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasInputModality', () => {
|
||||
it('returns true when model has the modality', async () => {
|
||||
modelMockData = [{
|
||||
provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5',
|
||||
family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true,
|
||||
structuredOutput: false, temperature: false, knowledge: null,
|
||||
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
|
||||
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
|
||||
interleaved: null, status: null, providerNpm: null,
|
||||
}];
|
||||
modalityMockData = [
|
||||
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'image' },
|
||||
];
|
||||
|
||||
const result = await engine.hasInputModality('claude-sonnet-4-5', 'image');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when model lacks the modality', async () => {
|
||||
modelMockData = [{
|
||||
provider: 'opencode', modelId: 'text-only', name: 'Text Only',
|
||||
family: null, attachment: false, reasoning: false, toolCall: false,
|
||||
structuredOutput: false, temperature: false, knowledge: null,
|
||||
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||
contextWindow: 32000, maxInputTokens: null, maxOutputTokens: 4096,
|
||||
inputPrice: null, outputPrice: null, cacheReadPrice: null, cacheWritePrice: null,
|
||||
interleaved: null, status: null, providerNpm: null,
|
||||
}];
|
||||
modalityMockData = [
|
||||
{ provider: 'opencode', modelId: 'text-only', direction: 'input', modality: 'text' },
|
||||
];
|
||||
|
||||
const result = await engine.hasInputModality('text-only', 'image');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown model', async () => {
|
||||
modelMockData = [];
|
||||
modalityMockData = [];
|
||||
const result = await engine.hasInputModality('nonexistent', 'image');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('parses models.dev response and inserts models into DB', async () => {
|
||||
it('parses multi-provider models.dev response and inserts all providers and models', async () => {
|
||||
const mockResponse = sampleModelsDevResponse();
|
||||
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
|
||||
statusCode: 200,
|
||||
@@ -186,13 +318,16 @@ describe('ModelCatalogEngine', () => {
|
||||
headers: { etag: '"abc123"' },
|
||||
});
|
||||
|
||||
// getMeta returns null (no existing etag)
|
||||
selectMockData = [];
|
||||
metaMockData = [];
|
||||
|
||||
const result = await engine.refresh();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.modelsUpdated).toBe(3);
|
||||
// 3 opencode models + 1 mistral model = 4
|
||||
expect(result.modelsUpdated).toBe(4);
|
||||
expect(result.notModified).toBeUndefined();
|
||||
|
||||
// Should have inserted provider rows and model rows and modality rows
|
||||
expect(insertedValues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('sends If-None-Match header when ETag is cached', async () => {
|
||||
@@ -208,7 +343,9 @@ describe('ModelCatalogEngine', () => {
|
||||
mockLocalDb.select = vi.fn(() => {
|
||||
metaCallCount++;
|
||||
if (metaCallCount === 1) {
|
||||
return createSelectChain([{ key: 'etag', value: '"old-etag"' }]);
|
||||
// getMeta('etag') → picks up model_catalog_meta table
|
||||
const chain = createSelectChain([{ key: 'etag', value: '"old-etag"' }]);
|
||||
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue(chain), ...chain }), ...chain };
|
||||
}
|
||||
return createSelectChain([]);
|
||||
}) as any;
|
||||
@@ -231,7 +368,7 @@ describe('ModelCatalogEngine', () => {
|
||||
body: 'Internal Server Error',
|
||||
headers: {},
|
||||
});
|
||||
selectMockData = [];
|
||||
metaMockData = [];
|
||||
|
||||
const result = await engine.refresh();
|
||||
expect(result.success).toBe(false);
|
||||
@@ -240,24 +377,24 @@ describe('ModelCatalogEngine', () => {
|
||||
|
||||
it('handles network errors gracefully', async () => {
|
||||
vi.spyOn(engine as any, 'httpGet').mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
selectMockData = [];
|
||||
metaMockData = [];
|
||||
|
||||
const result = await engine.refresh();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('handles invalid response (missing opencode provider)', async () => {
|
||||
it('handles invalid response (no providers)', async () => {
|
||||
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({ other_provider: { models: {} } }),
|
||||
body: JSON.stringify({}),
|
||||
headers: {},
|
||||
});
|
||||
selectMockData = [];
|
||||
metaMockData = [];
|
||||
|
||||
const result = await engine.refresh();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('no opencode models');
|
||||
expect(result.error).toContain('no providers');
|
||||
});
|
||||
|
||||
it('handles malformed JSON gracefully', async () => {
|
||||
@@ -266,7 +403,7 @@ describe('ModelCatalogEngine', () => {
|
||||
body: 'not valid json {{{',
|
||||
headers: {},
|
||||
});
|
||||
selectMockData = [];
|
||||
metaMockData = [];
|
||||
|
||||
const result = await engine.refresh();
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
* - getAvailableModels() merge from both providers
|
||||
* - getProviderConfig() helper
|
||||
* - isProviderKeySet() helper
|
||||
* - MODEL_CONTEXT_BUDGETS correctness
|
||||
* - MODEL_CAPABILITIES (vision flags)
|
||||
* - Vision from catalog modalities
|
||||
* - validateMistralApiKey()
|
||||
* - Provider-aware routing in sendOpenAIMessage()
|
||||
* - generateConversationTitle() provider routing
|
||||
@@ -336,10 +335,20 @@ describe('OpenCodeManager Mistral integration', () => {
|
||||
expect(providers.has('mistral')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes vision field on models', async () => {
|
||||
it('includes vision field from catalog modalities', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
|
||||
// Mock catalog with modality data for vision resolution
|
||||
(manager as any).modelCatalogEngine = {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'devstral-small-latest', name: 'Devstral Small', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
]),
|
||||
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('mistral.ai')) {
|
||||
return Promise.resolve({
|
||||
@@ -367,6 +376,14 @@ describe('OpenCodeManager Mistral integration', () => {
|
||||
// No OpenCode key set
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
(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();
|
||||
// Should only have Mistral models from fallback
|
||||
@@ -409,19 +426,6 @@ describe('OpenCodeManager Mistral integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MODEL_DISPLAY_NAMES includes Mistral models', () => {
|
||||
it('has display names for all target Mistral models', () => {
|
||||
const manager = createManager();
|
||||
const format = (manager as any).formatModelName.bind(manager);
|
||||
|
||||
expect(format('mistral-large-latest')).toBe('Mistral Large');
|
||||
expect(format('mistral-medium-latest')).toBe('Mistral Medium');
|
||||
expect(format('mistral-small-latest')).toBe('Mistral Small');
|
||||
expect(format('devstral-small-latest')).toBe('Devstral Small');
|
||||
expect(format('devstral-large-latest')).toBe('Devstral Large');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateConversationTitle provider routing', () => {
|
||||
it('uses Mistral API when conversation model is a Mistral model', async () => {
|
||||
const manager = createManager();
|
||||
@@ -529,39 +533,24 @@ describe('OpenCodeManager Mistral integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MODEL_CONTEXT_BUDGETS', () => {
|
||||
it('has correct budget values for all Mistral models', () => {
|
||||
// Access the constant via a model that triggers truncation path
|
||||
const manager = createManager();
|
||||
// We verify the budgets via the getProviderConfig indirectly,
|
||||
// but here we check them via the module-level constant accessed via the manager
|
||||
// by using sendOpenAIMessage truncation behavior.
|
||||
// Since the budgets map is not exported, we test the values are correct
|
||||
// by checking the truncation call parameter via a mock.
|
||||
|
||||
// Access budgets through internal reference
|
||||
const budgets: Record<string, number> = {
|
||||
'mistral-large-latest': 35_000,
|
||||
'mistral-medium-latest': 35_000,
|
||||
'mistral-small-latest': 120_000,
|
||||
'devstral-small-latest': 120_000,
|
||||
'devstral-large-latest': 240_000,
|
||||
};
|
||||
|
||||
// Verify each budget is reasonable (within expected ranges)
|
||||
expect(budgets['mistral-large-latest']).toBe(35_000);
|
||||
expect(budgets['mistral-medium-latest']).toBe(35_000);
|
||||
expect(budgets['mistral-small-latest']).toBe(120_000);
|
||||
expect(budgets['devstral-small-latest']).toBe(120_000);
|
||||
expect(budgets['devstral-large-latest']).toBe(240_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MODEL_CAPABILITIES', () => {
|
||||
it('vision flags are correct for Mistral models via getAvailableModels', async () => {
|
||||
describe('vision from catalog modalities', () => {
|
||||
it('vision flags are derived from catalog input modalities via getAvailableModels', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
|
||||
// Mock catalog with modality data
|
||||
(manager as any).modelCatalogEngine = {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'mistral-medium-latest', name: 'Mistral Medium', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'devstral-small-latest', name: 'Devstral Small', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
{ id: 'devstral-large-latest', name: 'Devstral Large', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
]),
|
||||
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('mistral.ai')) {
|
||||
return Promise.resolve({
|
||||
@@ -580,12 +569,12 @@ describe('OpenCodeManager Mistral integration', () => {
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
// Vision-capable models
|
||||
// Vision-capable models (image in input modalities)
|
||||
expect(models.find((m: ChatModel) => m.id === 'mistral-large-latest')?.vision).toBe(true);
|
||||
expect(models.find((m: ChatModel) => m.id === 'mistral-medium-latest')?.vision).toBe(true);
|
||||
expect(models.find((m: ChatModel) => m.id === 'mistral-small-latest')?.vision).toBe(true);
|
||||
|
||||
// Non-vision models
|
||||
// Non-vision models (no image in input modalities)
|
||||
expect(models.find((m: ChatModel) => m.id === 'devstral-small-latest')?.vision).toBe(false);
|
||||
expect(models.find((m: ChatModel) => m.id === 'devstral-large-latest')?.vision).toBe(false);
|
||||
});
|
||||
@@ -670,10 +659,9 @@ describe('OpenCodeManager Mistral integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey model filtering', () => {
|
||||
it('filters out models whose provider key is not set', async () => {
|
||||
describe('validateApiKey returns models from API response', () => {
|
||||
it('returns models from the actual API response', async () => {
|
||||
const manager = createManager();
|
||||
// Only OpenCode key — no Mistral key
|
||||
manager.setApiKey('oc-key');
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
@@ -683,9 +671,9 @@ describe('OpenCodeManager Mistral integration', () => {
|
||||
|
||||
const result = await manager.validateApiKey('oc-key');
|
||||
expect(result.isValid).toBe(true);
|
||||
// Should NOT include Mistral models
|
||||
const mistralModels = result.models.filter(m => m.provider === 'mistral');
|
||||
expect(mistralModels.length).toBe(0);
|
||||
expect(result.models).toHaveLength(1);
|
||||
expect(result.models[0].id).toBe('claude-sonnet-4');
|
||||
expect(result.models[0].provider).toBe('anthropic');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user