mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-20 09:24:03 +00:00
- Merge coder-model and qwen3.5-plus into a single coder-model with vision capability - Remove vlmSwitchMode CLI argument and experimental.vlmSwitchMode setting - Remove useVisionAutoSwitch hook and inline image format checking into useGeminiStream - Remove ModelSwitchDialog and related vision switch UI components - Update all related tests to reflect the simplified model structure - Set DEFAULT_QWEN_MODEL to coder-model Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1509 lines
52 KiB
TypeScript
1509 lines
52 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { ModelsConfig } from './modelsConfig.js';
|
|
import { AuthType } from '../core/contentGenerator.js';
|
|
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
|
|
import type { ModelProvidersConfig } from './types.js';
|
|
|
|
describe('ModelsConfig', () => {
|
|
function deepClone<T>(value: T): T {
|
|
if (value === null || typeof value !== 'object') return value;
|
|
if (Array.isArray(value)) return value.map((v) => deepClone(v)) as T;
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
out[key] = deepClone((value as Record<string, unknown>)[key]);
|
|
}
|
|
return out as T;
|
|
}
|
|
|
|
function snapshotGenerationConfig(
|
|
modelsConfig: ModelsConfig,
|
|
): ContentGeneratorConfig {
|
|
return deepClone<ContentGeneratorConfig>(
|
|
modelsConfig.getGenerationConfig() as ContentGeneratorConfig,
|
|
);
|
|
}
|
|
|
|
function currentGenerationConfig(
|
|
modelsConfig: ModelsConfig,
|
|
): ContentGeneratorConfig {
|
|
return modelsConfig.getGenerationConfig() as ContentGeneratorConfig;
|
|
}
|
|
|
|
it('should fully rollback state when switchModel fails after applying defaults (authType change)', async () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'openai-a',
|
|
name: 'OpenAI A',
|
|
baseUrl: 'https://api.openai.example.com/v1',
|
|
envKey: 'OPENAI_API_KEY',
|
|
generationConfig: {
|
|
samplingParams: { temperature: 0.2, max_tokens: 123 },
|
|
timeout: 111,
|
|
maxRetries: 1,
|
|
},
|
|
},
|
|
],
|
|
anthropic: [
|
|
{
|
|
id: 'anthropic-b',
|
|
name: 'Anthropic B',
|
|
baseUrl: 'https://api.anthropic.example.com/v1',
|
|
envKey: 'ANTHROPIC_API_KEY',
|
|
generationConfig: {
|
|
samplingParams: { temperature: 0.7, max_tokens: 456 },
|
|
timeout: 222,
|
|
maxRetries: 2,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
});
|
|
|
|
// Establish a known baseline state via a successful switch.
|
|
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'openai-a');
|
|
const baselineAuthType = modelsConfig.getCurrentAuthType();
|
|
const baselineModel = modelsConfig.getModel();
|
|
const baselineStrict = modelsConfig.isStrictModelProviderSelection();
|
|
const baselineGc = snapshotGenerationConfig(modelsConfig);
|
|
const baselineSources = deepClone(
|
|
modelsConfig.getGenerationConfigSources(),
|
|
);
|
|
|
|
modelsConfig.setOnModelChange(async () => {
|
|
throw new Error('refresh failed');
|
|
});
|
|
|
|
await expect(
|
|
modelsConfig.switchModel(AuthType.USE_ANTHROPIC, 'anthropic-b'),
|
|
).rejects.toThrow('refresh failed');
|
|
|
|
// Ensure state is fully rolled back (selection + generation config + flags).
|
|
expect(modelsConfig.getCurrentAuthType()).toBe(baselineAuthType);
|
|
expect(modelsConfig.getModel()).toBe(baselineModel);
|
|
expect(modelsConfig.isStrictModelProviderSelection()).toBe(baselineStrict);
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc).toMatchObject({
|
|
model: baselineGc.model,
|
|
baseUrl: baselineGc.baseUrl,
|
|
apiKeyEnvKey: baselineGc.apiKeyEnvKey,
|
|
samplingParams: baselineGc.samplingParams,
|
|
timeout: baselineGc.timeout,
|
|
maxRetries: baselineGc.maxRetries,
|
|
});
|
|
|
|
const sources = modelsConfig.getGenerationConfigSources();
|
|
expect(sources).toEqual(baselineSources);
|
|
});
|
|
|
|
it('should fully rollback state when switchModel fails after applying defaults', async () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'model-a',
|
|
name: 'Model A',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'API_KEY_A',
|
|
},
|
|
{
|
|
id: 'model-b',
|
|
name: 'Model B',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'API_KEY_B',
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
});
|
|
|
|
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-a');
|
|
const baselineModel = modelsConfig.getModel();
|
|
const baselineGc = snapshotGenerationConfig(modelsConfig);
|
|
const baselineSources = deepClone(
|
|
modelsConfig.getGenerationConfigSources(),
|
|
);
|
|
|
|
modelsConfig.setOnModelChange(async () => {
|
|
throw new Error('hot-update failed');
|
|
});
|
|
|
|
await expect(
|
|
modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-b'),
|
|
).rejects.toThrow('hot-update failed');
|
|
|
|
expect(modelsConfig.getModel()).toBe(baselineModel);
|
|
expect(modelsConfig.getGenerationConfig()).toMatchObject({
|
|
model: baselineGc.model,
|
|
baseUrl: baselineGc.baseUrl,
|
|
apiKeyEnvKey: baselineGc.apiKeyEnvKey,
|
|
});
|
|
expect(modelsConfig.getGenerationConfigSources()).toEqual(baselineSources);
|
|
});
|
|
|
|
it('should require provider-sourced apiKey when switching models even if envKey is missing', async () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'model-a',
|
|
name: 'Model A',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'API_KEY_SHARED',
|
|
},
|
|
{
|
|
id: 'model-b',
|
|
name: 'Model B',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'API_KEY_SHARED',
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
generationConfig: {
|
|
model: 'model-a',
|
|
},
|
|
});
|
|
|
|
// Simulate key prompt flow / explicit key provided via CLI/settings.
|
|
modelsConfig.updateCredentials({ apiKey: 'manual-key', model: 'model-a' });
|
|
|
|
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-b');
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.model).toBe('model-b');
|
|
expect(gc.apiKey).toBeUndefined();
|
|
expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED');
|
|
});
|
|
|
|
it('should use provider config when modelId exists in registry even after updateCredentials', () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'model-a',
|
|
name: 'Model A',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'API_KEY_A',
|
|
generationConfig: {
|
|
samplingParams: { temperature: 0.1, max_tokens: 123 },
|
|
timeout: 111,
|
|
maxRetries: 1,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
// Simulate settings.model.generationConfig being resolved into ModelsConfig.generationConfig
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
generationConfig: {
|
|
model: 'custom-model',
|
|
samplingParams: { temperature: 0.9, max_tokens: 999 },
|
|
timeout: 9999,
|
|
maxRetries: 9,
|
|
},
|
|
generationConfigSources: {
|
|
model: { kind: 'settings', detail: 'settings.model.name' },
|
|
samplingParams: {
|
|
kind: 'settings',
|
|
detail: 'settings.model.generationConfig.samplingParams',
|
|
},
|
|
timeout: {
|
|
kind: 'settings',
|
|
detail: 'settings.model.generationConfig.timeout',
|
|
},
|
|
maxRetries: {
|
|
kind: 'settings',
|
|
detail: 'settings.model.generationConfig.maxRetries',
|
|
},
|
|
},
|
|
});
|
|
|
|
// User manually updates credentials via updateCredentials.
|
|
// Note: In practice, handleAuthSelect prevents using a modelId that matches a provider model,
|
|
// but if syncAfterAuthRefresh is called with a modelId that exists in registry,
|
|
// we should use provider config.
|
|
modelsConfig.updateCredentials({ apiKey: 'manual-key' });
|
|
|
|
// syncAfterAuthRefresh with a modelId that exists in registry should use provider config
|
|
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'model-a');
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.model).toBe('model-a');
|
|
// Provider config should be applied
|
|
expect(gc.samplingParams?.temperature).toBe(0.1);
|
|
expect(gc.samplingParams?.max_tokens).toBe(123);
|
|
expect(gc.timeout).toBe(111);
|
|
expect(gc.maxRetries).toBe(1);
|
|
});
|
|
|
|
it('should preserve settings generationConfig when modelId does not exist in registry', () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'provider-model',
|
|
name: 'Provider Model',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'API_KEY_A',
|
|
generationConfig: {
|
|
samplingParams: { temperature: 0.1, max_tokens: 123 },
|
|
timeout: 111,
|
|
maxRetries: 1,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
// Simulate settings with a custom model (not in registry)
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
generationConfig: {
|
|
model: 'custom-model',
|
|
samplingParams: { temperature: 0.9, max_tokens: 999 },
|
|
timeout: 9999,
|
|
maxRetries: 9,
|
|
},
|
|
generationConfigSources: {
|
|
model: { kind: 'settings', detail: 'settings.model.name' },
|
|
samplingParams: {
|
|
kind: 'settings',
|
|
detail: 'settings.model.generationConfig.samplingParams',
|
|
},
|
|
timeout: {
|
|
kind: 'settings',
|
|
detail: 'settings.model.generationConfig.timeout',
|
|
},
|
|
maxRetries: {
|
|
kind: 'settings',
|
|
detail: 'settings.model.generationConfig.maxRetries',
|
|
},
|
|
},
|
|
});
|
|
|
|
// User manually sets credentials for a custom model (not in registry)
|
|
modelsConfig.updateCredentials({
|
|
apiKey: 'manual-key',
|
|
baseUrl: 'https://manual.example.com/v1',
|
|
model: 'custom-model',
|
|
});
|
|
|
|
// First auth refresh - modelId doesn't exist in registry, so credentials should be preserved
|
|
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
|
|
// Second auth refresh should still preserve settings generationConfig
|
|
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.model).toBe('custom-model');
|
|
// Settings-sourced generation config should be preserved since modelId doesn't exist in registry
|
|
expect(gc.samplingParams?.temperature).toBe(0.9);
|
|
expect(gc.samplingParams?.max_tokens).toBe(999);
|
|
expect(gc.timeout).toBe(9999);
|
|
expect(gc.maxRetries).toBe(9);
|
|
});
|
|
|
|
it('should clear provider-sourced config when updateCredentials is called after switchModel', async () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'provider-model',
|
|
name: 'Provider Model',
|
|
baseUrl: 'https://provider.example.com/v1',
|
|
envKey: 'PROVIDER_API_KEY',
|
|
generationConfig: {
|
|
samplingParams: { temperature: 0.1, max_tokens: 100 },
|
|
timeout: 1000,
|
|
maxRetries: 2,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
});
|
|
|
|
// Step 1: Switch to a provider model - this applies provider config
|
|
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'provider-model');
|
|
|
|
// Verify provider config is applied
|
|
let gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.model).toBe('provider-model');
|
|
expect(gc.baseUrl).toBe('https://provider.example.com/v1');
|
|
expect(gc.samplingParams?.temperature).toBe(0.1);
|
|
expect(gc.samplingParams?.max_tokens).toBe(100);
|
|
expect(gc.timeout).toBe(1000);
|
|
expect(gc.maxRetries).toBe(2);
|
|
|
|
// Verify sources are from modelProviders
|
|
let sources = modelsConfig.getGenerationConfigSources();
|
|
expect(sources['model']?.kind).toBe('modelProviders');
|
|
expect(sources['baseUrl']?.kind).toBe('modelProviders');
|
|
expect(sources['samplingParams']?.kind).toBe('modelProviders');
|
|
expect(sources['timeout']?.kind).toBe('modelProviders');
|
|
expect(sources['maxRetries']?.kind).toBe('modelProviders');
|
|
|
|
// Step 2: User manually sets credentials via updateCredentials
|
|
// This should clear all provider-sourced config
|
|
modelsConfig.updateCredentials({
|
|
apiKey: 'manual-api-key',
|
|
model: 'custom-model',
|
|
});
|
|
|
|
// Verify provider-sourced config is cleared
|
|
gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.model).toBe('custom-model'); // Set by updateCredentials
|
|
expect(gc.apiKey).toBe('manual-api-key'); // Set by updateCredentials
|
|
expect(gc.baseUrl).toBeUndefined(); // Cleared (was from provider)
|
|
expect(gc.samplingParams).toBeUndefined(); // Cleared (was from provider)
|
|
expect(gc.timeout).toBeUndefined(); // Cleared (was from provider)
|
|
expect(gc.maxRetries).toBeUndefined(); // Cleared (was from provider)
|
|
|
|
// Verify sources are updated
|
|
sources = modelsConfig.getGenerationConfigSources();
|
|
expect(sources['model']?.kind).toBe('programmatic');
|
|
expect(sources['apiKey']?.kind).toBe('programmatic');
|
|
expect(sources['baseUrl']).toBeUndefined(); // Source cleared
|
|
expect(sources['samplingParams']).toBeUndefined(); // Source cleared
|
|
expect(sources['timeout']).toBeUndefined(); // Source cleared
|
|
expect(sources['maxRetries']).toBeUndefined(); // Source cleared
|
|
});
|
|
|
|
it('should preserve non-provider config when updateCredentials clears provider config', async () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'provider-model',
|
|
name: 'Provider Model',
|
|
baseUrl: 'https://provider.example.com/v1',
|
|
envKey: 'PROVIDER_API_KEY',
|
|
generationConfig: {
|
|
samplingParams: { temperature: 0.1, max_tokens: 100 },
|
|
timeout: 1000,
|
|
maxRetries: 2,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
// Initialize with settings-sourced config
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
generationConfig: {
|
|
samplingParams: { temperature: 0.8, max_tokens: 500 },
|
|
timeout: 5000,
|
|
},
|
|
generationConfigSources: {
|
|
samplingParams: {
|
|
kind: 'settings',
|
|
detail: 'settings.model.generationConfig.samplingParams',
|
|
},
|
|
timeout: {
|
|
kind: 'settings',
|
|
detail: 'settings.model.generationConfig.timeout',
|
|
},
|
|
},
|
|
});
|
|
|
|
// Switch to provider model - this overwrites with provider config
|
|
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'provider-model');
|
|
|
|
// Verify provider config is applied (overwriting settings)
|
|
let gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.samplingParams?.temperature).toBe(0.1);
|
|
expect(gc.timeout).toBe(1000);
|
|
|
|
// User manually sets credentials - clears provider-sourced config
|
|
modelsConfig.updateCredentials({
|
|
apiKey: 'manual-key',
|
|
});
|
|
|
|
// Provider-sourced config should be cleared
|
|
gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.samplingParams).toBeUndefined();
|
|
expect(gc.timeout).toBeUndefined();
|
|
// The original settings-sourced config is NOT restored automatically;
|
|
// it should be re-resolved by other layers in refreshAuth
|
|
});
|
|
|
|
it('should always force Qwen OAuth apiKey placeholder when applying model defaults', async () => {
|
|
// Simulate a stale/explicit apiKey existing before switching models.
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.QWEN_OAUTH,
|
|
generationConfig: {
|
|
apiKey: 'manual-key-should-not-leak',
|
|
},
|
|
});
|
|
|
|
// Switching within qwen-oauth triggers applyResolvedModelDefaults().
|
|
await modelsConfig.switchModel(AuthType.QWEN_OAUTH, 'coder-model');
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
|
|
expect(gc.apiKeyEnvKey).toBeUndefined();
|
|
});
|
|
|
|
it('should apply extra_body and customHeaders from model provider config', async () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'model-with-extras',
|
|
name: 'Model With Extras',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'API_KEY',
|
|
generationConfig: {
|
|
extra_body: { custom_param: 'value', enable_thinking: true },
|
|
customHeaders: { 'X-Custom-Header': 'header-value' },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
});
|
|
|
|
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-with-extras');
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.extra_body).toEqual({
|
|
custom_param: 'value',
|
|
enable_thinking: true,
|
|
});
|
|
expect(gc.customHeaders).toEqual({ 'X-Custom-Header': 'header-value' });
|
|
|
|
const sources = modelsConfig.getGenerationConfigSources();
|
|
expect(sources['extra_body']?.kind).toBe('modelProviders');
|
|
expect(sources['customHeaders']?.kind).toBe('modelProviders');
|
|
});
|
|
|
|
it('should apply Qwen OAuth apiKey placeholder during syncAfterAuthRefresh for fresh users', () => {
|
|
// Fresh user: authType not selected yet (currentAuthType undefined).
|
|
const modelsConfig = new ModelsConfig();
|
|
|
|
// Config.refreshAuth passes modelId from modelsConfig.getModel(), which falls back to DEFAULT_QWEN_MODEL.
|
|
modelsConfig.syncAfterAuthRefresh(
|
|
AuthType.QWEN_OAUTH,
|
|
modelsConfig.getModel(),
|
|
);
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.model).toBe('coder-model');
|
|
expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
|
|
expect(gc.apiKeyEnvKey).toBeUndefined();
|
|
});
|
|
|
|
it('should use default model for new authType when switching from different authType with env vars', () => {
|
|
// Simulate cold start with OPENAI env vars (OPENAI_MODEL and OPENAI_API_KEY)
|
|
// This sets the model in generationConfig but no authType is selected yet
|
|
const modelsConfig = new ModelsConfig({
|
|
generationConfig: {
|
|
model: 'gpt-4o', // From OPENAI_MODEL env var
|
|
apiKey: 'openai-key-from-env',
|
|
},
|
|
});
|
|
|
|
// User switches to qwen-oauth via AuthDialog
|
|
// refreshAuth calls syncAfterAuthRefresh with the current model (gpt-4o)
|
|
// which doesn't exist in qwen-oauth registry, so it should use default
|
|
modelsConfig.syncAfterAuthRefresh(AuthType.QWEN_OAUTH, 'gpt-4o');
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
// Should use default qwen-oauth model (coder-model), not the OPENAI model
|
|
expect(gc.model).toBe('coder-model');
|
|
expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
|
|
expect(gc.apiKeyEnvKey).toBeUndefined();
|
|
});
|
|
|
|
it('should clear manual credentials when switching from USE_OPENAI to QWEN_OAUTH', () => {
|
|
// User manually set credentials for OpenAI
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
generationConfig: {
|
|
model: 'gpt-4o',
|
|
apiKey: 'manual-openai-key',
|
|
baseUrl: 'https://manual.example.com/v1',
|
|
},
|
|
});
|
|
|
|
// Manually set credentials via updateCredentials
|
|
modelsConfig.updateCredentials({
|
|
apiKey: 'manual-openai-key',
|
|
baseUrl: 'https://manual.example.com/v1',
|
|
model: 'gpt-4o',
|
|
});
|
|
|
|
// User switches to qwen-oauth
|
|
// Since authType is not USE_OPENAI, manual credentials should be cleared
|
|
// and default qwen-oauth model should be applied
|
|
modelsConfig.syncAfterAuthRefresh(AuthType.QWEN_OAUTH, 'gpt-4o');
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
// Should use default qwen-oauth model, not preserve manual OpenAI credentials
|
|
expect(gc.model).toBe('coder-model');
|
|
expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
|
|
// baseUrl should be set to qwen-oauth default, not preserved from manual OpenAI config
|
|
expect(gc.baseUrl).toBe('DYNAMIC_QWEN_OAUTH_BASE_URL');
|
|
expect(gc.apiKeyEnvKey).toBeUndefined();
|
|
});
|
|
|
|
it('should preserve manual credentials when switching to USE_OPENAI', () => {
|
|
// User manually set credentials
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
generationConfig: {
|
|
model: 'gpt-4o',
|
|
apiKey: 'manual-openai-key',
|
|
baseUrl: 'https://manual.example.com/v1',
|
|
samplingParams: { temperature: 0.9 },
|
|
},
|
|
});
|
|
|
|
// Manually set credentials via updateCredentials
|
|
modelsConfig.updateCredentials({
|
|
apiKey: 'manual-openai-key',
|
|
baseUrl: 'https://manual.example.com/v1',
|
|
model: 'gpt-4o',
|
|
});
|
|
|
|
// User switches to USE_OPENAI (same or different model)
|
|
// Since authType is USE_OPENAI, manual credentials should be preserved
|
|
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'gpt-4o');
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
// Should preserve manual credentials
|
|
expect(gc.model).toBe('gpt-4o');
|
|
expect(gc.apiKey).toBe('manual-openai-key');
|
|
expect(gc.baseUrl).toBe('https://manual.example.com/v1');
|
|
expect(gc.samplingParams?.temperature).toBe(0.9); // Preserved from initial config
|
|
});
|
|
|
|
it('should maintain consistency between currentModelId and _generationConfig.model after initialization', () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'test-model',
|
|
name: 'Test Model',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'TEST_API_KEY',
|
|
},
|
|
],
|
|
};
|
|
|
|
// Test case 1: generationConfig.model provided with other config
|
|
const config1 = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
generationConfig: {
|
|
model: 'test-model',
|
|
samplingParams: { temperature: 0.5 },
|
|
},
|
|
});
|
|
expect(config1.getModel()).toBe('test-model');
|
|
expect(config1.getGenerationConfig().model).toBe('test-model');
|
|
|
|
// Test case 2: generationConfig.model provided
|
|
const config2 = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
generationConfig: {
|
|
model: 'test-model',
|
|
},
|
|
});
|
|
expect(config2.getModel()).toBe('test-model');
|
|
expect(config2.getGenerationConfig().model).toBe('test-model');
|
|
|
|
// Test case 3: no model provided (empty string fallback)
|
|
const config3 = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
generationConfig: {},
|
|
});
|
|
expect(config3.getModel()).toBe('coder-model'); // Falls back to DEFAULT_QWEN_MODEL
|
|
expect(config3.getGenerationConfig().model).toBeUndefined();
|
|
});
|
|
|
|
it('should maintain consistency between currentModelId and _generationConfig.model during syncAfterAuthRefresh', () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'model-a',
|
|
name: 'Model A',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'API_KEY_A',
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
generationConfig: {
|
|
model: 'model-a',
|
|
},
|
|
});
|
|
|
|
// Manually set credentials to trigger preserveManualCredentials path
|
|
modelsConfig.updateCredentials({ apiKey: 'manual-key' });
|
|
|
|
// syncAfterAuthRefresh with a different modelId
|
|
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'model-a');
|
|
|
|
// Both should be consistent
|
|
expect(modelsConfig.getModel()).toBe('model-a');
|
|
expect(modelsConfig.getGenerationConfig().model).toBe('model-a');
|
|
});
|
|
|
|
it('should maintain consistency between currentModelId and _generationConfig.model during setModel', async () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'model-a',
|
|
name: 'Model A',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'API_KEY_A',
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
});
|
|
|
|
// setModel with a raw model ID
|
|
await modelsConfig.setModel('custom-model');
|
|
|
|
// Both should be consistent
|
|
expect(modelsConfig.getModel()).toBe('custom-model');
|
|
expect(modelsConfig.getGenerationConfig().model).toBe('custom-model');
|
|
});
|
|
|
|
it('should maintain consistency between currentModelId and _generationConfig.model during updateCredentials', () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
// updateCredentials with model
|
|
modelsConfig.updateCredentials({
|
|
apiKey: 'test-key',
|
|
model: 'updated-model',
|
|
});
|
|
|
|
// Both should be consistent
|
|
expect(modelsConfig.getModel()).toBe('updated-model');
|
|
expect(modelsConfig.getGenerationConfig().model).toBe('updated-model');
|
|
});
|
|
|
|
describe('getAllConfiguredModels', () => {
|
|
it('should return all models across all authTypes and put qwen-oauth first', () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'openai-model-1',
|
|
name: 'OpenAI Model 1',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
envKey: 'OPENAI_API_KEY',
|
|
},
|
|
{
|
|
id: 'openai-model-2',
|
|
name: 'OpenAI Model 2',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
envKey: 'OPENAI_API_KEY',
|
|
},
|
|
],
|
|
anthropic: [
|
|
{
|
|
id: 'anthropic-model-1',
|
|
name: 'Anthropic Model 1',
|
|
baseUrl: 'https://api.anthropic.com/v1',
|
|
envKey: 'ANTHROPIC_API_KEY',
|
|
},
|
|
],
|
|
gemini: [
|
|
{
|
|
id: 'gemini-model-1',
|
|
name: 'Gemini Model 1',
|
|
baseUrl: 'https://generativelanguage.googleapis.com/v1',
|
|
envKey: 'GEMINI_API_KEY',
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
modelProvidersConfig,
|
|
});
|
|
|
|
const allModels = modelsConfig.getAllConfiguredModels();
|
|
|
|
// qwen-oauth models should be ordered first
|
|
const firstNonQwenIndex = allModels.findIndex(
|
|
(m) => m.authType !== AuthType.QWEN_OAUTH,
|
|
);
|
|
expect(firstNonQwenIndex).toBeGreaterThan(0);
|
|
expect(
|
|
allModels
|
|
.slice(0, firstNonQwenIndex)
|
|
.every((m) => m.authType === AuthType.QWEN_OAUTH),
|
|
).toBe(true);
|
|
expect(
|
|
allModels
|
|
.slice(firstNonQwenIndex)
|
|
.every((m) => m.authType !== AuthType.QWEN_OAUTH),
|
|
).toBe(true);
|
|
|
|
// Should include qwen-oauth models (hard-coded)
|
|
const qwenModels = allModels.filter(
|
|
(m) => m.authType === AuthType.QWEN_OAUTH,
|
|
);
|
|
expect(qwenModels.length).toBeGreaterThan(0);
|
|
|
|
// Should include openai models
|
|
const openaiModels = allModels.filter(
|
|
(m) => m.authType === AuthType.USE_OPENAI,
|
|
);
|
|
expect(openaiModels.length).toBe(2);
|
|
expect(openaiModels.map((m) => m.id)).toContain('openai-model-1');
|
|
expect(openaiModels.map((m) => m.id)).toContain('openai-model-2');
|
|
|
|
// Should include anthropic models
|
|
const anthropicModels = allModels.filter(
|
|
(m) => m.authType === AuthType.USE_ANTHROPIC,
|
|
);
|
|
expect(anthropicModels.length).toBe(1);
|
|
expect(anthropicModels[0].id).toBe('anthropic-model-1');
|
|
|
|
// Should include gemini models
|
|
const geminiModels = allModels.filter(
|
|
(m) => m.authType === AuthType.USE_GEMINI,
|
|
);
|
|
expect(geminiModels.length).toBe(1);
|
|
expect(geminiModels[0].id).toBe('gemini-model-1');
|
|
});
|
|
|
|
it('should return empty array when no models are registered', () => {
|
|
const modelsConfig = new ModelsConfig();
|
|
|
|
const allModels = modelsConfig.getAllConfiguredModels();
|
|
|
|
// Should still include qwen-oauth models (hard-coded)
|
|
expect(allModels.length).toBeGreaterThan(0);
|
|
const qwenModels = allModels.filter(
|
|
(m) => m.authType === AuthType.QWEN_OAUTH,
|
|
);
|
|
expect(qwenModels.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return models with correct structure', () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'test-model',
|
|
name: 'Test Model',
|
|
description: 'A test model',
|
|
baseUrl: 'https://api.example.com/v1',
|
|
envKey: 'TEST_API_KEY',
|
|
capabilities: {
|
|
vision: true,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
modelProvidersConfig,
|
|
});
|
|
|
|
const allModels = modelsConfig.getAllConfiguredModels();
|
|
const testModel = allModels.find((m) => m.id === 'test-model');
|
|
|
|
expect(testModel).toBeDefined();
|
|
expect(testModel?.id).toBe('test-model');
|
|
expect(testModel?.label).toBe('Test Model');
|
|
expect(testModel?.description).toBe('A test model');
|
|
expect(testModel?.authType).toBe(AuthType.USE_OPENAI);
|
|
expect(testModel?.isVision).toBe(true);
|
|
expect(testModel?.capabilities?.vision).toBe(true);
|
|
});
|
|
|
|
it('should support filtering by authTypes and still put qwen-oauth first when included', () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'openai-model-1',
|
|
name: 'OpenAI Model 1',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
envKey: 'OPENAI_API_KEY',
|
|
},
|
|
],
|
|
anthropic: [
|
|
{
|
|
id: 'anthropic-model-1',
|
|
name: 'Anthropic Model 1',
|
|
baseUrl: 'https://api.anthropic.com/v1',
|
|
envKey: 'ANTHROPIC_API_KEY',
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
modelProvidersConfig,
|
|
});
|
|
|
|
// Filter: OpenAI only (should not include qwen-oauth)
|
|
const openaiOnly = modelsConfig.getAllConfiguredModels([
|
|
AuthType.USE_OPENAI,
|
|
]);
|
|
expect(openaiOnly.every((m) => m.authType === AuthType.USE_OPENAI)).toBe(
|
|
true,
|
|
);
|
|
expect(openaiOnly.map((m) => m.id)).toContain('openai-model-1');
|
|
|
|
// Filter: include qwen-oauth but request it later -> still ordered first
|
|
const withQwen = modelsConfig.getAllConfiguredModels([
|
|
AuthType.USE_OPENAI,
|
|
AuthType.QWEN_OAUTH,
|
|
AuthType.USE_ANTHROPIC,
|
|
]);
|
|
expect(withQwen.length).toBeGreaterThan(0);
|
|
const firstNonQwenIndex = withQwen.findIndex(
|
|
(m) => m.authType !== AuthType.QWEN_OAUTH,
|
|
);
|
|
expect(firstNonQwenIndex).toBeGreaterThan(0);
|
|
expect(
|
|
withQwen
|
|
.slice(0, firstNonQwenIndex)
|
|
.every((m) => m.authType === AuthType.QWEN_OAUTH),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Runtime Model Snapshot', () => {
|
|
it('should detect and capture runtime model from CLI source', () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
generationConfig: {
|
|
model: 'gpt-4-turbo',
|
|
apiKey: 'sk-test-key',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
},
|
|
generationConfigSources: {
|
|
model: { kind: 'cli', detail: '--model' },
|
|
apiKey: { kind: 'cli', detail: '--openaiApiKey' },
|
|
baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' },
|
|
},
|
|
});
|
|
|
|
const snapshotId = modelsConfig.detectAndCaptureRuntimeModel();
|
|
|
|
expect(snapshotId).toBe('$runtime|openai|gpt-4-turbo');
|
|
|
|
const snapshot = modelsConfig.getActiveRuntimeModelSnapshot();
|
|
expect(snapshot).toBeDefined();
|
|
expect(snapshot?.id).toBe('$runtime|openai|gpt-4-turbo');
|
|
expect(snapshot?.authType).toBe(AuthType.USE_OPENAI);
|
|
expect(snapshot?.modelId).toBe('gpt-4-turbo');
|
|
expect(snapshot?.apiKey).toBe('sk-test-key');
|
|
expect(snapshot?.baseUrl).toBe('https://api.openai.com/v1');
|
|
});
|
|
|
|
it('should detect and capture runtime model from ENV source', () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
generationConfig: {
|
|
model: 'gpt-4o',
|
|
apiKey: 'sk-env-key',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
},
|
|
generationConfigSources: {
|
|
model: { kind: 'settings', detail: 'settings.model.name' },
|
|
apiKey: { kind: 'env', envKey: 'OPENAI_API_KEY' },
|
|
baseUrl: { kind: 'settings', detail: 'settings.openaiBaseUrl' },
|
|
},
|
|
});
|
|
|
|
const snapshotId = modelsConfig.detectAndCaptureRuntimeModel();
|
|
|
|
expect(snapshotId).toBe('$runtime|openai|gpt-4o');
|
|
|
|
const snapshot = modelsConfig.getActiveRuntimeModelSnapshot();
|
|
expect(snapshot).toBeDefined();
|
|
expect(snapshot?.modelId).toBe('gpt-4o');
|
|
expect(snapshot?.apiKey).toBe('sk-env-key');
|
|
});
|
|
|
|
it('should not capture registry models as runtime', () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'gpt-4-turbo',
|
|
name: 'GPT-4 Turbo',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
envKey: 'OPENAI_API_KEY',
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
generationConfig: {
|
|
model: 'gpt-4-turbo',
|
|
apiKey: 'sk-test-key',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
},
|
|
generationConfigSources: {
|
|
model: { kind: 'cli', detail: '--model' },
|
|
apiKey: { kind: 'cli', detail: '--openaiApiKey' },
|
|
baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' },
|
|
},
|
|
});
|
|
|
|
const snapshotId = modelsConfig.detectAndCaptureRuntimeModel();
|
|
|
|
// Should not create snapshot since model exists in registry
|
|
expect(snapshotId).toBeUndefined();
|
|
expect(modelsConfig.getActiveRuntimeModelSnapshot()).toBeUndefined();
|
|
});
|
|
|
|
it('should not capture runtime model without valid credentials', () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
generationConfig: {
|
|
model: 'custom-model',
|
|
// Missing apiKey and baseUrl
|
|
},
|
|
generationConfigSources: {
|
|
model: { kind: 'cli', detail: '--model' },
|
|
},
|
|
});
|
|
|
|
const snapshotId = modelsConfig.detectAndCaptureRuntimeModel();
|
|
|
|
expect(snapshotId).toBeUndefined();
|
|
});
|
|
|
|
it('should switch to runtime model and apply snapshot configuration', async () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
generationConfig: {
|
|
model: 'runtime-model',
|
|
apiKey: 'sk-runtime-key',
|
|
baseUrl: 'https://runtime.example.com/v1',
|
|
samplingParams: { temperature: 0.7, max_tokens: 2000 },
|
|
},
|
|
generationConfigSources: {
|
|
model: { kind: 'programmatic', detail: 'test' },
|
|
apiKey: { kind: 'programmatic', detail: 'test' },
|
|
baseUrl: { kind: 'programmatic', detail: 'test' },
|
|
},
|
|
});
|
|
|
|
// Create initial snapshot
|
|
const initialSnapshotId = modelsConfig.detectAndCaptureRuntimeModel();
|
|
expect(initialSnapshotId).toBeDefined();
|
|
|
|
// Change to a different state
|
|
// Note: this updates the existing snapshot, changing its ID
|
|
modelsConfig.updateCredentials({
|
|
model: 'different-model',
|
|
apiKey: 'different-key',
|
|
baseUrl: 'https://different.example.com/v1',
|
|
});
|
|
|
|
// The snapshot ID has changed because we updated the model
|
|
const updatedSnapshotId = modelsConfig.getActiveRuntimeModelSnapshotId();
|
|
expect(updatedSnapshotId).toBe('$runtime|openai|different-model');
|
|
|
|
// Create a separate snapshot for the original runtime model
|
|
// (simulate having multiple runtime models available)
|
|
modelsConfig['runtimeModelSnapshots'].set(
|
|
'$runtime|openai|runtime-model',
|
|
{
|
|
id: '$runtime|openai|runtime-model',
|
|
authType: AuthType.USE_OPENAI,
|
|
modelId: 'runtime-model',
|
|
apiKey: 'sk-runtime-key',
|
|
baseUrl: 'https://runtime.example.com/v1',
|
|
generationConfig: {
|
|
samplingParams: { temperature: 0.7, max_tokens: 2000 },
|
|
},
|
|
sources: {
|
|
model: { kind: 'programmatic', detail: 'test' },
|
|
apiKey: { kind: 'programmatic', detail: 'test' },
|
|
baseUrl: { kind: 'programmatic', detail: 'test' },
|
|
},
|
|
createdAt: Date.now(),
|
|
},
|
|
);
|
|
|
|
// Switch back to original runtime model
|
|
await modelsConfig.switchToRuntimeModel('$runtime|openai|runtime-model');
|
|
|
|
const gc = currentGenerationConfig(modelsConfig);
|
|
expect(gc.model).toBe('runtime-model');
|
|
expect(gc.apiKey).toBe('sk-runtime-key');
|
|
expect(gc.baseUrl).toBe('https://runtime.example.com/v1');
|
|
expect(gc.samplingParams?.temperature).toBe(0.7);
|
|
expect(gc.samplingParams?.max_tokens).toBe(2000);
|
|
});
|
|
|
|
it('should throw error when switching to non-existent runtime snapshot', async () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
await expect(
|
|
modelsConfig.switchToRuntimeModel('$runtime|openai|nonexistent'),
|
|
).rejects.toThrow(
|
|
"Runtime model snapshot '$runtime|openai|nonexistent' not found",
|
|
);
|
|
});
|
|
|
|
it('should return runtime option first in getAllConfiguredModels', () => {
|
|
const modelProvidersConfig: ModelProvidersConfig = {
|
|
openai: [
|
|
{
|
|
id: 'registry-model',
|
|
name: 'Registry Model',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
envKey: 'OPENAI_API_KEY',
|
|
},
|
|
],
|
|
};
|
|
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig,
|
|
generationConfig: {
|
|
model: 'runtime-model',
|
|
apiKey: 'sk-test-key',
|
|
baseUrl: 'https://runtime.example.com/v1',
|
|
},
|
|
generationConfigSources: {
|
|
model: { kind: 'programmatic', detail: 'test' },
|
|
apiKey: { kind: 'programmatic', detail: 'test' },
|
|
baseUrl: { kind: 'programmatic', detail: 'test' },
|
|
},
|
|
});
|
|
|
|
modelsConfig.detectAndCaptureRuntimeModel();
|
|
|
|
const allModels = modelsConfig.getAllConfiguredModels();
|
|
|
|
// Runtime model should be first for USE_OPENAI
|
|
const openaiModels = allModels.filter(
|
|
(m) => m.authType === AuthType.USE_OPENAI,
|
|
);
|
|
expect(openaiModels.length).toBe(2);
|
|
expect(openaiModels[0].isRuntimeModel).toBe(true);
|
|
// AvailableModel.id should be modelId, runtimeSnapshotId should be snapshot.id
|
|
expect(openaiModels[0].id).toBe('runtime-model');
|
|
expect(openaiModels[0].runtimeSnapshotId).toBe(
|
|
'$runtime|openai|runtime-model',
|
|
);
|
|
expect(openaiModels[0].label).toBe('runtime-model');
|
|
expect(openaiModels[1].isRuntimeModel).toBeUndefined();
|
|
expect(openaiModels[1].id).toBe('registry-model');
|
|
});
|
|
|
|
it('should create/update runtime snapshot via updateCredentials', () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
// Update with complete credentials
|
|
modelsConfig.updateCredentials({
|
|
model: 'custom-model',
|
|
apiKey: 'sk-custom-key',
|
|
baseUrl: 'https://custom.example.com/v1',
|
|
});
|
|
|
|
const snapshot = modelsConfig.getActiveRuntimeModelSnapshot();
|
|
expect(snapshot).toBeDefined();
|
|
expect(snapshot?.modelId).toBe('custom-model');
|
|
expect(snapshot?.apiKey).toBe('sk-custom-key');
|
|
expect(snapshot?.baseUrl).toBe('https://custom.example.com/v1');
|
|
});
|
|
|
|
it('should update existing runtime snapshot when credentials change', () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
generationConfig: {
|
|
model: 'initial-model',
|
|
apiKey: 'sk-initial-key',
|
|
baseUrl: 'https://initial.example.com/v1',
|
|
},
|
|
generationConfigSources: {
|
|
model: { kind: 'programmatic', detail: 'test' },
|
|
apiKey: { kind: 'programmatic', detail: 'test' },
|
|
baseUrl: { kind: 'programmatic', detail: 'test' },
|
|
},
|
|
});
|
|
|
|
// Create initial snapshot
|
|
modelsConfig.detectAndCaptureRuntimeModel();
|
|
|
|
// Update credentials with different model
|
|
modelsConfig.updateCredentials({
|
|
model: 'updated-model',
|
|
apiKey: 'sk-updated-key',
|
|
});
|
|
|
|
const snapshot = modelsConfig.getActiveRuntimeModelSnapshot();
|
|
expect(snapshot).toBeDefined();
|
|
expect(snapshot?.modelId).toBe('updated-model');
|
|
expect(snapshot?.apiKey).toBe('sk-updated-key');
|
|
// baseUrl should be preserved from initial
|
|
expect(snapshot?.baseUrl).toBe('https://initial.example.com/v1');
|
|
});
|
|
|
|
it('should enforce per-authType snapshot limit', () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
// Create first snapshot for USE_OPENAI
|
|
modelsConfig.updateCredentials({
|
|
model: 'model-a',
|
|
apiKey: 'sk-key-a',
|
|
baseUrl: 'https://a.example.com/v1',
|
|
});
|
|
|
|
const firstSnapshotId = modelsConfig.getActiveRuntimeModelSnapshotId();
|
|
expect(firstSnapshotId).toBe('$runtime|openai|model-a');
|
|
|
|
// Create second snapshot for USE_OPENAI (different model)
|
|
modelsConfig.updateCredentials({
|
|
model: 'model-b',
|
|
apiKey: 'sk-key-b',
|
|
baseUrl: 'https://b.example.com/v1',
|
|
});
|
|
|
|
const secondSnapshotId = modelsConfig.getActiveRuntimeModelSnapshotId();
|
|
expect(secondSnapshotId).toBe('$runtime|openai|model-b');
|
|
|
|
// First snapshot should be cleaned up
|
|
expect(modelsConfig.getActiveRuntimeModelSnapshot()?.id).toBe(
|
|
secondSnapshotId,
|
|
);
|
|
});
|
|
|
|
it('should support multiple authTypes with separate snapshots', async () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
// Create OpenAI snapshot
|
|
modelsConfig.updateCredentials({
|
|
model: 'openai-model',
|
|
apiKey: 'sk-openai-key',
|
|
baseUrl: 'https://openai.example.com/v1',
|
|
});
|
|
|
|
// Verify OpenAI snapshot exists
|
|
const openaiSnapshot = modelsConfig.getActiveRuntimeModelSnapshot();
|
|
expect(openaiSnapshot?.authType).toBe(AuthType.USE_OPENAI);
|
|
expect(openaiSnapshot?.modelId).toBe('openai-model');
|
|
|
|
// Switch to Anthropic via switchToRuntimeModel
|
|
// First create an Anthropic snapshot manually
|
|
modelsConfig['runtimeModelSnapshots'].set(
|
|
'$runtime|anthropic|anthropic-model',
|
|
{
|
|
id: '$runtime|anthropic|anthropic-model',
|
|
authType: AuthType.USE_ANTHROPIC,
|
|
modelId: 'anthropic-model',
|
|
apiKey: 'sk-anthropic-key',
|
|
baseUrl: 'https://anthropic.example.com/v1',
|
|
sources: {
|
|
model: { kind: 'programmatic', detail: 'test' },
|
|
apiKey: { kind: 'programmatic', detail: 'test' },
|
|
baseUrl: { kind: 'programmatic', detail: 'test' },
|
|
},
|
|
createdAt: Date.now(),
|
|
},
|
|
);
|
|
|
|
// Switch to the Anthropic runtime model
|
|
await modelsConfig.switchToRuntimeModel(
|
|
'$runtime|anthropic|anthropic-model',
|
|
);
|
|
|
|
// Should now have Anthropic snapshot active
|
|
const anthropicSnapshot = modelsConfig.getActiveRuntimeModelSnapshot();
|
|
expect(anthropicSnapshot?.authType).toBe(AuthType.USE_ANTHROPIC);
|
|
expect(anthropicSnapshot?.modelId).toBe('anthropic-model');
|
|
});
|
|
|
|
it('should rollback state when switchToRuntimeModel fails', async () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
generationConfig: {
|
|
model: 'runtime-model',
|
|
apiKey: 'sk-runtime-key',
|
|
baseUrl: 'https://runtime.example.com/v1',
|
|
},
|
|
generationConfigSources: {
|
|
model: { kind: 'programmatic', detail: 'test' },
|
|
apiKey: { kind: 'programmatic', detail: 'test' },
|
|
baseUrl: { kind: 'programmatic', detail: 'test' },
|
|
},
|
|
});
|
|
|
|
// Create snapshot
|
|
const snapshotId = modelsConfig.detectAndCaptureRuntimeModel();
|
|
expect(snapshotId).toBeDefined();
|
|
|
|
// Set up onModelChange to fail
|
|
modelsConfig.setOnModelChange(async () => {
|
|
throw new Error('refresh failed');
|
|
});
|
|
|
|
// Store baseline state
|
|
const baselineModel = modelsConfig.getModel();
|
|
const baselineGc = snapshotGenerationConfig(modelsConfig);
|
|
|
|
// Try to switch - should fail
|
|
await expect(
|
|
modelsConfig.switchToRuntimeModel(snapshotId!),
|
|
).rejects.toThrow('refresh failed');
|
|
|
|
// State should be rolled back
|
|
expect(modelsConfig.getModel()).toBe(baselineModel);
|
|
expect(modelsConfig.getGenerationConfig()).toMatchObject({
|
|
model: baselineGc.model,
|
|
apiKey: baselineGc.apiKey,
|
|
baseUrl: baselineGc.baseUrl,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('reloadModelProvidersConfig', () => {
|
|
it('should reload model providers configuration', async () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig: {
|
|
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
|
},
|
|
});
|
|
|
|
// Verify initial model
|
|
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'gpt-4');
|
|
expect(modelsConfig.getModel()).toBe('gpt-4');
|
|
|
|
// Reload with new config
|
|
modelsConfig.reloadModelProvidersConfig({
|
|
openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }],
|
|
});
|
|
|
|
// After reload, old model should not exist
|
|
expect(
|
|
modelsConfig.getAllConfiguredModels().find((m) => m.id === 'gpt-4'),
|
|
).toBeUndefined();
|
|
expect(
|
|
modelsConfig.getAllConfiguredModels().find((m) => m.id === 'gpt-3.5'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('should preserve current model selection if still available after reload', async () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig: {
|
|
openai: [
|
|
{ id: 'gpt-4', name: 'GPT-4' },
|
|
{ id: 'gpt-3.5', name: 'GPT-3.5' },
|
|
],
|
|
},
|
|
});
|
|
|
|
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'gpt-4');
|
|
expect(modelsConfig.getModel()).toBe('gpt-4');
|
|
|
|
// Reload with config that still includes gpt-4
|
|
modelsConfig.reloadModelProvidersConfig({
|
|
openai: [
|
|
{ id: 'gpt-4', name: 'GPT-4 Updated' },
|
|
{ id: 'new-model', name: 'New Model' },
|
|
],
|
|
});
|
|
|
|
// Current model should still be available
|
|
const availableModels = modelsConfig.getAllConfiguredModels();
|
|
expect(availableModels.find((m) => m.id === 'gpt-4')).toBeDefined();
|
|
expect(availableModels.find((m) => m.id === 'new-model')).toBeDefined();
|
|
});
|
|
|
|
it('should update available models after reload', async () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig: {
|
|
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
|
},
|
|
});
|
|
|
|
const initialModels = modelsConfig.getAllConfiguredModels();
|
|
expect(initialModels.some((m) => m.id === 'gpt-4')).toBe(true);
|
|
expect(initialModels.some((m) => m.id === 'gemini-pro')).toBe(false);
|
|
|
|
// Reload with different config
|
|
modelsConfig.reloadModelProvidersConfig({
|
|
openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }],
|
|
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
|
|
});
|
|
|
|
const updatedModels = modelsConfig.getAllConfiguredModels();
|
|
expect(updatedModels.some((m) => m.id === 'gpt-4')).toBe(false);
|
|
expect(updatedModels.some((m) => m.id === 'gpt-3.5')).toBe(true);
|
|
expect(updatedModels.some((m) => m.id === 'gemini-pro')).toBe(true);
|
|
});
|
|
|
|
it('should handle reload with empty config', async () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig: {
|
|
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
|
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
|
|
},
|
|
});
|
|
|
|
expect(
|
|
modelsConfig
|
|
.getAllConfiguredModels()
|
|
.filter((m) => m.authType !== 'qwen-oauth').length,
|
|
).toBeGreaterThan(0);
|
|
|
|
// Reload with empty config
|
|
modelsConfig.reloadModelProvidersConfig({});
|
|
|
|
// Only qwen-oauth models should remain
|
|
const models = modelsConfig.getAllConfiguredModels();
|
|
expect(models.every((m) => m.authType === 'qwen-oauth')).toBe(true);
|
|
});
|
|
|
|
it('should preserve qwen-oauth models after reload', () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
modelProvidersConfig: {
|
|
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
|
},
|
|
});
|
|
|
|
const initialQwenModels = modelsConfig
|
|
.getAllConfiguredModels()
|
|
.filter((m) => m.authType === 'qwen-oauth');
|
|
|
|
modelsConfig.reloadModelProvidersConfig({
|
|
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
|
|
});
|
|
|
|
// qwen-oauth models should still exist
|
|
const qwenModelsAfterReload = modelsConfig
|
|
.getAllConfiguredModels()
|
|
.filter((m) => m.authType === 'qwen-oauth');
|
|
expect(qwenModelsAfterReload.length).toBe(initialQwenModels.length);
|
|
});
|
|
|
|
it('should handle reload with undefined config', () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
modelProvidersConfig: {
|
|
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
|
},
|
|
});
|
|
|
|
expect(
|
|
modelsConfig
|
|
.getAllConfiguredModels()
|
|
.filter((m) => m.authType === 'openai').length,
|
|
).toBeGreaterThan(0);
|
|
|
|
modelsConfig.reloadModelProvidersConfig(undefined);
|
|
|
|
// User-configured models should be cleared
|
|
expect(
|
|
modelsConfig
|
|
.getAllConfiguredModels()
|
|
.filter((m) => m.authType === 'openai').length,
|
|
).toBe(0);
|
|
});
|
|
|
|
it('should support multiple reloads', () => {
|
|
const modelsConfig = new ModelsConfig();
|
|
|
|
// First reload
|
|
modelsConfig.reloadModelProvidersConfig({
|
|
openai: [{ id: 'model-v1', name: 'Model V1' }],
|
|
});
|
|
expect(
|
|
modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v1'),
|
|
).toBe(true);
|
|
|
|
// Second reload
|
|
modelsConfig.reloadModelProvidersConfig({
|
|
openai: [{ id: 'model-v2', name: 'Model V2' }],
|
|
});
|
|
expect(
|
|
modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v1'),
|
|
).toBe(false);
|
|
expect(
|
|
modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v2'),
|
|
).toBe(true);
|
|
|
|
// Third reload with empty config
|
|
modelsConfig.reloadModelProvidersConfig({});
|
|
expect(
|
|
modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v2'),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('should handle complex multi-authType reload', async () => {
|
|
const modelsConfig = new ModelsConfig({
|
|
initialAuthType: AuthType.USE_OPENAI,
|
|
modelProvidersConfig: {
|
|
openai: [
|
|
{ id: 'gpt-4', name: 'GPT-4' },
|
|
{ id: 'gpt-3.5', name: 'GPT-3.5' },
|
|
],
|
|
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
|
|
},
|
|
});
|
|
|
|
// Reload with completely different config
|
|
modelsConfig.reloadModelProvidersConfig({
|
|
openai: [{ id: 'new-openai', name: 'New OpenAI' }],
|
|
anthropic: [{ id: 'claude', name: 'Claude' }],
|
|
gemini: [{ id: 'gemini-ultra', name: 'Gemini Ultra' }],
|
|
});
|
|
|
|
const allModels = modelsConfig.getAllConfiguredModels();
|
|
|
|
// Old models should be gone
|
|
expect(allModels.some((m) => m.id === 'gpt-4')).toBe(false);
|
|
expect(allModels.some((m) => m.id === 'gpt-3.5')).toBe(false);
|
|
expect(allModels.some((m) => m.id === 'gemini-pro')).toBe(false);
|
|
|
|
// New models should exist
|
|
expect(allModels.some((m) => m.id === 'new-openai')).toBe(true);
|
|
expect(allModels.some((m) => m.id === 'claude')).toBe(true);
|
|
expect(allModels.some((m) => m.id === 'gemini-ultra')).toBe(true);
|
|
});
|
|
});
|
|
});
|