mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-24 00:14:12 +00:00
* feat(cli): add serve env isolation and total admission Add runtime-local serve env snapshots, explicit env injection for low-cost workspace-scoped consumers, and sourceEnv support for ACP child spawn. Add a daemon-wide maxTotalSessions admission reservation hook for fresh session creation while keeping multi-workspace sessions gated. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6416) Reject fractional maxTotalSessions values so the daemon-wide session cap remains an integer count and matches the documented limit semantics. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address PR review feedback (#6416) Always pass the runtime env to A2UI stdio transports, keep daemon runtime env metadata coherent after env reload fallback, and tighten total-admission coverage. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address total admission review feedback (#6416) Add retryable ACP error data for total session limits, log total-admission REST rejections, and keep session-limit response scopes explicit. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address env review feedback (#6416) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): restore scheduled task serve deps (#6416) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): isolate runtime env reload base (#6416) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6416) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6416) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6416) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6416) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix CI failure on PR #6416 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address daemon admission review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address runtime env review feedback Scrub daemon bearer tokens from A2UI stdio MCP environments and prune reload-owned keys from the daemon runtime base before rebuilding runtime env snapshots. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): preserve daemon env base on reload Keep runtime env rebuilds anchored to the boot-time daemon base snapshot, preventing reload-owned key pruning from dropping valid shell-exported values. Also carry env file read failure details into runtime metadata and daemon logs. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): satisfy env metadata lint rules Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1828 lines
53 KiB
TypeScript
1828 lines
53 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import {
|
|
AuthType,
|
|
resolveModelConfig,
|
|
type ProviderModelConfig,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import {
|
|
getAuthTypeFromEnv,
|
|
resolveCliGenerationConfig,
|
|
} from './modelConfigUtils.js';
|
|
import type { Settings } from '../config/settings.js';
|
|
|
|
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|
const original =
|
|
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
|
return {
|
|
...original,
|
|
resolveModelConfig: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('./stdioHelpers.js', () => ({
|
|
writeStderrLine: mockWriteStderrLine,
|
|
writeStdoutLine: vi.fn(),
|
|
clearScreen: vi.fn(),
|
|
}));
|
|
|
|
describe('modelConfigUtils', () => {
|
|
describe('getAuthTypeFromEnv', () => {
|
|
const originalEnv = process.env;
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
// Start with a clean env - getAuthTypeFromEnv only checks auth-related vars
|
|
process.env = {};
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
});
|
|
|
|
it('should return USE_OPENAI when all OpenAI env vars are set', () => {
|
|
process.env['OPENAI_API_KEY'] = 'test-key';
|
|
process.env['OPENAI_MODEL'] = 'gpt-4';
|
|
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
|
|
|
|
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_OPENAI);
|
|
});
|
|
|
|
it('uses an injected env instead of process.env when provided', () => {
|
|
process.env['OPENAI_API_KEY'] = 'process-key';
|
|
process.env['OPENAI_MODEL'] = 'process-model';
|
|
process.env['OPENAI_BASE_URL'] = 'https://process.example';
|
|
|
|
expect(
|
|
getAuthTypeFromEnv({
|
|
GEMINI_API_KEY: 'runtime-key',
|
|
GEMINI_MODEL: 'runtime-model',
|
|
}),
|
|
).toBe(AuthType.USE_GEMINI);
|
|
});
|
|
|
|
it('should return USE_OPENAI when the model is given via QWEN_MODEL', () => {
|
|
// QWEN_MODEL is a valid USE_OPENAI model var (see AUTH_ENV_MODEL_VARS),
|
|
// so a config that sets it instead of OPENAI_MODEL must still resolve.
|
|
process.env['OPENAI_API_KEY'] = 'test-key';
|
|
process.env['QWEN_MODEL'] = 'qwen3-coder-plus';
|
|
process.env['OPENAI_BASE_URL'] =
|
|
'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
|
|
|
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_OPENAI);
|
|
});
|
|
|
|
it('should return undefined when OpenAI env vars are incomplete', () => {
|
|
process.env['OPENAI_API_KEY'] = 'test-key';
|
|
process.env['OPENAI_MODEL'] = 'gpt-4';
|
|
// Missing OPENAI_BASE_URL
|
|
|
|
expect(getAuthTypeFromEnv()).toBeUndefined();
|
|
});
|
|
|
|
it('should return QWEN_OAUTH when QWEN_OAUTH is set', () => {
|
|
process.env['QWEN_OAUTH'] = 'true';
|
|
|
|
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
|
|
});
|
|
|
|
it('should return USE_GEMINI when Gemini env vars are set', () => {
|
|
process.env['GEMINI_API_KEY'] = 'test-key';
|
|
process.env['GEMINI_MODEL'] = 'gemini-pro';
|
|
|
|
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_GEMINI);
|
|
});
|
|
|
|
it('should return undefined when Gemini env vars are incomplete', () => {
|
|
process.env['GEMINI_API_KEY'] = 'test-key';
|
|
// Missing GEMINI_MODEL
|
|
|
|
expect(getAuthTypeFromEnv()).toBeUndefined();
|
|
});
|
|
|
|
it('should return USE_VERTEX_AI when Google env vars are set', () => {
|
|
process.env['GOOGLE_API_KEY'] = 'test-key';
|
|
process.env['GOOGLE_MODEL'] = 'vertex-model';
|
|
|
|
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_VERTEX_AI);
|
|
});
|
|
|
|
it('should return undefined when Google env vars are incomplete', () => {
|
|
process.env['GOOGLE_API_KEY'] = 'test-key';
|
|
// Missing GOOGLE_MODEL
|
|
|
|
expect(getAuthTypeFromEnv()).toBeUndefined();
|
|
});
|
|
|
|
it('should return USE_ANTHROPIC when Anthropic env vars are set', () => {
|
|
process.env['ANTHROPIC_API_KEY'] = 'test-key';
|
|
process.env['ANTHROPIC_MODEL'] = 'claude-3';
|
|
process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com';
|
|
|
|
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_ANTHROPIC);
|
|
});
|
|
|
|
it('should return undefined when Anthropic env vars are incomplete', () => {
|
|
process.env['ANTHROPIC_API_KEY'] = 'test-key';
|
|
process.env['ANTHROPIC_MODEL'] = 'claude-3';
|
|
// Missing ANTHROPIC_BASE_URL
|
|
|
|
expect(getAuthTypeFromEnv()).toBeUndefined();
|
|
});
|
|
|
|
it('should prioritize QWEN_OAUTH over other auth types when explicitly set', () => {
|
|
process.env['QWEN_OAUTH'] = 'true';
|
|
process.env['OPENAI_API_KEY'] = 'test-key';
|
|
process.env['OPENAI_MODEL'] = 'gpt-4';
|
|
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
|
|
|
|
// QWEN_OAUTH is checked first, so it should be returned even when other auth vars are set
|
|
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
|
|
});
|
|
|
|
it('should return undefined when no auth env vars are set', () => {
|
|
expect(getAuthTypeFromEnv()).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('resolveCliGenerationConfig', () => {
|
|
const originalEnv = process.env;
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
process.env = { ...originalEnv };
|
|
delete process.env['OPENAI_MODEL'];
|
|
delete process.env['QWEN_MODEL'];
|
|
mockWriteStderrLine.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function makeMockSettings(overrides?: Partial<Settings>): Settings {
|
|
return {
|
|
model: { name: 'default-model' },
|
|
security: {
|
|
auth: {
|
|
apiKey: 'settings-api-key',
|
|
baseUrl: 'https://settings.example.com',
|
|
},
|
|
},
|
|
...overrides,
|
|
} as Settings;
|
|
}
|
|
|
|
it('should resolve config from argv with highest precedence', () => {
|
|
const argv = {
|
|
model: 'argv-model',
|
|
openaiApiKey: 'argv-key',
|
|
openaiBaseUrl: 'https://argv.example.com',
|
|
};
|
|
const settings = makeMockSettings();
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'argv-model',
|
|
apiKey: 'argv-key',
|
|
baseUrl: 'https://argv.example.com',
|
|
},
|
|
sources: {
|
|
model: { kind: 'cli', detail: '--model' },
|
|
apiKey: { kind: 'cli', detail: '--openaiApiKey' },
|
|
baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' },
|
|
},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.model).toBe('argv-model');
|
|
expect(result.apiKey).toBe('argv-key');
|
|
expect(result.baseUrl).toBe('https://argv.example.com');
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cli: {
|
|
model: 'argv-model',
|
|
apiKey: 'argv-key',
|
|
baseUrl: 'https://argv.example.com',
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should resolve config from settings when argv is not provided', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings({
|
|
model: { name: 'settings-model' },
|
|
security: {
|
|
auth: {
|
|
apiKey: 'settings-key',
|
|
baseUrl: 'https://settings.example.com',
|
|
},
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'settings-model',
|
|
apiKey: 'settings-key',
|
|
baseUrl: 'https://settings.example.com',
|
|
},
|
|
sources: {
|
|
model: { kind: 'settings', detail: 'model.name' },
|
|
apiKey: { kind: 'settings', detail: 'security.auth.apiKey' },
|
|
baseUrl: { kind: 'settings', detail: 'security.auth.baseUrl' },
|
|
},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.model).toBe('settings-model');
|
|
expect(result.apiKey).toBe('settings-key');
|
|
expect(result.baseUrl).toBe('https://settings.example.com');
|
|
});
|
|
|
|
it('should merge generationConfig from settings', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'test-model',
|
|
generationConfig: {
|
|
samplingParams: {
|
|
temperature: 0.7,
|
|
max_tokens: 1000,
|
|
},
|
|
timeout: 5000,
|
|
} as Record<string, unknown>,
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'test-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
samplingParams: {
|
|
temperature: 0.7,
|
|
max_tokens: 1000,
|
|
},
|
|
timeout: 5000,
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.generationConfig.samplingParams?.temperature).toBe(0.7);
|
|
expect(result.generationConfig.samplingParams?.max_tokens).toBe(1000);
|
|
expect(result.generationConfig.timeout).toBe(5000);
|
|
});
|
|
|
|
it('should resolve OpenAI logging from argv', () => {
|
|
const argv = {
|
|
openaiLogging: true,
|
|
openaiLoggingDir: '/custom/log/dir',
|
|
};
|
|
const settings = makeMockSettings();
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'test-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.generationConfig.enableOpenAILogging).toBe(true);
|
|
expect(result.generationConfig.openAILoggingDir).toBe('/custom/log/dir');
|
|
});
|
|
|
|
it('should resolve OpenAI logging from settings when argv is undefined', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'test-model',
|
|
enableOpenAILogging: true,
|
|
openAILoggingDir: '/settings/log/dir',
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'test-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.generationConfig.enableOpenAILogging).toBe(true);
|
|
expect(result.generationConfig.openAILoggingDir).toBe(
|
|
'/settings/log/dir',
|
|
);
|
|
});
|
|
|
|
it('should default OpenAI logging to false when not provided', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings();
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'test-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.generationConfig.enableOpenAILogging).toBe(false);
|
|
});
|
|
|
|
it('should find modelProvider from settings when authType and model match', () => {
|
|
const argv = { model: 'provider-model' };
|
|
const modelProvider: ProviderModelConfig = {
|
|
id: 'provider-model',
|
|
name: 'Provider Model',
|
|
generationConfig: {
|
|
samplingParams: { temperature: 0.8 },
|
|
},
|
|
};
|
|
const settings = makeMockSettings({
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [modelProvider],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'provider-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('finds a custom-id provider via providerProtocol (honors its envKey/metadata)', () => {
|
|
const argv = { model: 'qwen3.7-max' };
|
|
const modelProvider: ProviderModelConfig = {
|
|
id: 'qwen3.7-max',
|
|
name: 'Idealab Max',
|
|
envKey: 'IDEALAB_KEY',
|
|
generationConfig: { samplingParams: { temperature: 0.5 } },
|
|
};
|
|
// Stored under the custom key `idealab`, NOT under `openai`.
|
|
const settings = makeMockSettings({
|
|
modelProviders: {
|
|
idealab: [modelProvider],
|
|
} as unknown as Settings['modelProviders'],
|
|
providerProtocol: {
|
|
idealab: 'openai',
|
|
} as unknown as Settings['providerProtocol'],
|
|
});
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'qwen3.7-max', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
// Without the reverse lookup, modelProviders['openai'] is empty and the
|
|
// custom provider's envKey would be lost.
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ modelProvider }),
|
|
);
|
|
});
|
|
|
|
it('warns when a provider id has models but no resolvable protocol', () => {
|
|
const settings = makeMockSettings({
|
|
model: { name: 'some-model' },
|
|
modelProviders: {
|
|
idealab: [{ id: 'qwen3.7-max' }],
|
|
} as unknown as Settings['modelProviders'],
|
|
// No providerProtocol entry for `idealab`.
|
|
});
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'some-model', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
expect(
|
|
result.warnings.some(
|
|
(w) => w.includes('idealab') && w.includes('providerProtocol'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('warns when providerProtocol maps to an unknown protocol', () => {
|
|
const settings = makeMockSettings({
|
|
model: { name: 'some-model' },
|
|
modelProviders: {
|
|
idealab: [{ id: 'qwen3.7-max' }],
|
|
} as unknown as Settings['modelProviders'],
|
|
providerProtocol: {
|
|
idealab: 'not-a-protocol',
|
|
} as unknown as Settings['providerProtocol'],
|
|
});
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'some-model', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
expect(result.warnings).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining(
|
|
'providerProtocol["idealab"] = "not-a-protocol" is not a known protocol',
|
|
),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('warns when a custom provider maps to qwen-oauth', () => {
|
|
const settings = makeMockSettings({
|
|
model: { name: 'some-model' },
|
|
modelProviders: {
|
|
idealab: [{ id: 'qwen3.7-max' }],
|
|
} as unknown as Settings['modelProviders'],
|
|
providerProtocol: {
|
|
idealab: AuthType.QWEN_OAUTH,
|
|
} as unknown as Settings['providerProtocol'],
|
|
});
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'some-model', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
expect(result.warnings).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining(
|
|
'modelProviders provider "idealab" maps to "qwen-oauth"',
|
|
),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should find modelProvider from settings.model.name when argv.model is not provided', () => {
|
|
const argv = {};
|
|
const modelProvider: ProviderModelConfig = {
|
|
id: 'settings-model',
|
|
name: 'Settings Model',
|
|
generationConfig: {
|
|
samplingParams: { temperature: 0.9 },
|
|
},
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: { name: 'settings-model' },
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [modelProvider],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'settings-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider,
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe('provider disambiguation by settings.model.baseUrl', () => {
|
|
const tokenPlan: ProviderModelConfig = {
|
|
id: 'qwen3.7-max',
|
|
name: '[Token Plan] qwen3.7-max',
|
|
baseUrl: 'https://token-plan.example.com/v1',
|
|
envKey: 'TOKEN_PLAN_KEY',
|
|
};
|
|
const ideaLab: ProviderModelConfig = {
|
|
id: 'qwen3.7-max',
|
|
name: '[IdeaLab] qwen3.7-max',
|
|
baseUrl: 'https://idealab.example.com/v1',
|
|
envKey: 'IDEALAB_KEY',
|
|
};
|
|
|
|
function mockResolved() {
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'qwen3.7-max', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
}
|
|
|
|
it('selects the provider matching the persisted baseUrl, not the first id match', () => {
|
|
mockResolved();
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'qwen3.7-max',
|
|
baseUrl: 'https://idealab.example.com/v1',
|
|
},
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [tokenPlan, ideaLab],
|
|
},
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
// The IdeaLab provider (not the first-listed Token Plan) must be passed
|
|
// to resolveModelConfig, which is what makes sources.baseUrl.kind become
|
|
// 'modelProviders' and lets syncAfterAuthRefresh keep it after refresh.
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ modelProvider: ideaLab }),
|
|
);
|
|
});
|
|
|
|
it('falls back to the first id match when no baseUrl is persisted (backward compat)', () => {
|
|
mockResolved();
|
|
const settings = makeMockSettings({
|
|
model: { name: 'qwen3.7-max' },
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [tokenPlan, ideaLab],
|
|
},
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ modelProvider: tokenPlan }),
|
|
);
|
|
});
|
|
|
|
it('falls back to the first id match and emits a warning when the persisted baseUrl no longer matches any provider', () => {
|
|
mockResolved();
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'qwen3.7-max',
|
|
baseUrl: 'https://removed.example.com/v1',
|
|
},
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [tokenPlan, ideaLab],
|
|
},
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ modelProvider: tokenPlan }),
|
|
);
|
|
expect(result.warnings).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('https://removed.example.com/v1'),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('treats an empty-string tombstone as no disambiguator (first id match)', () => {
|
|
// A cleared selection persists model.baseUrl: '' so it can override a
|
|
// stale lower-scope value on merge; the resolver must treat '' as "no
|
|
// baseUrl" and fall back to the first id match rather than matching a
|
|
// provider whose baseUrl is literally empty.
|
|
mockResolved();
|
|
const settings = makeMockSettings({
|
|
model: { name: 'qwen3.7-max', baseUrl: '' },
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [tokenPlan, ideaLab],
|
|
},
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ modelProvider: tokenPlan }),
|
|
);
|
|
});
|
|
|
|
it('ignores the persisted baseUrl when the model comes from argv.model, not settings', () => {
|
|
mockResolved();
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'something-else',
|
|
baseUrl: 'https://idealab.example.com/v1',
|
|
},
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [tokenPlan, ideaLab],
|
|
},
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv: { model: 'qwen3.7-max' },
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
// argv-resolved model uses id-only lookup → first match.
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ modelProvider: tokenPlan }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('strips a runtime snapshot prefix from settings.model.name (read-side self-heal)', () => {
|
|
const modelProvider: ProviderModelConfig = {
|
|
id: 'gpt-4o',
|
|
name: 'GPT-4o',
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: { name: '$runtime|openai|gpt-4o' },
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [modelProvider],
|
|
},
|
|
});
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'gpt-4o', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
// Both read-side strip sites: the bare id is used for the provider
|
|
// lookup and passed through to resolveModelConfig as settings.model.
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider,
|
|
settings: expect.objectContaining({ model: 'gpt-4o' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('collapses a stacked runtime snapshot prefix from settings.model.name', () => {
|
|
const modelProvider: ProviderModelConfig = {
|
|
id: 'gpt-4o',
|
|
name: 'GPT-4o',
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: { name: '$runtime|openai|$runtime|openai|gpt-4o' },
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [modelProvider],
|
|
},
|
|
});
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'gpt-4o', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider,
|
|
settings: expect.objectContaining({ model: 'gpt-4o' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should not find modelProvider when authType is undefined', () => {
|
|
const argv = { model: 'test-model' };
|
|
const settings = makeMockSettings({
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [{ id: 'test-model', name: 'Test Model' }],
|
|
},
|
|
});
|
|
const selectedAuthType = undefined;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'test-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should not find modelProvider when modelProviders is not an array', () => {
|
|
const argv = { model: 'test-model' };
|
|
const settings = makeMockSettings({
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: null as unknown as ProviderModelConfig[],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'test-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should return warnings from resolveModelConfig', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings();
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'test-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: ['Warning 1', 'Warning 2'],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.warnings).toEqual(['Warning 1', 'Warning 2']);
|
|
});
|
|
|
|
it('should warn when top-level generationConfig fields are ignored for a provider model', () => {
|
|
const argv = {};
|
|
const modelProvider: ProviderModelConfig = {
|
|
id: 'qwen3.6-27b',
|
|
name: 'qwen3.6-27b',
|
|
baseUrl: 'http://localhost:8080/v1',
|
|
envKey: 'IK_LLAMA_API_KEY',
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'qwen3.6-27b',
|
|
generationConfig: {
|
|
contextWindowSize: 192000,
|
|
extra_body: {
|
|
enable_thinking: true,
|
|
},
|
|
} as Record<string, unknown>,
|
|
},
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [modelProvider],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'qwen3.6-27b',
|
|
apiKey: '',
|
|
baseUrl: 'http://localhost:8080/v1',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.warnings).toHaveLength(1);
|
|
expect(result.warnings[0]).toContain(
|
|
'model.generationConfig.contextWindowSize',
|
|
);
|
|
expect(result.warnings[0]).toContain('model.generationConfig.extra_body');
|
|
expect(result.warnings[0]).toContain(
|
|
'provider model "qwen3.6-27b" from modelProviders.openai',
|
|
);
|
|
expect(result.warnings[0]).toContain(
|
|
'modelProviders.openai[].generationConfig',
|
|
);
|
|
});
|
|
|
|
it('names the custom provider in ignored generationConfig warnings', () => {
|
|
const argv = {};
|
|
const modelProvider: ProviderModelConfig = {
|
|
id: 'qwen3.6-27b',
|
|
name: 'qwen3.6-27b',
|
|
generationConfig: {},
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'qwen3.6-27b',
|
|
generationConfig: {
|
|
contextWindowSize: 192000,
|
|
} as Record<string, unknown>,
|
|
},
|
|
modelProviders: {
|
|
idealab: [modelProvider],
|
|
} as unknown as Settings['modelProviders'],
|
|
providerProtocol: {
|
|
idealab: AuthType.USE_OPENAI,
|
|
} as unknown as Settings['providerProtocol'],
|
|
});
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'qwen3.6-27b',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType: AuthType.USE_OPENAI,
|
|
});
|
|
|
|
expect(result.warnings[0]).toContain(
|
|
'provider model "qwen3.6-27b" from modelProviders.idealab',
|
|
);
|
|
expect(result.warnings[0]).toContain(
|
|
'modelProviders.idealab[].generationConfig',
|
|
);
|
|
expect(result.warnings[0]).not.toContain('modelProviders.openai');
|
|
});
|
|
|
|
it('should not warn for top-level generationConfig on runtime models', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'runtime-model',
|
|
generationConfig: {
|
|
contextWindowSize: 192000,
|
|
} as Record<string, unknown>,
|
|
},
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [{ id: 'other-model', name: 'Other Model' }],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'runtime-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
contextWindowSize: 192000,
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.warnings).toEqual([]);
|
|
});
|
|
|
|
it('should not warn for generationConfig fields defined by the provider model', () => {
|
|
const argv = {};
|
|
const modelProvider: ProviderModelConfig = {
|
|
id: 'provider-model',
|
|
name: 'Provider Model',
|
|
generationConfig: {
|
|
contextWindowSize: 192000,
|
|
},
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'provider-model',
|
|
generationConfig: {
|
|
contextWindowSize: 128000,
|
|
} as Record<string, unknown>,
|
|
},
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [modelProvider],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'provider-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
contextWindowSize: 192000,
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.warnings).toEqual([]);
|
|
});
|
|
|
|
it('should not warn for unknown top-level generationConfig fields', () => {
|
|
const argv = {};
|
|
const modelProvider: ProviderModelConfig = {
|
|
id: 'provider-model',
|
|
name: 'Provider Model',
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'provider-model',
|
|
generationConfig: {
|
|
contextWindwSize: 128000,
|
|
} as Record<string, unknown>,
|
|
},
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [modelProvider],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'provider-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.warnings).toEqual([]);
|
|
});
|
|
|
|
it('should append ignored generationConfig warnings to resolver warnings', () => {
|
|
const argv = {};
|
|
const modelProvider: ProviderModelConfig = {
|
|
id: 'provider-model',
|
|
name: 'Provider Model',
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'provider-model',
|
|
generationConfig: {
|
|
modalities: { image: false },
|
|
} as Record<string, unknown>,
|
|
},
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [modelProvider],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'provider-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: ['Resolver warning'],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.warnings).toHaveLength(2);
|
|
expect(result.warnings[0]).toBe('Resolver warning');
|
|
expect(result.warnings[1]).toContain(
|
|
'model.generationConfig.modalities is ignored',
|
|
);
|
|
expect(result.warnings[1]).toContain('Move this field');
|
|
});
|
|
|
|
it('should use custom env when provided', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings({
|
|
model: undefined as unknown as Settings['model'],
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
const customEnv = {
|
|
OPENAI_API_KEY: 'custom-key',
|
|
OPENAI_MODEL: 'custom-model',
|
|
};
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'custom-model',
|
|
apiKey: 'custom-key',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env: customEnv,
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
env: customEnv,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should use process.env (filtered) when env is not provided', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings();
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'test-model',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
// process.env is filtered: model env vars stripped since model came from settings
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
env: expect.not.objectContaining({
|
|
OPENAI_MODEL: expect.anything(),
|
|
QWEN_MODEL: expect.anything(),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should return empty strings for missing model, apiKey, and baseUrl', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings();
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: '',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.model).toBe('');
|
|
expect(result.apiKey).toBe('');
|
|
expect(result.baseUrl).toBe('');
|
|
});
|
|
|
|
it('should merge resolved config with logging settings', () => {
|
|
const argv = {
|
|
openaiLogging: true,
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: {
|
|
name: 'test-model',
|
|
generationConfig: {
|
|
timeout: 5000,
|
|
} as Record<string, unknown>,
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: 'test-model',
|
|
apiKey: 'test-key',
|
|
baseUrl: 'https://test.com',
|
|
samplingParams: { temperature: 0.5 },
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.generationConfig).toEqual({
|
|
model: 'test-model',
|
|
apiKey: 'test-key',
|
|
baseUrl: 'https://test.com',
|
|
samplingParams: { temperature: 0.5 },
|
|
enableOpenAILogging: true,
|
|
openAILoggingDir: undefined,
|
|
});
|
|
});
|
|
|
|
it('should handle settings without model property', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings({
|
|
model: undefined as unknown as Settings['model'],
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: '',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(result.model).toBe('');
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
settings: expect.objectContaining({
|
|
model: undefined,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should handle settings without security.auth property', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings({
|
|
security: undefined,
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: {
|
|
model: '',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
},
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
settings: expect.objectContaining({
|
|
apiKey: undefined,
|
|
baseUrl: undefined,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Case A: settings.model.name wins over OPENAI_MODEL when it matches a modelProvider
|
|
it('Case A: should use settings.model.name for modelProvider lookup even when OPENAI_MODEL is set', () => {
|
|
const argv = {};
|
|
const settingsProvider: ProviderModelConfig = {
|
|
id: 'settings-model',
|
|
name: 'Settings Model',
|
|
generationConfig: { samplingParams: { temperature: 0.9 } },
|
|
};
|
|
const envProvider: ProviderModelConfig = {
|
|
id: 'env-model',
|
|
name: 'Env Model',
|
|
generationConfig: { samplingParams: { temperature: 0.5 } },
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: { name: 'settings-model' },
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [settingsProvider, envProvider],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'settings-model', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env: { OPENAI_MODEL: 'env-model' },
|
|
});
|
|
|
|
// settings.model.name should win - modelProvider should be settingsProvider
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider: settingsProvider,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Case B: OPENAI_MODEL is honored when settings.model.name is not set
|
|
it('Case B: should use OPENAI_MODEL when settings.model.name is not set', () => {
|
|
const argv = {};
|
|
const envProvider: ProviderModelConfig = {
|
|
id: 'env-model',
|
|
name: 'Env Model',
|
|
generationConfig: { samplingParams: { temperature: 0.7 } },
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: undefined as unknown as Settings['model'],
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [
|
|
{ id: 'other-model', name: 'Other Model' },
|
|
envProvider,
|
|
],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'env-model', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env: { OPENAI_MODEL: 'env-model' },
|
|
});
|
|
|
|
// OPENAI_MODEL should be used since settings.model.name is not set
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider: envProvider,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Edge Case 1: argv.model overrides everything including settings.model.name and OPENAI_MODEL
|
|
it('Edge Case 1: argv.model should override settings.model.name and OPENAI_MODEL', () => {
|
|
const argv = { model: 'cli-model' };
|
|
const cliProvider: ProviderModelConfig = {
|
|
id: 'cli-model',
|
|
name: 'CLI Model',
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: { name: 'settings-model' },
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [
|
|
{ id: 'settings-model', name: 'Settings Model' },
|
|
{ id: 'env-model', name: 'Env Model' },
|
|
cliProvider,
|
|
],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'cli-model', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env: { OPENAI_MODEL: 'env-model' },
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider: cliProvider,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Edge Case 2: QWEN_MODEL is used as final fallback when OPENAI_MODEL is not set
|
|
it('Edge Case 2: QWEN_MODEL should be used as fallback when OPENAI_MODEL is not set', () => {
|
|
const argv = {};
|
|
const qwenProvider: ProviderModelConfig = {
|
|
id: 'qwen-env-model',
|
|
name: 'Qwen Env Model',
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: undefined as unknown as Settings['model'],
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [
|
|
{ id: 'other-model', name: 'Other Model' },
|
|
qwenProvider,
|
|
],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'qwen-env-model', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env: { QWEN_MODEL: 'qwen-env-model' },
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider: qwenProvider,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Edge Case 3: OPENAI_MODEL over QWEN_MODEL when both are set and settings.model.name is not set
|
|
it('Edge Case 3: OPENAI_MODEL should win over QWEN_MODEL when both set', () => {
|
|
const argv = {};
|
|
const openAIProvider: ProviderModelConfig = {
|
|
id: 'openai-env-model',
|
|
name: 'OpenAI Env Model',
|
|
};
|
|
const qwenProvider: ProviderModelConfig = {
|
|
id: 'qwen-env-model',
|
|
name: 'Qwen Env Model',
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: undefined as unknown as Settings['model'],
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [
|
|
{ id: 'other-model', name: 'Other Model' },
|
|
openAIProvider,
|
|
qwenProvider,
|
|
],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'openai-env-model', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env: {
|
|
OPENAI_MODEL: 'openai-env-model',
|
|
QWEN_MODEL: 'qwen-env-model',
|
|
},
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider: openAIProvider,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Edge Case 4: Non-OpenAI auth should ignore OPENAI_MODEL
|
|
it('Edge Case 4: non-OpenAI auth should ignore OPENAI_MODEL', () => {
|
|
const argv = {};
|
|
const settingsProvider: ProviderModelConfig = {
|
|
id: 'settings-model',
|
|
name: 'Settings Model',
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: { name: 'settings-model' },
|
|
modelProviders: {
|
|
[AuthType.USE_ANTHROPIC]: [settingsProvider],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_ANTHROPIC;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'settings-model', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env: { OPENAI_MODEL: 'some-other-model' },
|
|
});
|
|
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider: settingsProvider,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Edge Case 5: settings.model.name does NOT fall back to OPENAI_MODEL when unmatched
|
|
it('Edge Case 5: settings.model.name should NOT fall back to OPENAI_MODEL when unmatched', () => {
|
|
const argv = {};
|
|
const envProvider: ProviderModelConfig = {
|
|
id: 'env-model',
|
|
name: 'Env Model',
|
|
};
|
|
const settings = makeMockSettings({
|
|
model: { name: 'non-existent-model' },
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [
|
|
{ id: 'other-model', name: 'Other Model' },
|
|
envProvider,
|
|
],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
vi.mocked(resolveModelConfig).mockReturnValue({
|
|
config: { model: 'non-existent-model', apiKey: '', baseUrl: '' },
|
|
sources: {},
|
|
warnings: [],
|
|
});
|
|
|
|
resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env: { OPENAI_MODEL: 'env-model' },
|
|
});
|
|
|
|
// settings.model.name takes precedence even when unmatched - no provider fallback
|
|
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
modelProvider: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Integration test: settings.model.name unmatched + OPENAI_MODEL matched
|
|
it('Integration: settings.model.name wins over OPENAI_MODEL even when unmatched', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings({
|
|
model: { name: 'custom-model' },
|
|
modelProviders: {
|
|
[AuthType.USE_OPENAI]: [
|
|
{
|
|
id: 'gpt-4',
|
|
name: 'GPT-4',
|
|
generationConfig: { samplingParams: { temperature: 0.5 } },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
|
|
// Mock resolveModelConfig to simulate real behavior:
|
|
// modelProvider.id > cli.model > env > settings.model
|
|
vi.mocked(resolveModelConfig).mockImplementation((input) => {
|
|
const model =
|
|
input?.modelProvider?.id ||
|
|
input?.cli?.model ||
|
|
input?.env?.['OPENAI_MODEL'] ||
|
|
input?.settings?.model ||
|
|
'';
|
|
return {
|
|
config: { model, apiKey: '', baseUrl: '' },
|
|
sources: {
|
|
model:
|
|
model === 'custom-model'
|
|
? { kind: 'settings' as const, path: 'model.name' }
|
|
: { kind: 'env' as const, envKey: 'OPENAI_MODEL' },
|
|
},
|
|
warnings: [],
|
|
};
|
|
});
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env: { OPENAI_MODEL: 'gpt-4' },
|
|
});
|
|
|
|
// settings.model.name should be used (no provider found, so modelProvider is undefined)
|
|
expect(result.model).toBe('custom-model');
|
|
});
|
|
|
|
describe('[Regression] model precedence', () => {
|
|
it('[Regression] settings.model.name must NOT be overridden by OPENAI_MODEL', () => {
|
|
// This is the core bug: settings.model.name (set via /model)
|
|
// must take precedence over OPENAI_MODEL.
|
|
const settings = makeMockSettings({
|
|
model: { name: 'settings-model' },
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
const env = { OPENAI_MODEL: 'env-model', OPENAI_API_KEY: 'key' };
|
|
|
|
// Mock: settings.model.name should be used, not OPENAI_MODEL
|
|
vi.mocked(resolveModelConfig).mockImplementation(() => ({
|
|
config: { model: 'settings-model', apiKey: 'key', baseUrl: '' },
|
|
sources: {
|
|
model: { kind: 'settings' as const, path: 'model.name' },
|
|
},
|
|
warnings: [],
|
|
}));
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType,
|
|
env,
|
|
});
|
|
|
|
expect(result.model).toBe('settings-model');
|
|
// Verify OPENAI_MODEL was filtered from env passed to resolveModelConfig
|
|
const callArgs = vi.mocked(resolveModelConfig).mock.calls[0][0];
|
|
expect(callArgs.env?.['OPENAI_MODEL']).toBeUndefined();
|
|
});
|
|
|
|
it('[Regression] OPENAI_MODEL used when settings.model.name not set', () => {
|
|
const settings = makeMockSettings({ model: { name: undefined } });
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
const env = { OPENAI_MODEL: 'env-model', OPENAI_API_KEY: 'key' };
|
|
|
|
vi.mocked(resolveModelConfig).mockImplementation(() => ({
|
|
config: { model: 'env-model', apiKey: 'key', baseUrl: '' },
|
|
sources: {
|
|
model: { kind: 'env' as const, envKey: 'OPENAI_MODEL' },
|
|
},
|
|
warnings: [],
|
|
}));
|
|
|
|
resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType,
|
|
env,
|
|
});
|
|
|
|
// OPENAI_MODEL should NOT be filtered (it's the source of the model)
|
|
const callArgs = vi.mocked(resolveModelConfig).mock.calls[0][0];
|
|
expect(callArgs.env?.['OPENAI_MODEL']).toBe('env-model');
|
|
});
|
|
|
|
it('[Regression] argv.model overrides both settings and OPENAI_MODEL', () => {
|
|
const argv = { model: 'argv-model' };
|
|
const settings = makeMockSettings({
|
|
model: { name: 'settings-model' },
|
|
});
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
const env = { OPENAI_MODEL: 'env-model', OPENAI_API_KEY: 'key' };
|
|
|
|
vi.mocked(resolveModelConfig).mockImplementation(() => ({
|
|
config: { model: 'argv-model', apiKey: 'key', baseUrl: '' },
|
|
sources: { model: { kind: 'cli' as const, detail: '--model' } },
|
|
warnings: [],
|
|
}));
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env,
|
|
});
|
|
|
|
expect(result.model).toBe('argv-model');
|
|
// Both settings and env should be filtered when argv.model is set
|
|
const callArgs = vi.mocked(resolveModelConfig).mock.calls[0][0];
|
|
expect(callArgs.env?.['OPENAI_MODEL']).toBeUndefined();
|
|
});
|
|
|
|
it('[Regression] QWEN_MODEL as fallback when OPENAI_MODEL not set', () => {
|
|
const settings = makeMockSettings({ model: { name: undefined } });
|
|
const selectedAuthType = AuthType.USE_OPENAI;
|
|
const env = { QWEN_MODEL: 'qwen-model', OPENAI_API_KEY: 'key' };
|
|
|
|
vi.mocked(resolveModelConfig).mockImplementation(() => ({
|
|
config: { model: 'qwen-model', apiKey: 'key', baseUrl: '' },
|
|
sources: { model: { kind: 'env' as const, envKey: 'QWEN_MODEL' } },
|
|
warnings: [],
|
|
}));
|
|
|
|
resolveCliGenerationConfig({
|
|
argv: {},
|
|
settings,
|
|
selectedAuthType,
|
|
env,
|
|
});
|
|
|
|
// QWEN_MODEL should be passed to resolveModelConfig
|
|
const callArgs = vi.mocked(resolveModelConfig).mock.calls[0][0];
|
|
expect(callArgs.env?.['QWEN_MODEL']).toBe('qwen-model');
|
|
});
|
|
|
|
it('[Regression] Non-OpenAI auth ignores OPENAI_MODEL', () => {
|
|
const argv = {};
|
|
const settings = makeMockSettings();
|
|
const selectedAuthType = AuthType.USE_ANTHROPIC;
|
|
const env = {
|
|
OPENAI_MODEL: 'should-be-ignored',
|
|
ANTHROPIC_API_KEY: 'key',
|
|
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
|
|
};
|
|
|
|
vi.mocked(resolveModelConfig).mockImplementation(() => ({
|
|
config: {
|
|
model: 'claude-3',
|
|
apiKey: 'key',
|
|
baseUrl: 'https://api.anthropic.com',
|
|
},
|
|
sources: {
|
|
model: { kind: 'env' as const, envKey: 'ANTHROPIC_MODEL' },
|
|
},
|
|
warnings: [],
|
|
}));
|
|
|
|
const result = resolveCliGenerationConfig({
|
|
argv,
|
|
settings,
|
|
selectedAuthType,
|
|
env,
|
|
});
|
|
|
|
// For non-OpenAI auth, OPENAI_MODEL should not be in the model resolution
|
|
expect(result.model).toBe('claude-3');
|
|
const callArgs = vi.mocked(resolveModelConfig).mock.calls[0][0];
|
|
expect(callArgs.env?.['OPENAI_MODEL']).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
});
|