dynamics model discovery
This commit is contained in:
@@ -24,42 +24,52 @@ const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages';
|
|||||||
const ZEN_OPENAI_URL = 'https://opencode.ai/zen/v1/chat/completions';
|
const ZEN_OPENAI_URL = 'https://opencode.ai/zen/v1/chat/completions';
|
||||||
const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
|
const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
|
||||||
|
|
||||||
// Full model catalog from OpenCode Zen (fallback when API unavailable)
|
// Known model display names: maps model IDs to polished names and serves as offline fallback
|
||||||
const AVAILABLE_MODELS: ModelInfo[] = [
|
const MODEL_DISPLAY_NAMES: Record<string, string> = {
|
||||||
// Anthropic Claude
|
// Anthropic Claude
|
||||||
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' },
|
'claude-opus-4-6': 'Claude Opus 4.6',
|
||||||
{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', provider: 'anthropic' },
|
'claude-opus-4-5': 'Claude Opus 4.5',
|
||||||
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', provider: 'anthropic' },
|
'claude-opus-4-1': 'Claude Opus 4.1',
|
||||||
{ id: 'claude-3-5-haiku', name: 'Claude Haiku 3.5', provider: 'anthropic' },
|
'claude-sonnet-4-6': 'Claude Sonnet 4.6',
|
||||||
{ id: 'claude-opus-4-5', name: 'Claude Opus 4.5', provider: 'anthropic' },
|
'claude-sonnet-4-5': 'Claude Sonnet 4.5',
|
||||||
{ id: 'claude-opus-4-1', name: 'Claude Opus 4.1', provider: 'anthropic' },
|
'claude-sonnet-4': 'Claude Sonnet 4',
|
||||||
|
'claude-haiku-4-5': 'Claude Haiku 4.5',
|
||||||
|
'claude-3-5-haiku': 'Claude 3.5 Haiku',
|
||||||
// OpenAI GPT
|
// OpenAI GPT
|
||||||
{ id: 'gpt-5.2', name: 'GPT 5.2', provider: 'openai' },
|
'gpt-5.3-codex': 'GPT 5.3 Codex',
|
||||||
{ id: 'gpt-5.2-codex', name: 'GPT 5.2 Codex', provider: 'openai' },
|
'gpt-5.2': 'GPT 5.2',
|
||||||
{ id: 'gpt-5.1', name: 'GPT 5.1', provider: 'openai' },
|
'gpt-5.2-codex': 'GPT 5.2 Codex',
|
||||||
{ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai' },
|
'gpt-5.1': 'GPT 5.1',
|
||||||
{ id: 'gpt-5.1-codex-max', name: 'GPT 5.1 Codex Max', provider: 'openai' },
|
'gpt-5.1-codex': 'GPT 5.1 Codex',
|
||||||
{ id: 'gpt-5.1-codex-mini', name: 'GPT 5.1 Codex Mini', provider: 'openai' },
|
'gpt-5.1-codex-max': 'GPT 5.1 Codex Max',
|
||||||
{ id: 'gpt-5', name: 'GPT 5', provider: 'openai' },
|
'gpt-5.1-codex-mini': 'GPT 5.1 Codex Mini',
|
||||||
{ id: 'gpt-5-codex', name: 'GPT 5 Codex', provider: 'openai' },
|
'gpt-5': 'GPT 5',
|
||||||
{ id: 'gpt-5-nano', name: 'GPT 5 Nano (Free)', provider: 'openai' },
|
'gpt-5-codex': 'GPT 5 Codex',
|
||||||
|
'gpt-5-nano': 'GPT 5 Nano',
|
||||||
// Google Gemini
|
// Google Gemini
|
||||||
{ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google' },
|
'gemini-3.1-pro': 'Gemini 3.1 Pro',
|
||||||
{ id: 'gemini-3-flash', name: 'Gemini 3 Flash', provider: 'google' },
|
'gemini-3-pro': 'Gemini 3 Pro',
|
||||||
|
'gemini-3-flash': 'Gemini 3 Flash',
|
||||||
// Other providers
|
// Other providers
|
||||||
{ id: 'qwen3-coder', name: 'Qwen3 Coder 480B', provider: 'other' },
|
'glm-5': 'GLM 5',
|
||||||
{ id: 'minimax-m2.1', name: 'MiniMax M2.1', provider: 'other' },
|
'glm-5-free': 'GLM 5 Free',
|
||||||
{ id: 'minimax-m2.1-free', name: 'MiniMax M2.1 (Free)', provider: 'other' },
|
'glm-4.7': 'GLM 4.7',
|
||||||
{ id: 'glm-4.7', name: 'GLM 4.7', provider: 'other' },
|
'glm-4.6': 'GLM 4.6',
|
||||||
{ id: 'glm-4.7-free', name: 'GLM 4.7 (Free)', provider: 'other' },
|
'qwen3-coder': 'Qwen3 Coder',
|
||||||
{ id: 'glm-4.6', name: 'GLM 4.6', provider: 'other' },
|
'minimax-m2.5': 'MiniMax M2.5',
|
||||||
{ id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'other' },
|
'minimax-m2.5-free': 'MiniMax M2.5 Free',
|
||||||
{ id: 'kimi-k2.5-free', name: 'Kimi K2.5 (Free)', provider: 'other' },
|
'minimax-m2.1': 'MiniMax M2.1',
|
||||||
{ id: 'kimi-k2', name: 'Kimi K2', provider: 'other' },
|
'minimax-m2.1-free': 'MiniMax M2.1 Free',
|
||||||
{ id: 'kimi-k2-thinking', name: 'Kimi K2 Thinking', provider: 'other' },
|
'kimi-k2.5': 'Kimi K2.5',
|
||||||
{ id: 'big-pickle', name: 'Big Pickle (Free)', provider: 'other' },
|
'kimi-k2.5-free': 'Kimi K2.5 Free',
|
||||||
{ id: 'trinity-large-preview-free', name: 'Trinity Large Preview (Free)', provider: 'other' },
|
'kimi-k2': 'Kimi K2',
|
||||||
];
|
'kimi-k2-thinking': 'Kimi K2 Thinking',
|
||||||
|
'big-pickle': 'Big Pickle',
|
||||||
|
'trinity-large-preview-free': 'Trinity Large Preview Free',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Uppercase prefixes that should not be title-cased
|
||||||
|
const UPPERCASE_PREFIXES = ['gpt', 'glm'];
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -148,6 +158,9 @@ export class OpenCodeManager {
|
|||||||
private getMainWindow: () => BrowserWindow | null;
|
private getMainWindow: () => BrowserWindow | null;
|
||||||
private apiKey: string = '';
|
private apiKey: string = '';
|
||||||
private abortControllers: Map<string, AbortController> = new Map();
|
private abortControllers: Map<string, AbortController> = new Map();
|
||||||
|
private cachedModels: ModelInfo[] | null = null;
|
||||||
|
private cachedModelsAt: number = 0;
|
||||||
|
private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
private conversationUsage: Map<string, {
|
private conversationUsage: Map<string, {
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
@@ -212,7 +225,7 @@ export class OpenCodeManager {
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
return { isValid: true, models: AVAILABLE_MODELS };
|
return { isValid: true, models: Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({ id, name, provider: this.detectProvider(id) })) };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Try next auth method
|
// Try next auth method
|
||||||
@@ -223,10 +236,15 @@ export class OpenCodeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available models
|
* Get available models (cached with 5-minute TTL)
|
||||||
*/
|
*/
|
||||||
async getAvailableModels(): Promise<ModelInfo[]> {
|
async getAvailableModels(): Promise<ModelInfo[]> {
|
||||||
// Try fetching from API, fall back to hardcoded list
|
// Return cached models if within TTL
|
||||||
|
if (this.cachedModels && Date.now() - this.cachedModelsAt < OpenCodeManager.MODEL_CACHE_TTL) {
|
||||||
|
return this.cachedModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try fetching from API
|
||||||
if (this.apiKey) {
|
if (this.apiKey) {
|
||||||
try {
|
try {
|
||||||
const response = await this.httpRequest(ZEN_MODELS_URL, {
|
const response = await this.httpRequest(ZEN_MODELS_URL, {
|
||||||
@@ -239,18 +257,28 @@ export class OpenCodeManager {
|
|||||||
if (response.statusCode === 200) {
|
if (response.statusCode === 200) {
|
||||||
const data = JSON.parse(response.body);
|
const data = JSON.parse(response.body);
|
||||||
if (data.data && Array.isArray(data.data)) {
|
if (data.data && Array.isArray(data.data)) {
|
||||||
return data.data.map((m: { id: string; name?: string }) => ({
|
const models = data.data.map((m: { id: string }) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name || this.formatModelName(m.id),
|
name: this.formatModelName(m.id),
|
||||||
provider: this.detectProvider(m.id),
|
provider: this.detectProvider(m.id),
|
||||||
}));
|
}));
|
||||||
|
this.cachedModels = models;
|
||||||
|
this.cachedModelsAt = Date.now();
|
||||||
|
return models;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to hardcoded
|
// Fall through to fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return AVAILABLE_MODELS;
|
|
||||||
|
// Build fallback from display name map
|
||||||
|
const fallback = Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
provider: this.detectProvider(id),
|
||||||
|
}));
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1823,9 +1851,21 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all
|
|||||||
}
|
}
|
||||||
|
|
||||||
private formatModelName(modelId: string): string {
|
private formatModelName(modelId: string): string {
|
||||||
return modelId
|
// Check display name map first
|
||||||
.split('-')
|
if (MODEL_DISPLAY_NAMES[modelId]) {
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
return MODEL_DISPLAY_NAMES[modelId];
|
||||||
|
}
|
||||||
|
// Auto-format: split on hyphens, handle uppercase prefixes and version dots
|
||||||
|
const words = modelId.split('-');
|
||||||
|
return words
|
||||||
|
.map((word, index) => {
|
||||||
|
// First word: check for uppercase prefixes
|
||||||
|
if (index === 0 && UPPERCASE_PREFIXES.includes(word.toLowerCase())) {
|
||||||
|
return word.toUpperCase();
|
||||||
|
}
|
||||||
|
// Capitalize first letter
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||||
|
})
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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