dynamics model discovery
This commit is contained in:
249
tests/engine/OpenCodeModelDiscovery.test.ts
Normal file
249
tests/engine/OpenCodeModelDiscovery.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* OpenCodeManager Model Discovery Tests
|
||||
*
|
||||
* Tests the model discovery, display name formatting, and caching behavior.
|
||||
* Following TDD: these tests describe the expected behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// Mock dependencies before importing the class
|
||||
vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||
ChatEngine: class {
|
||||
getSetting = vi.fn();
|
||||
setSetting = vi.fn();
|
||||
getSelectedModel = vi.fn();
|
||||
getDefaultSystemPrompt = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||
getPostEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||
getMediaEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { OpenCodeManager, ModelInfo } from '../../src/main/engine/OpenCodeManager';
|
||||
|
||||
// Helper to create manager with mocked httpRequest
|
||||
function createManager(): OpenCodeManager {
|
||||
const manager = new OpenCodeManager(
|
||||
{ getSetting: vi.fn(), setSetting: vi.fn() } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
() => null,
|
||||
);
|
||||
manager.setApiKey('test-key');
|
||||
return manager;
|
||||
}
|
||||
|
||||
// Mock API response in the Zen format (id, object, created, owned_by — no name field)
|
||||
function createZenModelResponse(ids: string[]) {
|
||||
return {
|
||||
object: 'list',
|
||||
data: ids.map(id => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: 1772132920,
|
||||
owned_by: 'opencode',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('OpenCodeManager model discovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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 () => {
|
||||
const manager = createManager();
|
||||
const zenResponse = createZenModelResponse([
|
||||
'claude-sonnet-4',
|
||||
'gpt-5.1-codex',
|
||||
'gemini-3-pro',
|
||||
]);
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(zenResponse),
|
||||
});
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
expect(models).toHaveLength(3);
|
||||
expect(models[0]).toEqual({ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' });
|
||||
expect(models[1]).toEqual({ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai' });
|
||||
expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google' });
|
||||
});
|
||||
|
||||
it('falls back to known models when API fails', async () => {
|
||||
const manager = createManager();
|
||||
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
|
||||
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: ModelInfo) => 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: ModelInfo) => m.id === 'claude-sonnet-4');
|
||||
expect(claudeModel?.provider).toBe('anthropic');
|
||||
const gptModel = models.find((m: ModelInfo) => m.id === 'gpt-5');
|
||||
expect(gptModel?.provider).toBe('openai');
|
||||
});
|
||||
|
||||
it('falls back when API returns non-200 status', async () => {
|
||||
const manager = createManager();
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 401,
|
||||
body: '{"error":"unauthorized"}',
|
||||
});
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
const ids = models.map((m: ModelInfo) => m.id);
|
||||
expect(ids).toContain('claude-sonnet-4');
|
||||
});
|
||||
|
||||
it('caches models and does not re-fetch within TTL', async () => {
|
||||
const manager = createManager();
|
||||
const httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])),
|
||||
});
|
||||
(manager as any).httpRequest = httpRequest;
|
||||
|
||||
await manager.getAvailableModels();
|
||||
await manager.getAvailableModels();
|
||||
|
||||
expect(httpRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-fetches after cache TTL expires', async () => {
|
||||
const manager = createManager();
|
||||
const httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])),
|
||||
});
|
||||
(manager as any).httpRequest = httpRequest;
|
||||
|
||||
await manager.getAvailableModels();
|
||||
expect(httpRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance past 5-minute TTL
|
||||
vi.advanceTimersByTime(6 * 60 * 1000);
|
||||
|
||||
await manager.getAvailableModels();
|
||||
expect(httpRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles unknown model IDs from API with auto-formatting', async () => {
|
||||
const manager = createManager();
|
||||
const zenResponse = createZenModelResponse(['some-new-model-v3']);
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(zenResponse),
|
||||
});
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
expect(models).toHaveLength(1);
|
||||
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 () => {
|
||||
const manager = createManager();
|
||||
(manager as any).apiKey = '';
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
const ids = models.map((m: ModelInfo) => m.id);
|
||||
expect(ids).toContain('claude-sonnet-4');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user