mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-06 08:59:15 +00:00
Compare commits
35 Commits
v0.6.0-nig
...
mingholy/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1498eaf35 | ||
|
|
c063694bd4 | ||
|
|
99610652be | ||
|
|
5b074ca698 | ||
|
|
49892a8e17 | ||
|
|
d1a3e828b7 | ||
|
|
b19bb6cb20 | ||
|
|
e8625658ba | ||
|
|
a4eb3adea8 | ||
|
|
7dc7c6380d | ||
|
|
d2d2b845c5 | ||
|
|
96080f84a6 | ||
|
|
2b6218e564 | ||
|
|
24edf32da8 | ||
|
|
51b08f700c | ||
|
|
58eac7f595 | ||
|
|
32e8b01cf0 | ||
|
|
db9d5cb45d | ||
|
|
473cb7b951 | ||
|
|
73848d3867 | ||
|
|
6a62167f79 | ||
|
|
6ff437671e | ||
|
|
30f9e9c782 | ||
|
|
e4caa7a856 | ||
|
|
0ae59b900c | ||
|
|
5a5dae1987 | ||
|
|
ac7ba95d65 | ||
|
|
4154493640 | ||
|
|
422998d7f0 | ||
|
|
68628bf952 | ||
|
|
e5efad89e0 | ||
|
|
e09bb5f5c0 | ||
|
|
24d11179d8 | ||
|
|
2ef8b6f350 | ||
|
|
5779f7ab1d |
@@ -1,5 +1,6 @@
|
||||
# Qwen Code overview
|
||||
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||
|
||||
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
|
||||
|
||||
@@ -1,41 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { vi } from 'vitest';
|
||||
import { validateAuthMethod } from './auth.js';
|
||||
import * as settings from './settings.js';
|
||||
|
||||
vi.mock('./settings.js', () => ({
|
||||
loadEnvironment: vi.fn(),
|
||||
loadSettings: vi.fn().mockReturnValue({
|
||||
merged: vi.fn().mockReturnValue({}),
|
||||
merged: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('validateAuthMethod', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// Reset mock to default
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {},
|
||||
} as ReturnType<typeof settings.loadSettings>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
delete process.env['CUSTOM_API_KEY'];
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['GEMINI_API_KEY_ALTERED'];
|
||||
delete process.env['ANTHROPIC_API_KEY'];
|
||||
delete process.env['ANTHROPIC_BASE_URL'];
|
||||
delete process.env['GOOGLE_API_KEY'];
|
||||
});
|
||||
|
||||
it('should return null for USE_OPENAI', () => {
|
||||
it('should return null for USE_OPENAI with default env key', () => {
|
||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
it('should return an error message for USE_OPENAI if no API key is available', () => {
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
|
||||
'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
|
||||
"Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for USE_OPENAI with custom envKey from modelProviders', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'custom-model' },
|
||||
modelProviders: {
|
||||
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['CUSTOM_API_KEY'] = 'custom-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error with custom envKey hint when modelProviders envKey is set but env var is missing', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'custom-model' },
|
||||
modelProviders: {
|
||||
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
|
||||
const result = validateAuthMethod(AuthType.USE_OPENAI);
|
||||
expect(result).toContain('CUSTOM_API_KEY');
|
||||
});
|
||||
|
||||
it('should return null for USE_GEMINI with custom envKey', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'gemini-1.5-flash' },
|
||||
modelProviders: {
|
||||
gemini: [
|
||||
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['GEMINI_API_KEY_ALTERED'] = 'altered-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error with custom envKey for USE_GEMINI when env var is missing', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'gemini-1.5-flash' },
|
||||
modelProviders: {
|
||||
gemini: [
|
||||
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
|
||||
const result = validateAuthMethod(AuthType.USE_GEMINI);
|
||||
expect(result).toContain('GEMINI_API_KEY_ALTERED');
|
||||
});
|
||||
|
||||
it('should return null for QWEN_OAUTH', () => {
|
||||
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
|
||||
});
|
||||
@@ -45,4 +116,55 @@ describe('validateAuthMethod', () => {
|
||||
'Invalid auth method selected.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for USE_ANTHROPIC with custom envKey and baseUrl', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'claude-3' },
|
||||
modelProviders: {
|
||||
anthropic: [
|
||||
{
|
||||
id: 'claude-3',
|
||||
envKey: 'CUSTOM_ANTHROPIC_KEY',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-anthropic-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_ANTHROPIC)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for USE_ANTHROPIC when baseUrl is missing', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'claude-3' },
|
||||
modelProviders: {
|
||||
anthropic: [{ id: 'claude-3', envKey: 'CUSTOM_ANTHROPIC_KEY' }],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-key';
|
||||
|
||||
const result = validateAuthMethod(AuthType.USE_ANTHROPIC);
|
||||
expect(result).toContain('ANTHROPIC_BASE_URL');
|
||||
});
|
||||
|
||||
it('should return null for USE_VERTEX_AI with custom envKey', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'vertex-model' },
|
||||
modelProviders: {
|
||||
'vertex-ai': [
|
||||
{ id: 'vertex-model', envKey: 'GOOGLE_API_KEY_VERTEX' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['GOOGLE_API_KEY_VERTEX'] = 'vertex-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { loadEnvironment, loadSettings } from './settings.js';
|
||||
import type {
|
||||
ModelProvidersConfig,
|
||||
ProviderModelConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { loadEnvironment, loadSettings, type Settings } from './settings.js';
|
||||
|
||||
/**
|
||||
* Default environment variable names for each auth type
|
||||
*/
|
||||
const DEFAULT_ENV_KEYS: Record<string, string> = {
|
||||
[AuthType.USE_OPENAI]: 'OPENAI_API_KEY',
|
||||
[AuthType.USE_ANTHROPIC]: 'ANTHROPIC_API_KEY',
|
||||
[AuthType.USE_GEMINI]: 'GEMINI_API_KEY',
|
||||
[AuthType.USE_VERTEX_AI]: 'GOOGLE_API_KEY',
|
||||
};
|
||||
|
||||
/**
|
||||
* Find model configuration from modelProviders by authType and modelId
|
||||
*/
|
||||
function findModelConfig(
|
||||
modelProviders: ModelProvidersConfig | undefined,
|
||||
authType: string,
|
||||
modelId: string | undefined,
|
||||
): ProviderModelConfig | undefined {
|
||||
if (!modelProviders || !modelId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const models = modelProviders[authType];
|
||||
if (!Array.isArray(models)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return models.find((m) => m.id === modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API key is available for the given auth type and model configuration.
|
||||
* Prioritizes custom envKey from modelProviders over default environment variables.
|
||||
*/
|
||||
function hasApiKeyForAuth(
|
||||
authType: string,
|
||||
settings: Settings,
|
||||
): { hasKey: boolean; checkedEnvKey: string | undefined } {
|
||||
const modelProviders = settings.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| undefined;
|
||||
const modelId = settings.model?.name;
|
||||
|
||||
// Try to find model-specific envKey from modelProviders
|
||||
const modelConfig = findModelConfig(modelProviders, authType, modelId);
|
||||
if (modelConfig?.envKey) {
|
||||
const hasKey = !!process.env[modelConfig.envKey];
|
||||
return { hasKey, checkedEnvKey: modelConfig.envKey };
|
||||
}
|
||||
|
||||
// Fallback to default environment variable
|
||||
const defaultEnvKey = DEFAULT_ENV_KEYS[authType];
|
||||
if (defaultEnvKey) {
|
||||
const hasKey = !!process.env[defaultEnvKey];
|
||||
return { hasKey, checkedEnvKey: defaultEnvKey };
|
||||
}
|
||||
|
||||
// Also check settings.security.auth.apiKey as fallback
|
||||
if (settings.security?.auth?.apiKey) {
|
||||
return { hasKey: true, checkedEnvKey: undefined };
|
||||
}
|
||||
|
||||
return { hasKey: false, checkedEnvKey: undefined };
|
||||
}
|
||||
|
||||
export function validateAuthMethod(authMethod: string): string | null {
|
||||
const settings = loadSettings();
|
||||
loadEnvironment(settings.merged);
|
||||
|
||||
if (authMethod === AuthType.USE_OPENAI) {
|
||||
const hasApiKey =
|
||||
process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
|
||||
if (!hasApiKey) {
|
||||
return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
|
||||
const { hasKey, checkedEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings.merged,
|
||||
);
|
||||
if (!hasKey) {
|
||||
const envKeyHint = checkedEnvKey
|
||||
? `'${checkedEnvKey}'`
|
||||
: "'OPENAI_API_KEY' (or configure modelProviders[].envKey)";
|
||||
return (
|
||||
'Missing API key for OpenAI-compatible auth. ' +
|
||||
`Set settings.security.auth.apiKey, or set the ${envKeyHint} environment variable.`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -27,31 +104,50 @@ export function validateAuthMethod(authMethod: string): string | null {
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_ANTHROPIC) {
|
||||
const hasApiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'ANTHROPIC_API_KEY environment variable not found.';
|
||||
const { hasKey, checkedEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings.merged,
|
||||
);
|
||||
if (!hasKey) {
|
||||
const envKeyHint = checkedEnvKey || 'ANTHROPIC_API_KEY';
|
||||
return `${envKeyHint} environment variable not found.`;
|
||||
}
|
||||
|
||||
const hasBaseUrl = process.env['ANTHROPIC_BASE_URL'];
|
||||
// Check baseUrl - can come from modelProviders or environment
|
||||
const modelProviders = settings.merged.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| undefined;
|
||||
const modelId = settings.merged.model?.name;
|
||||
const modelConfig = findModelConfig(modelProviders, authMethod, modelId);
|
||||
const hasBaseUrl =
|
||||
modelConfig?.baseUrl || process.env['ANTHROPIC_BASE_URL'];
|
||||
if (!hasBaseUrl) {
|
||||
return 'ANTHROPIC_BASE_URL environment variable not found.';
|
||||
return 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_GEMINI) {
|
||||
const hasApiKey = process.env['GEMINI_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||
const { hasKey, checkedEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings.merged,
|
||||
);
|
||||
if (!hasKey) {
|
||||
const envKeyHint = checkedEnvKey || 'GEMINI_API_KEY';
|
||||
return `${envKeyHint} environment variable not found. Please set it in your .env file or environment variables.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_VERTEX_AI) {
|
||||
const hasApiKey = process.env['GOOGLE_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||
const { hasKey, checkedEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings.merged,
|
||||
);
|
||||
if (!hasKey) {
|
||||
const envKeyHint = checkedEnvKey || 'GOOGLE_API_KEY';
|
||||
return `${envKeyHint} environment variable not found. Please set it in your .env file or environment variables.`;
|
||||
}
|
||||
|
||||
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
|
||||
|
||||
@@ -77,10 +77,8 @@ vi.mock('read-package-up', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actualServer = await vi.importActual<typeof ServerConfig>(
|
||||
'@qwen-code/qwen-code-core',
|
||||
);
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actualServer = await importOriginal<typeof ServerConfig>();
|
||||
return {
|
||||
...actualServer,
|
||||
IdeClient: {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import { resolveCliGenerationConfig } from '../utils/modelConfigUtils.js';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -881,26 +882,21 @@ export async function loadCliConfig(
|
||||
(argv.authType as AuthType | undefined) ||
|
||||
settings.security?.auth?.selectedType;
|
||||
|
||||
const apiKey =
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? argv.openaiApiKey ||
|
||||
process.env['OPENAI_API_KEY'] ||
|
||||
settings.security?.auth?.apiKey
|
||||
: '') || '';
|
||||
const baseUrl =
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? argv.openaiBaseUrl ||
|
||||
process.env['OPENAI_BASE_URL'] ||
|
||||
settings.security?.auth?.baseUrl
|
||||
: '') || '';
|
||||
const resolvedModel =
|
||||
argv.model ||
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? process.env['OPENAI_MODEL'] ||
|
||||
process.env['QWEN_MODEL'] ||
|
||||
settings.model?.name
|
||||
: '') ||
|
||||
'';
|
||||
// Unified resolution of generation config with source attribution
|
||||
const resolvedCliConfig = resolveCliGenerationConfig({
|
||||
argv: {
|
||||
model: argv.model,
|
||||
openaiApiKey: argv.openaiApiKey,
|
||||
openaiBaseUrl: argv.openaiBaseUrl,
|
||||
openaiLogging: argv.openaiLogging,
|
||||
openaiLoggingDir: argv.openaiLoggingDir,
|
||||
},
|
||||
settings,
|
||||
selectedAuthType,
|
||||
env: process.env as Record<string, string | undefined>,
|
||||
});
|
||||
|
||||
const { model: resolvedModel } = resolvedCliConfig;
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
const screenReader =
|
||||
@@ -934,6 +930,8 @@ export async function loadCliConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const modelProvidersConfig = settings.modelProviders;
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
sessionData,
|
||||
@@ -991,24 +989,11 @@ export async function loadCliConfig(
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
generationConfig: {
|
||||
...(settings.model?.generationConfig || {}),
|
||||
model: resolvedModel,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
enableOpenAILogging:
|
||||
(typeof argv.openaiLogging === 'undefined'
|
||||
? settings.model?.enableOpenAILogging
|
||||
: argv.openaiLogging) ?? false,
|
||||
openAILoggingDir:
|
||||
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
|
||||
},
|
||||
modelProvidersConfig,
|
||||
generationConfigSources: resolvedCliConfig.sources,
|
||||
generationConfig: resolvedCliConfig.generationConfig,
|
||||
cliVersion: await getCliVersion(),
|
||||
webSearch: buildWebSearchConfig(
|
||||
argv,
|
||||
settings,
|
||||
settings.security?.auth?.selectedType,
|
||||
),
|
||||
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
chatCompression: settings.model?.chatCompression,
|
||||
|
||||
87
packages/cli/src/config/modelProvidersScope.test.ts
Normal file
87
packages/cli/src/config/modelProvidersScope.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SettingScope } from './settings.js';
|
||||
import { getPersistScopeForModelSelection } from './modelProvidersScope.js';
|
||||
|
||||
function makeSettings({
|
||||
isTrusted,
|
||||
userModelProviders,
|
||||
workspaceModelProviders,
|
||||
}: {
|
||||
isTrusted: boolean;
|
||||
userModelProviders?: unknown;
|
||||
workspaceModelProviders?: unknown;
|
||||
}) {
|
||||
const userSettings: Record<string, unknown> = {};
|
||||
const workspaceSettings: Record<string, unknown> = {};
|
||||
|
||||
// When undefined, treat as "not present in this scope" (the key is omitted),
|
||||
// matching how LoadedSettings is shaped when a settings file doesn't define it.
|
||||
if (userModelProviders !== undefined) {
|
||||
userSettings['modelProviders'] = userModelProviders;
|
||||
}
|
||||
if (workspaceModelProviders !== undefined) {
|
||||
workspaceSettings['modelProviders'] = workspaceModelProviders;
|
||||
}
|
||||
|
||||
return {
|
||||
isTrusted,
|
||||
user: { settings: userSettings },
|
||||
workspace: { settings: workspaceSettings },
|
||||
} as unknown as import('./settings.js').LoadedSettings;
|
||||
}
|
||||
|
||||
describe('getPersistScopeForModelSelection', () => {
|
||||
it('prefers workspace when trusted and workspace defines modelProviders', () => {
|
||||
const settings = makeSettings({
|
||||
isTrusted: true,
|
||||
workspaceModelProviders: {},
|
||||
userModelProviders: { anything: true },
|
||||
});
|
||||
|
||||
expect(getPersistScopeForModelSelection(settings)).toBe(
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to user when workspace does not define modelProviders', () => {
|
||||
const settings = makeSettings({
|
||||
isTrusted: true,
|
||||
workspaceModelProviders: undefined,
|
||||
userModelProviders: {},
|
||||
});
|
||||
|
||||
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
|
||||
});
|
||||
|
||||
it('ignores workspace modelProviders when workspace is untrusted', () => {
|
||||
const settings = makeSettings({
|
||||
isTrusted: false,
|
||||
workspaceModelProviders: {},
|
||||
userModelProviders: undefined,
|
||||
});
|
||||
|
||||
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
|
||||
});
|
||||
|
||||
it('falls back to legacy trust heuristic when neither scope defines modelProviders', () => {
|
||||
const trusted = makeSettings({
|
||||
isTrusted: true,
|
||||
userModelProviders: undefined,
|
||||
workspaceModelProviders: undefined,
|
||||
});
|
||||
expect(getPersistScopeForModelSelection(trusted)).toBe(SettingScope.User);
|
||||
|
||||
const untrusted = makeSettings({
|
||||
isTrusted: false,
|
||||
userModelProviders: undefined,
|
||||
workspaceModelProviders: undefined,
|
||||
});
|
||||
expect(getPersistScopeForModelSelection(untrusted)).toBe(SettingScope.User);
|
||||
});
|
||||
});
|
||||
48
packages/cli/src/config/modelProvidersScope.ts
Normal file
48
packages/cli/src/config/modelProvidersScope.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SettingScope, type LoadedSettings } from './settings.js';
|
||||
|
||||
function hasOwnModelProviders(settingsObj: unknown): boolean {
|
||||
if (!settingsObj || typeof settingsObj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const obj = settingsObj as Record<string, unknown>;
|
||||
// Treat an explicitly configured empty object (modelProviders: {}) as "owned"
|
||||
// by this scope, which is important when mergeStrategy is REPLACE.
|
||||
return Object.prototype.hasOwnProperty.call(obj, 'modelProviders');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns which writable scope (Workspace/User) owns the effective modelProviders
|
||||
* configuration.
|
||||
*
|
||||
* Note: Workspace scope is only considered when the workspace is trusted.
|
||||
*/
|
||||
export function getModelProvidersOwnerScope(
|
||||
settings: LoadedSettings,
|
||||
): SettingScope | undefined {
|
||||
if (settings.isTrusted && hasOwnModelProviders(settings.workspace.settings)) {
|
||||
return SettingScope.Workspace;
|
||||
}
|
||||
|
||||
if (hasOwnModelProviders(settings.user.settings)) {
|
||||
return SettingScope.User;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the settings scope to persist a model selection.
|
||||
* Prefer persisting back to the scope that contains the effective modelProviders
|
||||
* config, otherwise fall back to the legacy trust-based heuristic.
|
||||
*/
|
||||
export function getPersistScopeForModelSelection(
|
||||
settings: LoadedSettings,
|
||||
): SettingScope {
|
||||
return getModelProvidersOwnerScope(settings) ?? SettingScope.User;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
TelemetrySettings,
|
||||
AuthType,
|
||||
ChatCompressionSettings,
|
||||
ModelProvidersConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
@@ -102,6 +103,19 @@ const SETTINGS_SCHEMA = {
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
},
|
||||
|
||||
// Model providers configuration grouped by authType
|
||||
modelProviders: {
|
||||
type: 'object',
|
||||
label: 'Model Providers',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: {} as ModelProvidersConfig,
|
||||
description:
|
||||
'Model providers configuration grouped by authType. Each authType contains an array of model configurations.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
},
|
||||
|
||||
general: {
|
||||
type: 'object',
|
||||
label: 'General',
|
||||
@@ -202,6 +216,7 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh', label: '中文 (Chinese)' },
|
||||
{ value: 'ru', label: 'Русский (Russian)' },
|
||||
{ value: 'de', label: 'Deutsch (German)' },
|
||||
],
|
||||
},
|
||||
terminalBell: {
|
||||
|
||||
@@ -45,7 +45,9 @@ export async function initializeApp(
|
||||
// Auto-detect and set LLM output language on first use
|
||||
initializeLlmOutputLanguage();
|
||||
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
// Use authType from modelsConfig which respects CLI --auth-type argument
|
||||
// over settings.security.auth.selectedType
|
||||
const authType = config.modelsConfig.getCurrentAuthType();
|
||||
const authError = await performInitialAuth(config, authType);
|
||||
|
||||
// Fallback to user select when initial authentication fails
|
||||
@@ -58,8 +60,13 @@ export async function initializeApp(
|
||||
}
|
||||
const themeError = validateTheme(settings);
|
||||
|
||||
// Open auth dialog if:
|
||||
// 1. No authType was explicitly selected (neither from CLI --auth-type nor settings), OR
|
||||
// 2. Authentication failed
|
||||
// wasAuthTypeExplicitlyProvided() returns true if CLI or settings specified authType,
|
||||
// false if using the default QWEN_OAUTH
|
||||
const shouldOpenAuthDialog =
|
||||
settings.merged.security?.auth?.selectedType === undefined || !!authError;
|
||||
!config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError;
|
||||
|
||||
if (config.getIdeMode()) {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
|
||||
@@ -87,6 +87,15 @@ vi.mock('./config/sandboxConfig.js', () => ({
|
||||
loadSandboxConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./core/initializer.js', () => ({
|
||||
initializeApp: vi.fn().mockResolvedValue({
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('gemini.tsx main function', () => {
|
||||
let originalEnvGeminiSandbox: string | undefined;
|
||||
let originalEnvSandbox: string | undefined;
|
||||
@@ -639,4 +648,37 @@ describe('startInteractiveUI', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not check for updates when update nag is disabled', async () => {
|
||||
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
|
||||
|
||||
const mockInitializationResult = {
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
};
|
||||
|
||||
const settingsWithUpdateNagDisabled = {
|
||||
merged: {
|
||||
general: {
|
||||
disableUpdateNag: true,
|
||||
},
|
||||
ui: {
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
await startInteractiveUI(
|
||||
mockConfig,
|
||||
settingsWithUpdateNagDisabled,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(checkForUpdates).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,16 +183,18 @@ export async function startInteractiveUI(
|
||||
},
|
||||
);
|
||||
|
||||
checkForUpdates()
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
console.error('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
if (!settings.merged.general?.disableUpdateNag) {
|
||||
checkForUpdates()
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
console.error('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
}
|
||||
|
||||
1101
packages/cli/src/i18n/locales/de.js
Normal file
1101
packages/cli/src/i18n/locales/de.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1037,7 +1037,6 @@ export default {
|
||||
'Applying percussive maintenance...',
|
||||
'Searching for the correct USB orientation...',
|
||||
'Ensuring the magic smoke stays inside the wires...',
|
||||
'Rewriting in Rust for no particular reason...',
|
||||
'Trying to exit Vim...',
|
||||
'Spinning up the hamster wheel...',
|
||||
"That's not a bug, it's an undocumented feature...",
|
||||
|
||||
@@ -1056,7 +1056,6 @@ export default {
|
||||
'Провожу настройку методом тыка...',
|
||||
'Ищем, какой стороной вставлять флешку...',
|
||||
'Следим, чтобы волшебный дым не вышел из проводов...',
|
||||
'Переписываем всё на Rust без особой причины...',
|
||||
'Пытаемся выйти из Vim...',
|
||||
'Раскручиваем колесо для хомяка...',
|
||||
'Это не баг, а фича...',
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
type Config,
|
||||
type IdeInfo,
|
||||
type IdeContext,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
IdeClient,
|
||||
ideContextStore,
|
||||
getErrorMessage,
|
||||
@@ -180,15 +179,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[],
|
||||
);
|
||||
|
||||
// Helper to determine the effective model, considering the fallback state.
|
||||
const getEffectiveModel = useCallback(() => {
|
||||
if (config.isInFallbackMode()) {
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
return config.getModel();
|
||||
}, [config]);
|
||||
// Helper to determine the current model (polled, since Config has no model-change event).
|
||||
const getCurrentModel = useCallback(() => config.getModel(), [config]);
|
||||
|
||||
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
|
||||
const [currentModel, setCurrentModel] = useState(getCurrentModel());
|
||||
|
||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||
|
||||
@@ -241,12 +235,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[historyManager.addItem],
|
||||
);
|
||||
|
||||
// Watch for model changes (e.g., from Flash fallback)
|
||||
// Watch for model changes (e.g., user switches model via /model)
|
||||
useEffect(() => {
|
||||
const checkModelChange = () => {
|
||||
const effectiveModel = getEffectiveModel();
|
||||
if (effectiveModel !== currentModel) {
|
||||
setCurrentModel(effectiveModel);
|
||||
const model = getCurrentModel();
|
||||
if (model !== currentModel) {
|
||||
setCurrentModel(model);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,7 +248,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const interval = setInterval(checkModelChange, 1000); // Check every second
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [config, currentModel, getEffectiveModel]);
|
||||
}, [config, currentModel, getCurrentModel]);
|
||||
|
||||
const {
|
||||
consoleMessages,
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthEvent,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
getErrorMessage,
|
||||
logAuth,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
@@ -109,7 +108,6 @@ export const useAuthCommand = (
|
||||
if (credentials?.model != null) {
|
||||
settings.setValue(scope, 'model.name', credentials.model);
|
||||
}
|
||||
await clearCachedCredentialFile();
|
||||
}
|
||||
} catch (error) {
|
||||
handleAuthFailure(error);
|
||||
|
||||
@@ -13,12 +13,6 @@ import {
|
||||
type ContentGeneratorConfig,
|
||||
type Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as availableModelsModule from '../models/availableModels.js';
|
||||
|
||||
// Mock the availableModels module
|
||||
vi.mock('../models/availableModels.js', () => ({
|
||||
getAvailableModelsForAuthType: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper function to create a mock config
|
||||
function createMockConfig(
|
||||
@@ -31,9 +25,6 @@ function createMockConfig(
|
||||
|
||||
describe('modelCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const mockGetAvailableModelsForAuthType = vi.mocked(
|
||||
availableModelsModule.getAvailableModelsForAuthType,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
@@ -87,10 +78,6 @@ describe('modelCommand', () => {
|
||||
});
|
||||
|
||||
it('should return dialog action for QWEN_OAUTH auth type', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([
|
||||
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
|
||||
]);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
@@ -105,11 +92,7 @@ describe('modelCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([
|
||||
{ id: 'gpt-4', label: 'gpt-4' },
|
||||
]);
|
||||
|
||||
it('should return dialog action for USE_OPENAI auth type', async () => {
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
@@ -124,28 +107,7 @@ describe('modelCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error for USE_OPENAI auth type when no model is available', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([]);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
});
|
||||
mockContext.services.config = mockConfig as Config;
|
||||
|
||||
const result = await modelCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'No models available for the current authentication type (openai).',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error for unsupported auth types', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([]);
|
||||
|
||||
it('should return dialog action for unsupported auth types', async () => {
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
|
||||
@@ -155,10 +117,8 @@ describe('modelCommand', () => {
|
||||
const result = await modelCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'No models available for the current authentication type (UNSUPPORTED_AUTH_TYPE).',
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
MessageActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const modelCommand: SlashCommand = {
|
||||
@@ -52,22 +51,6 @@ export const modelCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
const availableModels = getAvailableModelsForAuthType(authType);
|
||||
|
||||
if (availableModels.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'No models available for the current authentication type ({{authType}}).',
|
||||
{
|
||||
authType,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Trigger model selection dialog
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
|
||||
@@ -10,7 +10,11 @@ import { ModelDialog } from './ModelDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
AVAILABLE_MODELS_QWEN,
|
||||
MAINLINE_CODER,
|
||||
@@ -36,18 +40,29 @@ const renderComponent = (
|
||||
};
|
||||
const combinedProps = { ...defaultProps, ...props };
|
||||
|
||||
const mockSettings = {
|
||||
isTrusted: true,
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const mockConfig = contextValue
|
||||
? ({
|
||||
// --- Functions used by ModelDialog ---
|
||||
getModel: vi.fn(() => MAINLINE_CODER),
|
||||
setModel: vi.fn(),
|
||||
setModel: vi.fn().mockResolvedValue(undefined),
|
||||
switchModel: vi.fn().mockResolvedValue(undefined),
|
||||
getAuthType: vi.fn(() => 'qwen-oauth'),
|
||||
|
||||
// --- Functions used by ClearcutLogger ---
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
getSessionId: vi.fn(() => 'mock-session-id'),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
|
||||
getContentGeneratorConfig: vi.fn(() => ({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: MAINLINE_CODER,
|
||||
})),
|
||||
getUseSmartEdit: vi.fn(() => false),
|
||||
getUseModelRouter: vi.fn(() => false),
|
||||
getProxy: vi.fn(() => undefined),
|
||||
@@ -58,21 +73,27 @@ const renderComponent = (
|
||||
: undefined;
|
||||
|
||||
const renderResult = render(
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<ModelDialog {...combinedProps} />
|
||||
</ConfigContext.Provider>,
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<ModelDialog {...combinedProps} />
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
props: combinedProps,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
};
|
||||
};
|
||||
|
||||
describe('<ModelDialog />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Ensure env-based fallback models don't leak into this suite from the developer environment.
|
||||
delete process.env['OPENAI_MODEL'];
|
||||
delete process.env['ANTHROPIC_MODEL'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -91,8 +112,12 @@ describe('<ModelDialog />', () => {
|
||||
|
||||
const props = mockedSelect.mock.calls[0][0];
|
||||
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
|
||||
expect(props.items[0].value).toBe(MAINLINE_CODER);
|
||||
expect(props.items[1].value).toBe(MAINLINE_VLM);
|
||||
expect(props.items[0].value).toBe(
|
||||
`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`,
|
||||
);
|
||||
expect(props.items[1].value).toBe(
|
||||
`${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`,
|
||||
);
|
||||
expect(props.showNumbers).toBe(true);
|
||||
});
|
||||
|
||||
@@ -139,16 +164,93 @@ describe('<ModelDialog />', () => {
|
||||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
|
||||
const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => {
|
||||
const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
expect(childOnSelect).toBeDefined();
|
||||
|
||||
childOnSelect(MAINLINE_CODER);
|
||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
||||
|
||||
// Assert against the default mock provided by renderComponent
|
||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER);
|
||||
expect(mockConfig?.switchModel).toHaveBeenCalledWith(
|
||||
AuthType.QWEN_OAUTH,
|
||||
MAINLINE_CODER,
|
||||
undefined,
|
||||
{
|
||||
reason: 'user_manual',
|
||||
context: 'Model switched via /model dialog',
|
||||
},
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'model.name',
|
||||
MAINLINE_CODER,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.QWEN_OAUTH,
|
||||
);
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls config.switchModel and persists authType+model when selecting a different authType', async () => {
|
||||
const switchModel = vi.fn().mockResolvedValue(undefined);
|
||||
const getAuthType = vi.fn(() => AuthType.USE_OPENAI);
|
||||
const getAvailableModelsForAuthType = vi.fn((t: AuthType) => {
|
||||
if (t === AuthType.USE_OPENAI) {
|
||||
return [{ id: 'gpt-4', label: 'GPT-4', authType: t }];
|
||||
}
|
||||
if (t === AuthType.QWEN_OAUTH) {
|
||||
return AVAILABLE_MODELS_QWEN.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const mockConfigWithSwitchAuthType = {
|
||||
getAuthType,
|
||||
getModel: vi.fn(() => 'gpt-4'),
|
||||
getContentGeneratorConfig: vi.fn(() => ({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: MAINLINE_CODER,
|
||||
})),
|
||||
// Add switchModel to the mock object (not the type)
|
||||
switchModel,
|
||||
getAvailableModelsForAuthType,
|
||||
};
|
||||
|
||||
const { props, mockSettings } = renderComponent(
|
||||
{},
|
||||
// Cast to Config to bypass type checking, matching the runtime behavior
|
||||
mockConfigWithSwitchAuthType as unknown as Partial<Config>,
|
||||
);
|
||||
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
||||
|
||||
expect(switchModel).toHaveBeenCalledWith(
|
||||
AuthType.QWEN_OAUTH,
|
||||
MAINLINE_CODER,
|
||||
{ requireCachedCredentials: true },
|
||||
{
|
||||
reason: 'user_manual',
|
||||
context: 'AuthType+model switched via /model dialog',
|
||||
},
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'model.name',
|
||||
MAINLINE_CODER,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.QWEN_OAUTH,
|
||||
);
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -193,17 +295,25 @@ describe('<ModelDialog />', () => {
|
||||
it('updates initialIndex when config context changes', () => {
|
||||
const mockGetModel = vi.fn(() => MAINLINE_CODER);
|
||||
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
|
||||
const mockSettings = {
|
||||
isTrusted: true,
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
const { rerender } = render(
|
||||
<ConfigContext.Provider
|
||||
value={
|
||||
{
|
||||
getModel: mockGetModel,
|
||||
getAuthType: mockGetAuthType,
|
||||
} as unknown as Config
|
||||
}
|
||||
>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<ConfigContext.Provider
|
||||
value={
|
||||
{
|
||||
getModel: mockGetModel,
|
||||
getAuthType: mockGetAuthType,
|
||||
} as unknown as Config
|
||||
}
|
||||
>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
||||
@@ -215,9 +325,11 @@ describe('<ModelDialog />', () => {
|
||||
} as unknown as Config;
|
||||
|
||||
rerender(
|
||||
<ConfigContext.Provider value={newMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<ConfigContext.Provider value={newMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
// Should be called at least twice: initial render + re-render after context change
|
||||
|
||||
@@ -5,52 +5,210 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
AuthType,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
type ContentGeneratorConfig,
|
||||
type ContentGeneratorConfigSource,
|
||||
type ContentGeneratorConfigSources,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import {
|
||||
getAvailableModelsForAuthType,
|
||||
MAINLINE_CODER,
|
||||
} from '../models/availableModels.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface ModelDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatSourceBadge(
|
||||
source: ContentGeneratorConfigSource | undefined,
|
||||
): string | undefined {
|
||||
if (!source) return undefined;
|
||||
|
||||
switch (source.kind) {
|
||||
case 'cli':
|
||||
return source.detail ? `CLI ${source.detail}` : 'CLI';
|
||||
case 'env':
|
||||
return source.envKey ? `ENV ${source.envKey}` : 'ENV';
|
||||
case 'settings':
|
||||
return source.settingsPath
|
||||
? `Settings ${source.settingsPath}`
|
||||
: 'Settings';
|
||||
case 'modelProviders': {
|
||||
const suffix =
|
||||
source.authType && source.modelId
|
||||
? `${source.authType}:${source.modelId}`
|
||||
: source.authType
|
||||
? `${source.authType}`
|
||||
: source.modelId
|
||||
? `${source.modelId}`
|
||||
: '';
|
||||
return suffix ? `ModelProviders ${suffix}` : 'ModelProviders';
|
||||
}
|
||||
case 'default':
|
||||
return source.detail ? `Default ${source.detail}` : 'Default';
|
||||
case 'computed':
|
||||
return source.detail ? `Computed ${source.detail}` : 'Computed';
|
||||
case 'programmatic':
|
||||
return source.detail ? `Programmatic ${source.detail}` : 'Programmatic';
|
||||
case 'unknown':
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources {
|
||||
if (!config) {
|
||||
return {};
|
||||
}
|
||||
const maybe = config as {
|
||||
getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources;
|
||||
};
|
||||
return maybe.getContentGeneratorConfigSources?.() ?? {};
|
||||
}
|
||||
|
||||
function maskApiKey(apiKey: string | undefined): string {
|
||||
if (!apiKey) return '(not set)';
|
||||
const trimmed = apiKey.trim();
|
||||
if (trimmed.length === 0) return '(not set)';
|
||||
if (trimmed.length <= 6) return '***';
|
||||
const head = trimmed.slice(0, 3);
|
||||
const tail = trimmed.slice(-4);
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
|
||||
function persistModelSelection(
|
||||
settings: ReturnType<typeof useSettings>,
|
||||
modelId: string,
|
||||
): void {
|
||||
const scope = getPersistScopeForModelSelection(settings);
|
||||
settings.setValue(scope, 'model.name', modelId);
|
||||
}
|
||||
|
||||
function persistAuthTypeSelection(
|
||||
settings: ReturnType<typeof useSettings>,
|
||||
authType: AuthType,
|
||||
): void {
|
||||
const scope = getPersistScopeForModelSelection(settings);
|
||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||
}
|
||||
|
||||
function ConfigRow({
|
||||
label,
|
||||
value,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
badge?: string;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Box minWidth={12} flexShrink={0}>
|
||||
<Text color={theme.text.secondary}>{label}:</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="row" flexWrap="wrap">
|
||||
<Text>{value}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{badge ? (
|
||||
<Box>
|
||||
<Box minWidth={12} flexShrink={0}>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={theme.text.secondary}>{badge}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const config = useContext(ConfigContext);
|
||||
const uiState = useContext(UIStateContext);
|
||||
const settings = useSettings();
|
||||
|
||||
// Local error state for displaying errors within the dialog
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Get auth type from config, default to QWEN_OAUTH if not available
|
||||
const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
|
||||
const effectiveConfig =
|
||||
(config?.getContentGeneratorConfig?.() as
|
||||
| ContentGeneratorConfig
|
||||
| undefined) ?? undefined;
|
||||
const sources = readSourcesFromConfig(config);
|
||||
|
||||
// Get available models based on auth type
|
||||
const availableModels = useMemo(
|
||||
() => getAvailableModelsForAuthType(authType),
|
||||
[authType],
|
||||
);
|
||||
const availableModelEntries = useMemo(() => {
|
||||
const allAuthTypes = Object.values(AuthType) as AuthType[];
|
||||
const modelsByAuthType = allAuthTypes
|
||||
.map((t) => ({
|
||||
authType: t,
|
||||
models: getAvailableModelsForAuthType(t, config ?? undefined),
|
||||
}))
|
||||
.filter((x) => x.models.length > 0);
|
||||
|
||||
// Fixed order: qwen-oauth first, then others in a stable order
|
||||
const authTypeOrder: AuthType[] = [
|
||||
AuthType.QWEN_OAUTH,
|
||||
AuthType.USE_OPENAI,
|
||||
AuthType.USE_ANTHROPIC,
|
||||
AuthType.USE_GEMINI,
|
||||
AuthType.USE_VERTEX_AI,
|
||||
];
|
||||
|
||||
// Filter to only include authTypes that have models
|
||||
const availableAuthTypes = new Set(modelsByAuthType.map((x) => x.authType));
|
||||
const orderedAuthTypes = authTypeOrder.filter((t) =>
|
||||
availableAuthTypes.has(t),
|
||||
);
|
||||
|
||||
return orderedAuthTypes.flatMap((t) => {
|
||||
const models =
|
||||
modelsByAuthType.find((x) => x.authType === t)?.models ?? [];
|
||||
return models.map((m) => ({ authType: t, model: m }));
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
const MODEL_OPTIONS = useMemo(
|
||||
() =>
|
||||
availableModels.map((model) => ({
|
||||
value: model.id,
|
||||
title: model.label,
|
||||
description: model.description || '',
|
||||
key: model.id,
|
||||
})),
|
||||
[availableModels],
|
||||
availableModelEntries.map(({ authType: t2, model }) => {
|
||||
const value = `${t2}::${model.id}`;
|
||||
const title = (
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
[{t2}]
|
||||
</Text>
|
||||
<Text>{` ${model.label}`}</Text>
|
||||
</Text>
|
||||
);
|
||||
const description = model.description || '';
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
description,
|
||||
key: value,
|
||||
};
|
||||
}),
|
||||
[availableModelEntries],
|
||||
);
|
||||
|
||||
// Determine the Preferred Model (read once when the dialog opens).
|
||||
const preferredModel = config?.getModel() || MAINLINE_CODER;
|
||||
const preferredModelId = config?.getModel() || MAINLINE_CODER;
|
||||
const preferredKey = `${authType}::${preferredModelId}`;
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
@@ -61,25 +219,97 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Calculate the initial index based on the preferred model.
|
||||
const initialIndex = useMemo(
|
||||
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
|
||||
[MODEL_OPTIONS, preferredModel],
|
||||
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredKey),
|
||||
[MODEL_OPTIONS, preferredKey],
|
||||
);
|
||||
|
||||
// Handle selection internally (Autonomous Dialog).
|
||||
const handleSelect = useCallback(
|
||||
(model: string) => {
|
||||
async (selected: string) => {
|
||||
// Clear any previous error
|
||||
setErrorMessage(null);
|
||||
|
||||
const sep = '::';
|
||||
const idx = selected.indexOf(sep);
|
||||
const selectedAuthType = (
|
||||
idx >= 0 ? selected.slice(0, idx) : authType
|
||||
) as AuthType;
|
||||
const modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected;
|
||||
|
||||
if (config) {
|
||||
config.setModel(model);
|
||||
const event = new ModelSlashCommandEvent(model);
|
||||
try {
|
||||
await config.switchModel(
|
||||
selectedAuthType,
|
||||
modelId,
|
||||
selectedAuthType !== authType &&
|
||||
selectedAuthType === AuthType.QWEN_OAUTH
|
||||
? { requireCachedCredentials: true }
|
||||
: undefined,
|
||||
{
|
||||
reason: 'user_manual',
|
||||
context:
|
||||
selectedAuthType === authType
|
||||
? 'Model switched via /model dialog'
|
||||
: 'AuthType+model switched via /model dialog',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
const baseErrorMessage = e instanceof Error ? e.message : String(e);
|
||||
|
||||
// Some auth types (notably openai without modelProviders configured) can present
|
||||
// env-based "raw" model IDs in the list. These are not registry-backed and will
|
||||
// fail switchModel(). Fall back to setModel() to keep UX functional.
|
||||
const isNotFound =
|
||||
baseErrorMessage.includes('not found for authType') ||
|
||||
(baseErrorMessage.includes('Model') &&
|
||||
baseErrorMessage.includes('not found'));
|
||||
if (!isNotFound) {
|
||||
setErrorMessage(
|
||||
`Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`,
|
||||
);
|
||||
|
||||
// Keep the dialog open so the user can choose another model.
|
||||
return;
|
||||
}
|
||||
await config.setModel(modelId, {
|
||||
reason: 'user_manual',
|
||||
context: 'Model set via /model dialog (raw)',
|
||||
});
|
||||
}
|
||||
const event = new ModelSlashCommandEvent(modelId);
|
||||
logModelSlashCommand(config, event);
|
||||
|
||||
const after = config.getContentGeneratorConfig?.() as
|
||||
| ContentGeneratorConfig
|
||||
| undefined;
|
||||
const effectiveAuthType =
|
||||
after?.authType ?? selectedAuthType ?? authType;
|
||||
const effectiveModelId = after?.model ?? modelId;
|
||||
|
||||
persistModelSelection(settings, effectiveModelId);
|
||||
persistAuthTypeSelection(settings, effectiveAuthType);
|
||||
|
||||
const baseUrl = after?.baseUrl ?? '(default)';
|
||||
const maskedKey = maskApiKey(after?.apiKey);
|
||||
uiState?.historyManager.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text:
|
||||
`authType: ${effectiveAuthType}\n` +
|
||||
`Using model: ${effectiveModelId}\n` +
|
||||
`Base URL: ${baseUrl}\n` +
|
||||
`API key: ${maskedKey}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[config, onClose],
|
||||
[authType, config, onClose, settings, uiState, setErrorMessage],
|
||||
);
|
||||
|
||||
const hasModels = MODEL_OPTIONS.length > 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -89,14 +319,93 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>{t('Select Model')}</Text>
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={MODEL_OPTIONS}
|
||||
onSelect={handleSelect}
|
||||
initialIndex={initialIndex}
|
||||
showNumbers={true}
|
||||
/>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Current (effective) configuration')}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<ConfigRow label="AuthType" value={authType} />
|
||||
<ConfigRow
|
||||
label="Model"
|
||||
value={effectiveConfig?.model ?? config?.getModel?.() ?? ''}
|
||||
badge={formatSourceBadge(sources['model'])}
|
||||
/>
|
||||
|
||||
{authType !== AuthType.QWEN_OAUTH && (
|
||||
<>
|
||||
<ConfigRow
|
||||
label="Base URL"
|
||||
value={effectiveConfig?.baseUrl ?? ''}
|
||||
badge={formatSourceBadge(sources['baseUrl'])}
|
||||
/>
|
||||
<ConfigRow
|
||||
label="API Key"
|
||||
value={effectiveConfig?.apiKey ? t('(set)') : t('(not set)')}
|
||||
badge={formatSourceBadge(sources['apiKey'])}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{effectiveConfig?.samplingParams ? (
|
||||
<ConfigRow
|
||||
label="Sampling"
|
||||
value={
|
||||
`temperature=${effectiveConfig.samplingParams.temperature ?? t('unset')} ` +
|
||||
`top_p=${effectiveConfig.samplingParams.top_p ?? t('unset')} ` +
|
||||
`max_tokens=${effectiveConfig.samplingParams.max_tokens ?? t('unset')}`
|
||||
}
|
||||
badge={formatSourceBadge(sources['samplingParams'])}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{effectiveConfig?.timeout !== undefined ? (
|
||||
<ConfigRow
|
||||
label="Timeout"
|
||||
value={String(effectiveConfig.timeout)}
|
||||
badge={formatSourceBadge(sources['timeout'])}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{!hasModels ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.status.warning}>
|
||||
{t(
|
||||
'No models available for the current authentication type ({{authType}}).',
|
||||
{
|
||||
authType,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'Please configure models in settings.modelProviders or use environment variables.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={MODEL_OPTIONS}
|
||||
onSelect={handleSelect}
|
||||
initialIndex={initialIndex}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Box marginTop={1} flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.status.error} wrap="wrap">
|
||||
✕ {errorMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { BaseSelectionList } from './BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
|
||||
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
|
||||
title: string;
|
||||
title: React.ReactNode;
|
||||
description: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -912,7 +912,7 @@ export const useGeminiStream = (
|
||||
// Reset quota error flag when starting a new query (not a continuation)
|
||||
if (!options?.isContinuation) {
|
||||
setModelSwitchedFromQuotaError(false);
|
||||
config.setQuotaErrorOccurred(false);
|
||||
// No quota-error / fallback routing mechanism currently; keep state minimal.
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
@@ -62,7 +62,7 @@ const mockConfig = {
|
||||
getAllowedTools: vi.fn(() => []),
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
|
||||
205
packages/cli/src/ui/models/availableModels.test.ts
Normal file
205
packages/cli/src/ui/models/availableModels.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
getAvailableModelsForAuthType,
|
||||
getFilteredQwenModels,
|
||||
getOpenAIAvailableModelFromEnv,
|
||||
isVisionModel,
|
||||
getDefaultVisionModel,
|
||||
AVAILABLE_MODELS_QWEN,
|
||||
MAINLINE_VLM,
|
||||
MAINLINE_CODER,
|
||||
} from './availableModels.js';
|
||||
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
describe('availableModels', () => {
|
||||
describe('AVAILABLE_MODELS_QWEN', () => {
|
||||
it('should include coder model', () => {
|
||||
const coderModel = AVAILABLE_MODELS_QWEN.find(
|
||||
(m) => m.id === MAINLINE_CODER,
|
||||
);
|
||||
expect(coderModel).toBeDefined();
|
||||
expect(coderModel?.isVision).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should include vision model', () => {
|
||||
const visionModel = AVAILABLE_MODELS_QWEN.find(
|
||||
(m) => m.id === MAINLINE_VLM,
|
||||
);
|
||||
expect(visionModel).toBeDefined();
|
||||
expect(visionModel?.isVision).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilteredQwenModels', () => {
|
||||
it('should return all models when vision preview is enabled', () => {
|
||||
const models = getFilteredQwenModels(true);
|
||||
expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length);
|
||||
});
|
||||
|
||||
it('should filter out vision models when preview is disabled', () => {
|
||||
const models = getFilteredQwenModels(false);
|
||||
expect(models.every((m) => !m.isVision)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOpenAIAvailableModelFromEnv', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should return null when OPENAI_MODEL is not set', () => {
|
||||
delete process.env['OPENAI_MODEL'];
|
||||
expect(getOpenAIAvailableModelFromEnv()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return model from OPENAI_MODEL env var', () => {
|
||||
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
|
||||
const model = getOpenAIAvailableModelFromEnv();
|
||||
expect(model?.id).toBe('gpt-4-turbo');
|
||||
expect(model?.label).toBe('gpt-4-turbo');
|
||||
});
|
||||
|
||||
it('should trim whitespace from env var', () => {
|
||||
process.env['OPENAI_MODEL'] = ' gpt-4 ';
|
||||
const model = getOpenAIAvailableModelFromEnv();
|
||||
expect(model?.id).toBe('gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModelsForAuthType', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should return hard-coded qwen models for qwen-oauth', () => {
|
||||
const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH);
|
||||
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
|
||||
});
|
||||
|
||||
it('should return hard-coded qwen models even when config is provided', () => {
|
||||
const mockConfig = {
|
||||
getAvailableModels: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH },
|
||||
]),
|
||||
} as unknown as Config;
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.QWEN_OAUTH,
|
||||
mockConfig,
|
||||
);
|
||||
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
|
||||
});
|
||||
|
||||
it('should use config.getAvailableModels for openai authType when available', () => {
|
||||
const mockModels = [
|
||||
{
|
||||
id: 'gpt-4',
|
||||
label: 'GPT-4',
|
||||
description: 'Test',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
isVision: false,
|
||||
},
|
||||
];
|
||||
const getAvailableModelsForAuthType = vi.fn().mockReturnValue(mockModels);
|
||||
const mockConfigWithMethod = {
|
||||
// Prefer the newer API when available.
|
||||
getAvailableModelsForAuthType,
|
||||
};
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
mockConfigWithMethod as unknown as Config,
|
||||
);
|
||||
|
||||
expect(getAvailableModelsForAuthType).toHaveBeenCalled();
|
||||
expect(models[0].id).toBe('gpt-4');
|
||||
});
|
||||
|
||||
it('should fallback to env var for openai when config returns empty', () => {
|
||||
process.env['OPENAI_MODEL'] = 'fallback-model';
|
||||
const mockConfig = {
|
||||
getAvailableModelsForAuthType: vi.fn().mockReturnValue([]),
|
||||
} as unknown as Config;
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it('should fallback to env var for openai when config throws', () => {
|
||||
process.env['OPENAI_MODEL'] = 'fallback-model';
|
||||
const mockConfig = {
|
||||
getAvailableModelsForAuthType: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Registry not initialized');
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return env model for openai without config', () => {
|
||||
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
|
||||
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(models[0].id).toBe('gpt-4-turbo');
|
||||
});
|
||||
|
||||
it('should return empty array for openai without config or env', () => {
|
||||
delete process.env['OPENAI_MODEL'];
|
||||
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for other auth types', () => {
|
||||
const models = getAvailableModelsForAuthType(AuthType.USE_GEMINI);
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVisionModel', () => {
|
||||
it('should return true for vision model', () => {
|
||||
expect(isVisionModel(MAINLINE_VLM)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-vision model', () => {
|
||||
expect(isVisionModel(MAINLINE_CODER)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for unknown model', () => {
|
||||
expect(isVisionModel('unknown-model')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultVisionModel', () => {
|
||||
it('should return the vision model ID', () => {
|
||||
expect(getDefaultVisionModel()).toBe(MAINLINE_VLM);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
DEFAULT_QWEN_MODEL,
|
||||
type Config,
|
||||
type AvailableModel as CoreAvailableModel,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export type AvailableModel = {
|
||||
@@ -57,20 +62,78 @@ export function getFilteredQwenModels(
|
||||
*/
|
||||
export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
|
||||
const id = process.env['OPENAI_MODEL']?.trim();
|
||||
return id ? { id, label: id } : null;
|
||||
return id
|
||||
? {
|
||||
id,
|
||||
label: id,
|
||||
get description() {
|
||||
return t('Configured via OPENAI_MODEL environment variable');
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getAnthropicAvailableModelFromEnv(): AvailableModel | null {
|
||||
const id = process.env['ANTHROPIC_MODEL']?.trim();
|
||||
return id ? { id, label: id } : null;
|
||||
return id
|
||||
? {
|
||||
id,
|
||||
label: id,
|
||||
get description() {
|
||||
return t('Configured via ANTHROPIC_MODEL environment variable');
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert core AvailableModel to CLI AvailableModel format
|
||||
*/
|
||||
function convertCoreModelToCliModel(
|
||||
coreModel: CoreAvailableModel,
|
||||
): AvailableModel {
|
||||
return {
|
||||
id: coreModel.id,
|
||||
label: coreModel.label,
|
||||
description: coreModel.description,
|
||||
isVision: coreModel.isVision ?? coreModel.capabilities?.vision ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models for the given authType.
|
||||
*
|
||||
* If a Config object is provided, uses config.getAvailableModelsForAuthType().
|
||||
* For qwen-oauth, always returns the hard-coded models.
|
||||
* Falls back to environment variables only when no config is provided.
|
||||
*/
|
||||
export function getAvailableModelsForAuthType(
|
||||
authType: AuthType,
|
||||
config?: Config,
|
||||
): AvailableModel[] {
|
||||
// For qwen-oauth, always use hard-coded models, this aligns with the API gateway.
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
return AVAILABLE_MODELS_QWEN;
|
||||
}
|
||||
|
||||
// Use config's model registry when available
|
||||
if (config) {
|
||||
try {
|
||||
const models = config.getAvailableModelsForAuthType(authType);
|
||||
if (models.length > 0) {
|
||||
return models.map(convertCoreModelToCliModel);
|
||||
}
|
||||
} catch {
|
||||
// If config throws (e.g., not initialized), return empty array
|
||||
}
|
||||
// When a Config object is provided, we intentionally do NOT fall back to env-based
|
||||
// "raw" models. These may reflect the currently effective config but should not be
|
||||
// presented as selectable options in /model.
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fall back to environment variables for specific auth types (no config provided)
|
||||
switch (authType) {
|
||||
case AuthType.QWEN_OAUTH:
|
||||
return AVAILABLE_MODELS_QWEN;
|
||||
case AuthType.USE_OPENAI: {
|
||||
const openAIModel = getOpenAIAvailableModelFromEnv();
|
||||
return openAIModel ? [openAIModel] : [];
|
||||
@@ -80,13 +143,10 @@ export function getAvailableModelsForAuthType(
|
||||
return anthropicModel ? [anthropicModel] : [];
|
||||
}
|
||||
default:
|
||||
// For other auth types, return empty array for now
|
||||
// This can be expanded later according to the design doc
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Hard code the default vision model as a string literal,
|
||||
* until our coding model supports multimodal.
|
||||
|
||||
112
packages/cli/src/utils/modelConfigUtils.ts
Normal file
112
packages/cli/src/utils/modelConfigUtils.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
type ContentGeneratorConfig,
|
||||
type ContentGeneratorConfigSources,
|
||||
resolveModelConfig,
|
||||
type ModelConfigSourcesInput,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Settings } from '../config/settings.js';
|
||||
|
||||
export interface CliGenerationConfigInputs {
|
||||
argv: {
|
||||
model?: string | undefined;
|
||||
openaiApiKey?: string | undefined;
|
||||
openaiBaseUrl?: string | undefined;
|
||||
openaiLogging?: boolean | undefined;
|
||||
openaiLoggingDir?: string | undefined;
|
||||
};
|
||||
settings: Settings;
|
||||
selectedAuthType: AuthType | undefined;
|
||||
/**
|
||||
* Injectable env for testability. Defaults to process.env at callsites.
|
||||
*/
|
||||
env?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ResolvedCliGenerationConfig {
|
||||
/** The resolved model id (may be empty string if not resolvable at CLI layer) */
|
||||
model: string;
|
||||
/** API key for OpenAI-compatible auth */
|
||||
apiKey: string;
|
||||
/** Base URL for OpenAI-compatible auth */
|
||||
baseUrl: string;
|
||||
/** The full generation config to pass to core Config */
|
||||
generationConfig: Partial<ContentGeneratorConfig>;
|
||||
/** Source attribution for each resolved field */
|
||||
sources: ContentGeneratorConfigSources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified resolver for CLI generation config.
|
||||
*
|
||||
* Precedence (for OpenAI auth):
|
||||
* - model: argv.model > OPENAI_MODEL > QWEN_MODEL > settings.model.name
|
||||
* - apiKey: argv.openaiApiKey > OPENAI_API_KEY > settings.security.auth.apiKey
|
||||
* - baseUrl: argv.openaiBaseUrl > OPENAI_BASE_URL > settings.security.auth.baseUrl
|
||||
*
|
||||
* For non-OpenAI auth, only argv.model override is respected at CLI layer.
|
||||
*/
|
||||
export function resolveCliGenerationConfig(
|
||||
inputs: CliGenerationConfigInputs,
|
||||
): ResolvedCliGenerationConfig {
|
||||
const { argv, settings, selectedAuthType } = inputs;
|
||||
const env = inputs.env ?? (process.env as Record<string, string | undefined>);
|
||||
|
||||
const authType = selectedAuthType ?? AuthType.QWEN_OAUTH;
|
||||
|
||||
const configSources: ModelConfigSourcesInput = {
|
||||
authType,
|
||||
cli: {
|
||||
model: argv.model,
|
||||
apiKey: argv.openaiApiKey,
|
||||
baseUrl: argv.openaiBaseUrl,
|
||||
},
|
||||
settings: {
|
||||
model: settings.model?.name,
|
||||
apiKey: settings.security?.auth?.apiKey,
|
||||
baseUrl: settings.security?.auth?.baseUrl,
|
||||
generationConfig: settings.model?.generationConfig as
|
||||
| Partial<ContentGeneratorConfig>
|
||||
| undefined,
|
||||
},
|
||||
env,
|
||||
};
|
||||
|
||||
const resolved = resolveModelConfig(configSources);
|
||||
|
||||
// Log warnings if any
|
||||
for (const warning of resolved.warnings) {
|
||||
console.warn(`[modelProviderUtils] ${warning}`);
|
||||
}
|
||||
|
||||
// Resolve OpenAI logging config (CLI-specific, not part of core resolver)
|
||||
const enableOpenAILogging =
|
||||
(typeof argv.openaiLogging === 'undefined'
|
||||
? settings.model?.enableOpenAILogging
|
||||
: argv.openaiLogging) ?? false;
|
||||
|
||||
const openAILoggingDir =
|
||||
argv.openaiLoggingDir || settings.model?.openAILoggingDir;
|
||||
|
||||
// Build the full generation config
|
||||
// Note: we merge the resolved config with logging settings
|
||||
const generationConfig: Partial<ContentGeneratorConfig> = {
|
||||
...resolved.config,
|
||||
enableOpenAILogging,
|
||||
openAILoggingDir,
|
||||
};
|
||||
|
||||
return {
|
||||
model: resolved.config.model || '',
|
||||
apiKey: resolved.config.apiKey || '',
|
||||
baseUrl: resolved.config.baseUrl || '',
|
||||
generationConfig,
|
||||
sources: resolved.sources,
|
||||
};
|
||||
}
|
||||
@@ -8,12 +8,8 @@ export * from './src/index.js';
|
||||
export { Storage } from './src/config/storage.js';
|
||||
export {
|
||||
DEFAULT_QWEN_MODEL,
|
||||
DEFAULT_QWEN_FLASH_MODEL,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
} from './src/config/models.js';
|
||||
export {
|
||||
serializeTerminalToObject,
|
||||
|
||||
@@ -15,10 +15,16 @@ import {
|
||||
DEFAULT_OTLP_ENDPOINT,
|
||||
QwenLogger,
|
||||
} from '../telemetry/index.js';
|
||||
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
|
||||
import type {
|
||||
ContentGenerator,
|
||||
ContentGeneratorConfig,
|
||||
} from '../core/contentGenerator.js';
|
||||
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
|
||||
import {
|
||||
AuthType,
|
||||
createContentGenerator,
|
||||
createContentGeneratorConfig,
|
||||
resolveContentGeneratorConfigWithSources,
|
||||
} from '../core/contentGenerator.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
import { GitService } from '../services/gitService.js';
|
||||
@@ -208,6 +214,19 @@ describe('Server Config (config.ts)', () => {
|
||||
vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation(
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
// Setup default mock for resolveContentGeneratorConfigWithSources
|
||||
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
|
||||
(_config, authType, generationConfig) => ({
|
||||
config: {
|
||||
...generationConfig,
|
||||
authType,
|
||||
model: generationConfig?.model || MODEL,
|
||||
apiKey: 'test-key',
|
||||
} as ContentGeneratorConfig,
|
||||
sources: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
@@ -255,31 +274,28 @@ describe('Server Config (config.ts)', () => {
|
||||
const mockContentConfig = {
|
||||
apiKey: 'test-key',
|
||||
model: 'qwen3-coder-plus',
|
||||
authType,
|
||||
};
|
||||
|
||||
vi.mocked(createContentGeneratorConfig).mockReturnValue(
|
||||
mockContentConfig,
|
||||
);
|
||||
|
||||
// Set fallback mode to true to ensure it gets reset
|
||||
config.setFallbackMode(true);
|
||||
expect(config.isInFallbackMode()).toBe(true);
|
||||
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({
|
||||
config: mockContentConfig as ContentGeneratorConfig,
|
||||
sources: {},
|
||||
});
|
||||
|
||||
await config.refreshAuth(authType);
|
||||
|
||||
expect(createContentGeneratorConfig).toHaveBeenCalledWith(
|
||||
expect(resolveContentGeneratorConfigWithSources).toHaveBeenCalledWith(
|
||||
config,
|
||||
authType,
|
||||
{
|
||||
expect.objectContaining({
|
||||
model: MODEL,
|
||||
baseUrl: undefined,
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
// Verify that contentGeneratorConfig is updated
|
||||
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
|
||||
expect(GeminiClient).toHaveBeenCalledWith(config);
|
||||
// Verify that fallback mode is reset
|
||||
expect(config.isInFallbackMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not strip thoughts when switching from Vertex to GenAI', async () => {
|
||||
@@ -300,6 +316,129 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('model switching optimization (QWEN_OAUTH)', () => {
|
||||
it('should switch qwen-oauth model in-place without refreshing auth when safe', async () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
const mockContentConfig: ContentGeneratorConfig = {
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: 'coder-model',
|
||||
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
|
||||
baseUrl: DEFAULT_DASHSCOPE_BASE_URL,
|
||||
timeout: 60000,
|
||||
maxRetries: 3,
|
||||
} as ContentGeneratorConfig;
|
||||
|
||||
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
|
||||
(_config, authType, generationConfig) => ({
|
||||
config: {
|
||||
...mockContentConfig,
|
||||
authType,
|
||||
model: generationConfig?.model ?? mockContentConfig.model,
|
||||
} as ContentGeneratorConfig,
|
||||
sources: {},
|
||||
}),
|
||||
);
|
||||
vi.mocked(createContentGenerator).mockResolvedValue({
|
||||
generateContent: vi.fn(),
|
||||
generateContentStream: vi.fn(),
|
||||
countTokens: vi.fn(),
|
||||
embedContent: vi.fn(),
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
// Establish initial qwen-oauth content generator config/content generator.
|
||||
await config.refreshAuth(AuthType.QWEN_OAUTH);
|
||||
|
||||
// Spy after initial refresh to ensure model switch does not re-trigger refreshAuth.
|
||||
const refreshSpy = vi.spyOn(config, 'refreshAuth');
|
||||
|
||||
await config.switchModel(AuthType.QWEN_OAUTH, 'vision-model');
|
||||
|
||||
expect(config.getModel()).toBe('vision-model');
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
// Called once during initial refreshAuth + once during handleModelChange diffing.
|
||||
expect(
|
||||
vi.mocked(resolveContentGeneratorConfigWithSources),
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(createContentGenerator)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model switching with different credentials (OpenAI)', () => {
|
||||
it('should refresh auth when switching to model with different envKey', async () => {
|
||||
// This test verifies the fix for switching between modelProvider models
|
||||
// with different envKeys (e.g., deepseek-chat with DEEPSEEK_API_KEY)
|
||||
const configWithModelProviders = new Config({
|
||||
...baseParams,
|
||||
authType: AuthType.USE_OPENAI,
|
||||
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 mockContentConfigA: ContentGeneratorConfig = {
|
||||
authType: AuthType.USE_OPENAI,
|
||||
model: 'model-a',
|
||||
apiKey: 'key-a',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
} as ContentGeneratorConfig;
|
||||
|
||||
const mockContentConfigB: ContentGeneratorConfig = {
|
||||
authType: AuthType.USE_OPENAI,
|
||||
model: 'model-b',
|
||||
apiKey: 'key-b',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
} as ContentGeneratorConfig;
|
||||
|
||||
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
|
||||
(_config, _authType, generationConfig) => {
|
||||
const model = generationConfig?.model;
|
||||
return {
|
||||
config:
|
||||
model === 'model-b' ? mockContentConfigB : mockContentConfigA,
|
||||
sources: {},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(createContentGenerator).mockResolvedValue({
|
||||
generateContent: vi.fn(),
|
||||
generateContentStream: vi.fn(),
|
||||
countTokens: vi.fn(),
|
||||
embedContent: vi.fn(),
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
// Initialize with model-a
|
||||
await configWithModelProviders.refreshAuth(AuthType.USE_OPENAI);
|
||||
|
||||
// Spy on refreshAuth to verify it's called when switching to model-b
|
||||
const refreshSpy = vi.spyOn(configWithModelProviders, 'refreshAuth');
|
||||
|
||||
// Switch to model-b (different envKey)
|
||||
await configWithModelProviders.switchModel(
|
||||
AuthType.USE_OPENAI,
|
||||
'model-b',
|
||||
);
|
||||
|
||||
// Should trigger full refresh because envKey changed
|
||||
expect(refreshSpy).toHaveBeenCalledWith(AuthType.USE_OPENAI);
|
||||
expect(configWithModelProviders.getModel()).toBe('model-b');
|
||||
});
|
||||
});
|
||||
|
||||
it('Config constructor should store userMemory correctly', () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
|
||||
@@ -16,9 +16,8 @@ import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
import type {
|
||||
ContentGenerator,
|
||||
ContentGeneratorConfig,
|
||||
AuthType,
|
||||
} from '../core/contentGenerator.js';
|
||||
import type { FallbackModelHandler } from '../fallback/types.js';
|
||||
import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js';
|
||||
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||
import type { AnyToolInvocation } from '../tools/tools.js';
|
||||
@@ -27,8 +26,9 @@ import type { AnyToolInvocation } from '../tools/tools.js';
|
||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
import {
|
||||
AuthType,
|
||||
createContentGenerator,
|
||||
createContentGeneratorConfig,
|
||||
resolveContentGeneratorConfigWithSources,
|
||||
} from '../core/contentGenerator.js';
|
||||
import { tokenLimit } from '../core/tokenLimits.js';
|
||||
|
||||
@@ -94,7 +94,7 @@ import {
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
} from './constants.js';
|
||||
import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js';
|
||||
import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js';
|
||||
import { Storage } from './storage.js';
|
||||
import { ChatRecordingService } from '../services/chatRecordingService.js';
|
||||
import {
|
||||
@@ -103,6 +103,12 @@ import {
|
||||
} from '../services/sessionService.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
ModelsConfig,
|
||||
type ModelProvidersConfig,
|
||||
type AvailableModel,
|
||||
} from '../models/index.js';
|
||||
|
||||
// Re-export types
|
||||
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
|
||||
export {
|
||||
@@ -318,6 +324,11 @@ export interface ConfigParameters {
|
||||
ideMode?: boolean;
|
||||
authType?: AuthType;
|
||||
generationConfig?: Partial<ContentGeneratorConfig>;
|
||||
/**
|
||||
* Optional source map for generationConfig fields (e.g. CLI/env/settings attribution).
|
||||
* This is used to produce per-field source badges in the UI.
|
||||
*/
|
||||
generationConfigSources?: ContentGeneratorConfigSources;
|
||||
cliVersion?: string;
|
||||
loadMemoryFromIncludeDirectories?: boolean;
|
||||
chatRecording?: boolean;
|
||||
@@ -353,6 +364,8 @@ export interface ConfigParameters {
|
||||
sdkMode?: boolean;
|
||||
sessionSubagents?: SubagentConfig[];
|
||||
channel?: string;
|
||||
/** Model providers configuration grouped by authType */
|
||||
modelProvidersConfig?: ModelProvidersConfig;
|
||||
}
|
||||
|
||||
function normalizeConfigOutputFormat(
|
||||
@@ -394,9 +407,12 @@ export class Config {
|
||||
private skillManager!: SkillManager;
|
||||
private fileSystemService: FileSystemService;
|
||||
private contentGeneratorConfig!: ContentGeneratorConfig;
|
||||
private contentGeneratorConfigSources: ContentGeneratorConfigSources = {};
|
||||
private contentGenerator!: ContentGenerator;
|
||||
private _generationConfig: Partial<ContentGeneratorConfig>;
|
||||
private readonly embeddingModel: string;
|
||||
|
||||
private _modelsConfig!: ModelsConfig;
|
||||
private readonly modelProvidersConfig?: ModelProvidersConfig;
|
||||
private readonly sandbox: SandboxConfig | undefined;
|
||||
private readonly targetDir: string;
|
||||
private workspaceContext: WorkspaceContext;
|
||||
@@ -445,7 +461,6 @@ export class Config {
|
||||
private readonly folderTrust: boolean;
|
||||
private ideMode: boolean;
|
||||
|
||||
private inFallbackMode = false;
|
||||
private readonly maxSessionTurns: number;
|
||||
private readonly sessionTokenLimit: number;
|
||||
private readonly listExtensions: boolean;
|
||||
@@ -454,8 +469,6 @@ export class Config {
|
||||
name: string;
|
||||
extensionName: string;
|
||||
}>;
|
||||
fallbackModelHandler?: FallbackModelHandler;
|
||||
private quotaErrorOccurred: boolean = false;
|
||||
private readonly summarizeToolOutput:
|
||||
| Record<string, SummarizeToolOutputSettings>
|
||||
| undefined;
|
||||
@@ -570,13 +583,7 @@ export class Config {
|
||||
this.folderTrustFeature = params.folderTrustFeature ?? false;
|
||||
this.folderTrust = params.folderTrust ?? false;
|
||||
this.ideMode = params.ideMode ?? false;
|
||||
this._generationConfig = {
|
||||
model: params.model,
|
||||
...(params.generationConfig || {}),
|
||||
baseUrl: params.generationConfig?.baseUrl,
|
||||
};
|
||||
this.contentGeneratorConfig = this
|
||||
._generationConfig as ContentGeneratorConfig;
|
||||
this.modelProvidersConfig = params.modelProvidersConfig;
|
||||
this.cliVersion = params.cliVersion;
|
||||
|
||||
this.chatRecordingEnabled = params.chatRecording ?? true;
|
||||
@@ -619,6 +626,23 @@ export class Config {
|
||||
setGeminiMdFilename(params.contextFileName);
|
||||
}
|
||||
|
||||
// Create ModelsConfig for centralized model management
|
||||
// Prefer params.authType over generationConfig.authType because:
|
||||
// - params.authType preserves undefined (user hasn't selected yet)
|
||||
// - generationConfig.authType may have a default value from resolvers
|
||||
this._modelsConfig = new ModelsConfig({
|
||||
initialAuthType: params.authType ?? params.generationConfig?.authType,
|
||||
initialModelId: params.model,
|
||||
modelProvidersConfig: this.modelProvidersConfig,
|
||||
generationConfig: {
|
||||
model: params.model,
|
||||
...(params.generationConfig || {}),
|
||||
baseUrl: params.generationConfig?.baseUrl,
|
||||
},
|
||||
generationConfigSources: params.generationConfigSources,
|
||||
onModelChange: this.handleModelChange.bind(this),
|
||||
});
|
||||
|
||||
if (this.telemetrySettings.enabled) {
|
||||
initializeTelemetry(this);
|
||||
}
|
||||
@@ -669,45 +693,61 @@ export class Config {
|
||||
return this.contentGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ModelsConfig instance for model-related operations.
|
||||
* External code (e.g., CLI) can use this to access model configuration.
|
||||
*/
|
||||
get modelsConfig(): ModelsConfig {
|
||||
return this._modelsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the credentials in the generation config.
|
||||
* This is needed when credentials are set after Config construction.
|
||||
* Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth`
|
||||
* Delegates to ModelsConfig.
|
||||
*/
|
||||
updateCredentials(credentials: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}): void {
|
||||
if (credentials.apiKey) {
|
||||
this._generationConfig.apiKey = credentials.apiKey;
|
||||
}
|
||||
if (credentials.baseUrl) {
|
||||
this._generationConfig.baseUrl = credentials.baseUrl;
|
||||
}
|
||||
if (credentials.model) {
|
||||
this._generationConfig.model = credentials.model;
|
||||
}
|
||||
this._modelsConfig.updateCredentials(credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication and rebuild ContentGenerator.
|
||||
*/
|
||||
async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
|
||||
const newContentGeneratorConfig = createContentGeneratorConfig(
|
||||
// Sync modelsConfig state for this auth refresh
|
||||
const modelId = this._modelsConfig.getModel();
|
||||
this._modelsConfig.syncAfterAuthRefresh(authMethod, modelId);
|
||||
|
||||
// Check and consume cached credentials flag
|
||||
const requireCached =
|
||||
this._modelsConfig.consumeRequireCachedCredentialsFlag();
|
||||
|
||||
const { config, sources } = resolveContentGeneratorConfigWithSources(
|
||||
this,
|
||||
authMethod,
|
||||
this._generationConfig,
|
||||
this._modelsConfig.getGenerationConfig(),
|
||||
this._modelsConfig.getGenerationConfigSources(),
|
||||
{
|
||||
strictModelProvider:
|
||||
this._modelsConfig.isStrictModelProviderSelection(),
|
||||
},
|
||||
);
|
||||
const newContentGeneratorConfig = config;
|
||||
this.contentGenerator = await createContentGenerator(
|
||||
newContentGeneratorConfig,
|
||||
this,
|
||||
isInitialAuth,
|
||||
requireCached ? true : isInitialAuth,
|
||||
);
|
||||
// Only assign to instance properties after successful initialization
|
||||
this.contentGeneratorConfig = newContentGeneratorConfig;
|
||||
this.contentGeneratorConfigSources = sources;
|
||||
|
||||
// Initialize BaseLlmClient now that the ContentGenerator is available
|
||||
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
|
||||
|
||||
// Reset the session flag since we're explicitly changing auth and using default model
|
||||
this.inFallbackMode = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -767,31 +807,125 @@ export class Config {
|
||||
return this.contentGeneratorConfig;
|
||||
}
|
||||
|
||||
getModel(): string {
|
||||
return this.contentGeneratorConfig?.model || DEFAULT_QWEN_MODEL;
|
||||
getContentGeneratorConfigSources(): ContentGeneratorConfigSources {
|
||||
// If contentGeneratorConfigSources is empty (before initializeAuth),
|
||||
// get sources from ModelsConfig
|
||||
if (
|
||||
Object.keys(this.contentGeneratorConfigSources).length === 0 &&
|
||||
this._modelsConfig
|
||||
) {
|
||||
return this._modelsConfig.getGenerationConfigSources();
|
||||
}
|
||||
return this.contentGeneratorConfigSources;
|
||||
}
|
||||
|
||||
getModel(): string {
|
||||
return this.contentGeneratorConfig?.model || this._modelsConfig.getModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model programmatically (e.g., VLM auto-switch, fallback).
|
||||
* Delegates to ModelsConfig.
|
||||
*/
|
||||
async setModel(
|
||||
newModel: string,
|
||||
_metadata?: { reason?: string; context?: string },
|
||||
metadata?: { reason?: string; context?: string },
|
||||
): Promise<void> {
|
||||
await this._modelsConfig.setModel(newModel, metadata);
|
||||
// Also update contentGeneratorConfig for hot-update compatibility
|
||||
if (this.contentGeneratorConfig) {
|
||||
this.contentGeneratorConfig.model = newModel;
|
||||
}
|
||||
// TODO: Log _metadata for telemetry if needed
|
||||
// This _metadata can be used for tracking model switches (reason, context)
|
||||
}
|
||||
|
||||
isInFallbackMode(): boolean {
|
||||
return this.inFallbackMode;
|
||||
/**
|
||||
* Handle model change from ModelsConfig.
|
||||
* This updates the content generator config with the new model settings.
|
||||
*/
|
||||
private async handleModelChange(
|
||||
authType: AuthType,
|
||||
requiresRefresh: boolean,
|
||||
): Promise<void> {
|
||||
if (!this.contentGeneratorConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hot update path: only supported for qwen-oauth.
|
||||
// For other auth types we always refresh to recreate the ContentGenerator.
|
||||
//
|
||||
// Rationale:
|
||||
// - Non-qwen providers may need to re-validate credentials / baseUrl / envKey.
|
||||
// - ModelsConfig.applyResolvedModelDefaults can clear or change credentials sources.
|
||||
// - Refresh keeps runtime behavior consistent and centralized.
|
||||
if (authType === AuthType.QWEN_OAUTH && !requiresRefresh) {
|
||||
const { config, sources } = resolveContentGeneratorConfigWithSources(
|
||||
this,
|
||||
authType,
|
||||
this._modelsConfig.getGenerationConfig(),
|
||||
this._modelsConfig.getGenerationConfigSources(),
|
||||
{
|
||||
strictModelProvider:
|
||||
this._modelsConfig.isStrictModelProviderSelection(),
|
||||
},
|
||||
);
|
||||
|
||||
// Hot-update fields (qwen-oauth models share the same auth + client).
|
||||
this.contentGeneratorConfig.model = config.model;
|
||||
this.contentGeneratorConfig.samplingParams = config.samplingParams;
|
||||
this.contentGeneratorConfig.disableCacheControl =
|
||||
config.disableCacheControl;
|
||||
|
||||
if ('model' in sources) {
|
||||
this.contentGeneratorConfigSources['model'] = sources['model'];
|
||||
}
|
||||
if ('samplingParams' in sources) {
|
||||
this.contentGeneratorConfigSources['samplingParams'] =
|
||||
sources['samplingParams'];
|
||||
}
|
||||
if ('disableCacheControl' in sources) {
|
||||
this.contentGeneratorConfigSources['disableCacheControl'] =
|
||||
sources['disableCacheControl'];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Full refresh path
|
||||
await this.refreshAuth(authType);
|
||||
}
|
||||
|
||||
setFallbackMode(active: boolean): void {
|
||||
this.inFallbackMode = active;
|
||||
/**
|
||||
* Get available models for the current authType.
|
||||
* Delegates to ModelsConfig.
|
||||
*/
|
||||
getAvailableModels(): AvailableModel[] {
|
||||
return this._modelsConfig.getAvailableModels();
|
||||
}
|
||||
|
||||
setFallbackModelHandler(handler: FallbackModelHandler): void {
|
||||
this.fallbackModelHandler = handler;
|
||||
/**
|
||||
* Get available models for a specific authType.
|
||||
* Delegates to ModelsConfig.
|
||||
*/
|
||||
getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] {
|
||||
return this._modelsConfig.getAvailableModelsForAuthType(authType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch authType+model via registry-backed selection.
|
||||
* This triggers a refresh of the ContentGenerator when required (always on authType changes).
|
||||
* For qwen-oauth model switches that are hot-update safe, this may update in place.
|
||||
*
|
||||
* @param authType - Target authentication type
|
||||
* @param modelId - Target model ID
|
||||
* @param options - Additional options like requireCachedCredentials
|
||||
* @param metadata - Metadata for logging/tracking
|
||||
*/
|
||||
async switchModel(
|
||||
authType: AuthType,
|
||||
modelId: string,
|
||||
options?: { requireCachedCredentials?: boolean },
|
||||
metadata?: { reason?: string; context?: string },
|
||||
): Promise<void> {
|
||||
await this._modelsConfig.switchModel(authType, modelId, options, metadata);
|
||||
}
|
||||
|
||||
getMaxSessionTurns(): number {
|
||||
@@ -802,14 +936,6 @@ export class Config {
|
||||
return this.sessionTokenLimit;
|
||||
}
|
||||
|
||||
setQuotaErrorOccurred(value: boolean): void {
|
||||
this.quotaErrorOccurred = value;
|
||||
}
|
||||
|
||||
getQuotaErrorOccurred(): boolean {
|
||||
return this.quotaErrorOccurred;
|
||||
}
|
||||
|
||||
getEmbeddingModel(): string {
|
||||
return this.embeddingModel;
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Config } from './config.js';
|
||||
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
vi.mock('node:fs');
|
||||
|
||||
describe('Flash Model Fallback Configuration', () => {
|
||||
let config: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
config = new Config({
|
||||
targetDir: '/test',
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
});
|
||||
|
||||
// Initialize contentGeneratorConfig for testing
|
||||
(
|
||||
config as unknown as { contentGeneratorConfig: unknown }
|
||||
).contentGeneratorConfig = {
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
authType: 'gemini-api-key',
|
||||
};
|
||||
});
|
||||
|
||||
// These tests do not actually test fallback. isInFallbackMode() only returns true,
|
||||
// when setFallbackMode is marked as true. This is to decouple setting a model
|
||||
// with the fallback mechanism. This will be necessary we introduce more
|
||||
// intelligent model routing.
|
||||
describe('setModel', () => {
|
||||
it('should only mark as switched if contentGeneratorConfig exists', async () => {
|
||||
// Create config without initializing contentGeneratorConfig
|
||||
const newConfig = new Config({
|
||||
targetDir: '/test',
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
});
|
||||
|
||||
// Should not crash when contentGeneratorConfig is undefined
|
||||
await newConfig.setModel(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(newConfig.isInFallbackMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModel', () => {
|
||||
it('should return contentGeneratorConfig model if available', async () => {
|
||||
// Simulate initialized content generator config
|
||||
await config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should fall back to initial model if contentGeneratorConfig is not available', () => {
|
||||
// Test with fresh config where contentGeneratorConfig might not be set
|
||||
const newConfig = new Config({
|
||||
targetDir: '/test',
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: 'custom-model',
|
||||
});
|
||||
|
||||
expect(newConfig.getModel()).toBe('custom-model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInFallbackMode', () => {
|
||||
it('should start as false for new session', () => {
|
||||
expect(config.isInFallbackMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should remain false if no model switch occurs', () => {
|
||||
// Perform other operations that don't involve model switching
|
||||
expect(config.isInFallbackMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should persist switched state throughout session', async () => {
|
||||
await config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
// Setting state for fallback mode as is expected of clients
|
||||
config.setFallbackMode(true);
|
||||
expect(config.isInFallbackMode()).toBe(true);
|
||||
|
||||
// Should remain true even after getting model
|
||||
config.getModel();
|
||||
expect(config.isInFallbackMode()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getEffectiveModel,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
} from './models.js';
|
||||
|
||||
describe('getEffectiveModel', () => {
|
||||
describe('When NOT in fallback mode', () => {
|
||||
const isInFallbackMode = false;
|
||||
|
||||
it('should return the Pro model when Pro is requested', () => {
|
||||
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
|
||||
expect(model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Flash model when Flash is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Lite model when Lite is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should return a custom model name when requested', () => {
|
||||
const customModel = 'custom-model-v1';
|
||||
const model = getEffectiveModel(isInFallbackMode, customModel);
|
||||
expect(model).toBe(customModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When IN fallback mode', () => {
|
||||
const isInFallbackMode = true;
|
||||
|
||||
it('should downgrade the Pro model to the Flash model', () => {
|
||||
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Flash model when Flash is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should HONOR the Lite model when Lite is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should HONOR any model with "lite" in its name', () => {
|
||||
const customLiteModel = 'gemini-2.5-custom-lite-vNext';
|
||||
const model = getEffectiveModel(isInFallbackMode, customLiteModel);
|
||||
expect(model).toBe(customLiteModel);
|
||||
});
|
||||
|
||||
it('should downgrade any other custom model to the Flash model', () => {
|
||||
const customModel = 'custom-model-v1-unlisted';
|
||||
const model = getEffectiveModel(isInFallbackMode, customModel);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,46 +7,3 @@
|
||||
export const DEFAULT_QWEN_MODEL = 'coder-model';
|
||||
export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model';
|
||||
export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4';
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL = 'coder-model';
|
||||
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
|
||||
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL_AUTO = 'auto';
|
||||
|
||||
export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
|
||||
|
||||
// Some thinking models do not default to dynamic thinking which is done by a value of -1
|
||||
export const DEFAULT_THINKING_MODE = -1;
|
||||
|
||||
/**
|
||||
* Determines the effective model to use, applying fallback logic if necessary.
|
||||
*
|
||||
* When fallback mode is active, this function enforces the use of the standard
|
||||
* fallback model. However, it makes an exception for "lite" models (any model
|
||||
* with "lite" in its name), allowing them to be used to preserve cost savings.
|
||||
* This ensures that "pro" models are always downgraded, while "lite" model
|
||||
* requests are honored.
|
||||
*
|
||||
* @param isInFallbackMode Whether the application is in fallback mode.
|
||||
* @param requestedModel The model that was originally requested.
|
||||
* @returns The effective model name.
|
||||
*/
|
||||
export function getEffectiveModel(
|
||||
isInFallbackMode: boolean,
|
||||
requestedModel: string,
|
||||
): string {
|
||||
// If we are not in fallback mode, simply use the requested model.
|
||||
if (!isInFallbackMode) {
|
||||
return requestedModel;
|
||||
}
|
||||
|
||||
// If a "lite" model is requested, honor it. This allows for variations of
|
||||
// lite models without needing to list them all as constants.
|
||||
if (requestedModel.includes('lite')) {
|
||||
return requestedModel;
|
||||
}
|
||||
|
||||
// Default fallback for Gemini CLI.
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
type ChatCompressionInfo,
|
||||
} from './turn.js';
|
||||
import { getCoreSystemPrompt } from './prompts.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import { tokenLimit } from './tokenLimits.js';
|
||||
@@ -302,8 +302,6 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getFileService: vi.fn().mockReturnValue(fileService),
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(0),
|
||||
getSessionTokenLimit: vi.fn().mockReturnValue(32000),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
setQuotaErrorOccurred: vi.fn(),
|
||||
getNoBrowser: vi.fn().mockReturnValue(false),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
@@ -317,8 +315,6 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getModelRouterService: vi.fn().mockReturnValue({
|
||||
route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }),
|
||||
}),
|
||||
isInFallbackMode: vi.fn().mockReturnValue(false),
|
||||
setFallbackMode: vi.fn(),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
getChatCompression: vi.fn().mockReturnValue(undefined),
|
||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||
@@ -2262,12 +2258,12 @@ ${JSON.stringify(
|
||||
contents,
|
||||
generationConfig,
|
||||
abortSignal,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_QWEN_FLASH_MODEL,
|
||||
);
|
||||
|
||||
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
model: DEFAULT_QWEN_FLASH_MODEL,
|
||||
config: expect.objectContaining({
|
||||
abortSignal,
|
||||
systemInstruction: getCoreSystemPrompt(''),
|
||||
@@ -2290,7 +2286,7 @@ ${JSON.stringify(
|
||||
contents,
|
||||
{},
|
||||
new AbortController().signal,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_QWEN_FLASH_MODEL,
|
||||
);
|
||||
|
||||
expect(mockContentGenerator.generateContent).not.toHaveBeenCalledWith({
|
||||
@@ -2300,7 +2296,7 @@ ${JSON.stringify(
|
||||
});
|
||||
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
|
||||
{
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
model: DEFAULT_QWEN_FLASH_MODEL,
|
||||
config: expect.any(Object),
|
||||
contents,
|
||||
},
|
||||
@@ -2308,28 +2304,7 @@ ${JSON.stringify(
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the Flash model when fallback mode is active', async () => {
|
||||
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
|
||||
const generationConfig = { temperature: 0.5 };
|
||||
const abortSignal = new AbortController().signal;
|
||||
const requestedModel = 'gemini-2.5-pro'; // A non-flash model
|
||||
|
||||
// Mock config to be in fallback mode
|
||||
vi.spyOn(client['config'], 'isInFallbackMode').mockReturnValue(true);
|
||||
|
||||
await client.generateContent(
|
||||
contents,
|
||||
generationConfig,
|
||||
abortSignal,
|
||||
requestedModel,
|
||||
);
|
||||
|
||||
expect(mockGenerateContentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
}),
|
||||
'test-session-id',
|
||||
);
|
||||
});
|
||||
// Note: there is currently no "fallback mode" model routing; the model used
|
||||
// is always the one explicitly requested by the caller.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
|
||||
// Config
|
||||
import { ApprovalMode, type Config } from '../config/config.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
|
||||
// Core modules
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
@@ -542,11 +541,6 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
if (!turn.pendingToolCalls.length && signal && !signal.aborted) {
|
||||
// Check if next speaker check is needed
|
||||
if (this.config.getQuotaErrorOccurred()) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
if (this.config.getSkipNextSpeakerCheck()) {
|
||||
return turn;
|
||||
}
|
||||
@@ -602,14 +596,11 @@ export class GeminiClient {
|
||||
};
|
||||
|
||||
const apiCall = () => {
|
||||
const modelToUse = this.config.isInFallbackMode()
|
||||
? DEFAULT_GEMINI_FLASH_MODEL
|
||||
: model;
|
||||
currentAttemptModel = modelToUse;
|
||||
currentAttemptModel = model;
|
||||
|
||||
return this.getContentGeneratorOrFail().generateContent(
|
||||
{
|
||||
model: modelToUse,
|
||||
model,
|
||||
config: requestConfig,
|
||||
contents,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createContentGenerator, AuthType } from './contentGenerator.js';
|
||||
import {
|
||||
createContentGenerator,
|
||||
createContentGeneratorConfig,
|
||||
AuthType,
|
||||
} from './contentGenerator.js';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
|
||||
@@ -78,3 +82,32 @@ describe('createContentGenerator', () => {
|
||||
expect(generator).toBeInstanceOf(LoggingContentGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContentGeneratorConfig', () => {
|
||||
const mockConfig = {
|
||||
getProxy: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
it('should preserve provided fields and set authType for QWEN_OAUTH', () => {
|
||||
const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, {
|
||||
model: 'vision-model',
|
||||
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
|
||||
});
|
||||
expect(cfg.authType).toBe(AuthType.QWEN_OAUTH);
|
||||
expect(cfg.model).toBe('vision-model');
|
||||
expect(cfg.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
|
||||
});
|
||||
|
||||
it('should not warn or fallback for QWEN_OAUTH (resolution handled by ModelConfigResolver)', () => {
|
||||
const warnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, {
|
||||
model: 'some-random-model',
|
||||
});
|
||||
expect(cfg.model).toBe('some-random-model');
|
||||
expect(cfg.apiKey).toBeUndefined();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,24 @@ import type {
|
||||
GenerateContentParameters,
|
||||
GenerateContentResponse,
|
||||
} from '@google/genai';
|
||||
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
|
||||
import type {
|
||||
ConfigSource,
|
||||
ConfigSourceKind,
|
||||
ConfigSources,
|
||||
} from '../utils/configResolver.js';
|
||||
import {
|
||||
getDefaultApiKeyEnvVar,
|
||||
getDefaultModelEnvVar,
|
||||
MissingAnthropicBaseUrlEnvError,
|
||||
MissingApiKeyError,
|
||||
MissingBaseUrlError,
|
||||
MissingModelError,
|
||||
StrictMissingCredentialsError,
|
||||
StrictMissingModelIdError,
|
||||
} from '../models/modelConfigErrors.js';
|
||||
import { PROVIDER_SOURCED_FIELDS } from '../models/modelsConfig.js';
|
||||
|
||||
/**
|
||||
* Interface abstracting the core functionalities for generating content and counting tokens.
|
||||
@@ -48,6 +63,7 @@ export enum AuthType {
|
||||
export type ContentGeneratorConfig = {
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
apiKeyEnvKey?: string;
|
||||
baseUrl?: string;
|
||||
vertexai?: boolean;
|
||||
authType?: AuthType | undefined;
|
||||
@@ -77,102 +93,178 @@ export type ContentGeneratorConfig = {
|
||||
schemaCompliance?: 'auto' | 'openapi_30';
|
||||
};
|
||||
|
||||
export function createContentGeneratorConfig(
|
||||
// Keep the public ContentGeneratorConfigSources API, but reuse the generic
|
||||
// source-tracking types from utils/configResolver to avoid duplication.
|
||||
export type ContentGeneratorConfigSourceKind = ConfigSourceKind;
|
||||
export type ContentGeneratorConfigSource = ConfigSource;
|
||||
export type ContentGeneratorConfigSources = ConfigSources;
|
||||
|
||||
export type ResolvedContentGeneratorConfig = {
|
||||
config: ContentGeneratorConfig;
|
||||
sources: ContentGeneratorConfigSources;
|
||||
};
|
||||
|
||||
function setSource(
|
||||
sources: ContentGeneratorConfigSources,
|
||||
path: string,
|
||||
source: ContentGeneratorConfigSource,
|
||||
): void {
|
||||
sources[path] = source;
|
||||
}
|
||||
|
||||
function getSeedSource(
|
||||
seed: ContentGeneratorConfigSources | undefined,
|
||||
path: string,
|
||||
): ContentGeneratorConfigSource | undefined {
|
||||
return seed?.[path];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ContentGeneratorConfig while tracking the source of each effective field.
|
||||
*
|
||||
* This function now primarily validates and finalizes the configuration that has
|
||||
* already been resolved by ModelConfigResolver. The env fallback logic has been
|
||||
* moved to the unified resolver to eliminate duplication.
|
||||
*
|
||||
* Note: The generationConfig passed here should already be fully resolved with
|
||||
* proper source tracking from the caller (CLI/SDK layer).
|
||||
*/
|
||||
export function resolveContentGeneratorConfigWithSources(
|
||||
config: Config,
|
||||
authType: AuthType | undefined,
|
||||
generationConfig?: Partial<ContentGeneratorConfig>,
|
||||
): ContentGeneratorConfig {
|
||||
let newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
|
||||
seedSources?: ContentGeneratorConfigSources,
|
||||
options?: { strictModelProvider?: boolean },
|
||||
): ResolvedContentGeneratorConfig {
|
||||
const sources: ContentGeneratorConfigSources = { ...(seedSources || {}) };
|
||||
const strictModelProvider = options?.strictModelProvider === true;
|
||||
|
||||
// Build config with computed fields
|
||||
const newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
|
||||
...(generationConfig || {}),
|
||||
authType,
|
||||
proxy: config?.getProxy(),
|
||||
};
|
||||
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
// For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator
|
||||
// Set a special marker to indicate this is Qwen OAuth
|
||||
return {
|
||||
...newContentGeneratorConfig,
|
||||
model: DEFAULT_QWEN_MODEL,
|
||||
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
|
||||
} as ContentGeneratorConfig;
|
||||
// Set sources for computed fields
|
||||
setSource(sources, 'authType', {
|
||||
kind: 'computed',
|
||||
detail: 'provided by caller',
|
||||
});
|
||||
if (config?.getProxy()) {
|
||||
setSource(sources, 'proxy', {
|
||||
kind: 'computed',
|
||||
detail: 'Config.getProxy()',
|
||||
});
|
||||
}
|
||||
|
||||
if (authType === AuthType.USE_OPENAI) {
|
||||
newContentGeneratorConfig = {
|
||||
...newContentGeneratorConfig,
|
||||
apiKey: newContentGeneratorConfig.apiKey || process.env['OPENAI_API_KEY'],
|
||||
baseUrl:
|
||||
newContentGeneratorConfig.baseUrl || process.env['OPENAI_BASE_URL'],
|
||||
model: newContentGeneratorConfig.model || process.env['OPENAI_MODEL'],
|
||||
};
|
||||
// Preserve seed sources for fields that were passed in
|
||||
const seedOrUnknown = (path: string): ContentGeneratorConfigSource =>
|
||||
getSeedSource(seedSources, path) ?? { kind: 'unknown' };
|
||||
|
||||
if (!newContentGeneratorConfig.apiKey) {
|
||||
throw new Error('OPENAI_API_KEY environment variable not found.');
|
||||
}
|
||||
|
||||
return {
|
||||
...newContentGeneratorConfig,
|
||||
model: newContentGeneratorConfig?.model || 'qwen3-coder-plus',
|
||||
} as ContentGeneratorConfig;
|
||||
}
|
||||
|
||||
if (authType === AuthType.USE_ANTHROPIC) {
|
||||
newContentGeneratorConfig = {
|
||||
...newContentGeneratorConfig,
|
||||
apiKey:
|
||||
newContentGeneratorConfig.apiKey || process.env['ANTHROPIC_API_KEY'],
|
||||
baseUrl:
|
||||
newContentGeneratorConfig.baseUrl || process.env['ANTHROPIC_BASE_URL'],
|
||||
model: newContentGeneratorConfig.model || process.env['ANTHROPIC_MODEL'],
|
||||
};
|
||||
|
||||
if (!newContentGeneratorConfig.apiKey) {
|
||||
throw new Error('ANTHROPIC_API_KEY environment variable not found.');
|
||||
}
|
||||
|
||||
if (!newContentGeneratorConfig.baseUrl) {
|
||||
throw new Error('ANTHROPIC_BASE_URL environment variable not found.');
|
||||
}
|
||||
|
||||
if (!newContentGeneratorConfig.model) {
|
||||
throw new Error('ANTHROPIC_MODEL environment variable not found.');
|
||||
for (const field of PROVIDER_SOURCED_FIELDS) {
|
||||
if (generationConfig && field in generationConfig && !sources[field]) {
|
||||
setSource(sources, field, seedOrUnknown(field));
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === AuthType.USE_GEMINI) {
|
||||
newContentGeneratorConfig = {
|
||||
...newContentGeneratorConfig,
|
||||
apiKey: newContentGeneratorConfig.apiKey || process.env['GEMINI_API_KEY'],
|
||||
model: newContentGeneratorConfig.model || process.env['GEMINI_MODEL'],
|
||||
};
|
||||
// Validate required fields based on authType. This does not perform any
|
||||
// fallback resolution (resolution is handled by ModelConfigResolver).
|
||||
const validation = validateModelConfig(
|
||||
newContentGeneratorConfig as ContentGeneratorConfig,
|
||||
strictModelProvider,
|
||||
);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.errors.map((e) => e.message).join('\n'));
|
||||
}
|
||||
|
||||
if (!newContentGeneratorConfig.apiKey) {
|
||||
throw new Error('GEMINI_API_KEY environment variable not found.');
|
||||
}
|
||||
return {
|
||||
config: newContentGeneratorConfig as ContentGeneratorConfig,
|
||||
sources,
|
||||
};
|
||||
}
|
||||
|
||||
if (!newContentGeneratorConfig.model) {
|
||||
throw new Error('GEMINI_MODEL environment variable not found.');
|
||||
export interface ModelConfigValidationResult {
|
||||
valid: boolean;
|
||||
errors: Error[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a resolved model configuration.
|
||||
* This is the single validation entry point used across Core.
|
||||
*/
|
||||
export function validateModelConfig(
|
||||
config: ContentGeneratorConfig,
|
||||
isStrictModelProvider: boolean = false,
|
||||
): ModelConfigValidationResult {
|
||||
const errors: Error[] = [];
|
||||
|
||||
// Qwen OAuth doesn't need validation - it uses dynamic tokens
|
||||
if (config.authType === AuthType.QWEN_OAUTH) {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
|
||||
// API key is required for all other auth types
|
||||
if (!config.apiKey) {
|
||||
if (isStrictModelProvider) {
|
||||
errors.push(
|
||||
new StrictMissingCredentialsError(
|
||||
config.authType,
|
||||
config.model,
|
||||
config.apiKeyEnvKey,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const envKey =
|
||||
config.apiKeyEnvKey || getDefaultApiKeyEnvVar(config.authType);
|
||||
errors.push(
|
||||
new MissingApiKeyError({
|
||||
authType: config.authType,
|
||||
model: config.model,
|
||||
baseUrl: config.baseUrl,
|
||||
envKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === AuthType.USE_VERTEX_AI) {
|
||||
newContentGeneratorConfig = {
|
||||
...newContentGeneratorConfig,
|
||||
apiKey: newContentGeneratorConfig.apiKey || process.env['GOOGLE_API_KEY'],
|
||||
model: newContentGeneratorConfig.model || process.env['GOOGLE_MODEL'],
|
||||
};
|
||||
|
||||
if (!newContentGeneratorConfig.apiKey) {
|
||||
throw new Error('GOOGLE_API_KEY environment variable not found.');
|
||||
}
|
||||
|
||||
if (!newContentGeneratorConfig.model) {
|
||||
throw new Error('GOOGLE_MODEL environment variable not found.');
|
||||
// Model is required
|
||||
if (!config.model) {
|
||||
if (isStrictModelProvider) {
|
||||
errors.push(new StrictMissingModelIdError(config.authType));
|
||||
} else {
|
||||
const envKey = getDefaultModelEnvVar(config.authType);
|
||||
errors.push(new MissingModelError({ authType: config.authType, envKey }));
|
||||
}
|
||||
}
|
||||
|
||||
return newContentGeneratorConfig as ContentGeneratorConfig;
|
||||
// Explicit baseUrl is required for Anthropic; Migrated from existing code.
|
||||
if (config.authType === AuthType.USE_ANTHROPIC && !config.baseUrl) {
|
||||
if (isStrictModelProvider) {
|
||||
errors.push(
|
||||
new MissingBaseUrlError({
|
||||
authType: config.authType,
|
||||
model: config.model,
|
||||
}),
|
||||
);
|
||||
} else if (config.authType === AuthType.USE_ANTHROPIC) {
|
||||
errors.push(new MissingAnthropicBaseUrlEnvError());
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export function createContentGeneratorConfig(
|
||||
config: Config,
|
||||
authType: AuthType | undefined,
|
||||
generationConfig?: Partial<ContentGeneratorConfig>,
|
||||
): ContentGeneratorConfig {
|
||||
return resolveContentGeneratorConfigWithSources(
|
||||
config,
|
||||
authType,
|
||||
generationConfig,
|
||||
).config;
|
||||
}
|
||||
|
||||
export async function createContentGenerator(
|
||||
@@ -180,11 +272,12 @@ export async function createContentGenerator(
|
||||
gcConfig: Config,
|
||||
isInitialAuth?: boolean,
|
||||
): Promise<ContentGenerator> {
|
||||
if (config.authType === AuthType.USE_OPENAI) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error('OPENAI_API_KEY environment variable not found.');
|
||||
}
|
||||
const validation = validateModelConfig(config, false);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.errors.map((e) => e.message).join('\n'));
|
||||
}
|
||||
|
||||
if (config.authType === AuthType.USE_OPENAI) {
|
||||
// Import OpenAIContentGenerator dynamically to avoid circular dependencies
|
||||
const { createOpenAIContentGenerator } = await import(
|
||||
'./openaiContentGenerator/index.js'
|
||||
@@ -223,10 +316,6 @@ export async function createContentGenerator(
|
||||
}
|
||||
|
||||
if (config.authType === AuthType.USE_ANTHROPIC) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error('ANTHROPIC_API_KEY environment variable not found.');
|
||||
}
|
||||
|
||||
const { createAnthropicContentGenerator } = await import(
|
||||
'./anthropicContentGenerator/index.js'
|
||||
);
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('CoreToolScheduler', () => {
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
@@ -318,7 +318,7 @@ describe('CoreToolScheduler', () => {
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
@@ -497,7 +497,7 @@ describe('CoreToolScheduler', () => {
|
||||
getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
@@ -584,7 +584,7 @@ describe('CoreToolScheduler', () => {
|
||||
getExcludeTools: () => ['write_file', 'edit'], // Different excluded tools
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
@@ -674,7 +674,7 @@ describe('CoreToolScheduler with payload', () => {
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
@@ -1001,7 +1001,7 @@ describe('CoreToolScheduler edit cancellation', () => {
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
@@ -1108,7 +1108,7 @@ describe('CoreToolScheduler YOLO mode', () => {
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
@@ -1258,7 +1258,7 @@ describe('CoreToolScheduler cancellation during executing with live output', ()
|
||||
getApprovalMode: () => ApprovalMode.DEFAULT,
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
getShellExecutionConfig: () => ({
|
||||
@@ -1350,7 +1350,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
@@ -1482,7 +1482,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
getToolRegistry: () => toolRegistry,
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 80,
|
||||
@@ -1586,7 +1586,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
@@ -1854,7 +1854,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
@@ -1975,7 +1975,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from './geminiChat.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { AuthType } from './contentGenerator.js';
|
||||
import { type RetryOptions } from '../utils/retry.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
@@ -112,15 +111,11 @@ describe('GeminiChat', () => {
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'gemini-api-key', // Ensure this is set for fallback tests
|
||||
authType: 'gemini', // Ensure this is set for fallback tests
|
||||
model: 'test-model',
|
||||
}),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
setModel: vi.fn(),
|
||||
isInFallbackMode: vi.fn().mockReturnValue(false),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
setQuotaErrorOccurred: vi.fn(),
|
||||
flashFallbackHandler: undefined,
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
storage: {
|
||||
@@ -1349,9 +1344,8 @@ describe('GeminiChat', () => {
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
|
||||
it('should use the FLASH model when in fallback mode (sendMessageStream)', async () => {
|
||||
it('should pass the requested model through to generateContentStream', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-pro');
|
||||
vi.mocked(mockConfig.isInFallbackMode).mockReturnValue(true);
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
|
||||
async () =>
|
||||
(async function* () {
|
||||
@@ -1370,7 +1364,7 @@ describe('GeminiChat', () => {
|
||||
|
||||
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
model: 'test-model',
|
||||
}),
|
||||
'prompt-id-res3',
|
||||
);
|
||||
@@ -1422,9 +1416,6 @@ describe('GeminiChat', () => {
|
||||
authType,
|
||||
});
|
||||
|
||||
const isInFallbackModeSpy = vi.spyOn(mockConfig, 'isInFallbackMode');
|
||||
isInFallbackModeSpy.mockReturnValue(false);
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
.mockRejectedValueOnce(error429) // Attempt 1 fails
|
||||
.mockResolvedValueOnce(
|
||||
@@ -1441,10 +1432,7 @@ describe('GeminiChat', () => {
|
||||
})(),
|
||||
);
|
||||
|
||||
mockHandleFallback.mockImplementation(async () => {
|
||||
isInFallbackModeSpy.mockReturnValue(true);
|
||||
return true; // Signal retry
|
||||
});
|
||||
mockHandleFallback.mockImplementation(async () => true);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
|
||||
@@ -19,10 +19,6 @@ import type {
|
||||
import { ApiError, createUserContent } from '@google/genai';
|
||||
import { retryWithBackoff } from '../utils/retry.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
getEffectiveModel,
|
||||
} from '../config/models.js';
|
||||
import { hasCycleInSchema } from '../tools/tools.js';
|
||||
import type { StructuredError } from './turn.js';
|
||||
import {
|
||||
@@ -352,31 +348,15 @@ export class GeminiChat {
|
||||
params: SendMessageParameters,
|
||||
prompt_id: string,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
const apiCall = () => {
|
||||
const modelToUse = getEffectiveModel(
|
||||
this.config.isInFallbackMode(),
|
||||
model,
|
||||
);
|
||||
|
||||
if (
|
||||
this.config.getQuotaErrorOccurred() &&
|
||||
modelToUse === DEFAULT_GEMINI_FLASH_MODEL
|
||||
) {
|
||||
throw new Error(
|
||||
'Please submit a new query to continue with the Flash model.',
|
||||
);
|
||||
}
|
||||
|
||||
return this.config.getContentGenerator().generateContentStream(
|
||||
const apiCall = () =>
|
||||
this.config.getContentGenerator().generateContentStream(
|
||||
{
|
||||
model: modelToUse,
|
||||
model,
|
||||
contents: requestContents,
|
||||
config: { ...this.generationConfig, ...params.config },
|
||||
},
|
||||
prompt_id,
|
||||
);
|
||||
};
|
||||
|
||||
const onPersistent429Callback = async (
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('executeToolCall', () => {
|
||||
getDebugMode: () => false,
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
|
||||
@@ -93,6 +93,14 @@ export class OpenAIContentConverter {
|
||||
this.schemaCompliance = schemaCompliance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the model used for response metadata (modelVersion/logging) and any
|
||||
* model-specific conversion behavior.
|
||||
*/
|
||||
setModel(model: string): void {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset streaming tool calls parser for new stream processing
|
||||
* This should be called at the beginning of each stream to prevent
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('ContentGenerationPipeline', () => {
|
||||
|
||||
// Mock converter
|
||||
mockConverter = {
|
||||
setModel: vi.fn(),
|
||||
convertGeminiRequestToOpenAI: vi.fn(),
|
||||
convertOpenAIResponseToGemini: vi.fn(),
|
||||
convertOpenAIChunkToGemini: vi.fn(),
|
||||
@@ -99,6 +100,7 @@ describe('ContentGenerationPipeline', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize with correct configuration', () => {
|
||||
expect(mockProvider.buildClient).toHaveBeenCalled();
|
||||
// Converter is constructed once and the model is updated per-request via setModel().
|
||||
expect(OpenAIContentConverter).toHaveBeenCalledWith(
|
||||
'test-model',
|
||||
undefined,
|
||||
@@ -144,6 +146,9 @@ describe('ContentGenerationPipeline', () => {
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(mockGeminiResponse);
|
||||
expect(
|
||||
(mockConverter as unknown as { setModel: Mock }).setModel,
|
||||
).toHaveBeenCalledWith('test-model');
|
||||
expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith(
|
||||
request,
|
||||
);
|
||||
@@ -164,6 +169,53 @@ describe('ContentGenerationPipeline', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore request.model override and always use configured model', async () => {
|
||||
// Arrange
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'override-model',
|
||||
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
|
||||
};
|
||||
const userPromptId = 'test-prompt-id';
|
||||
|
||||
const mockMessages = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
] as OpenAI.Chat.ChatCompletionMessageParam[];
|
||||
const mockOpenAIResponse = {
|
||||
id: 'response-id',
|
||||
choices: [
|
||||
{ message: { content: 'Hello response' }, finish_reason: 'stop' },
|
||||
],
|
||||
created: Date.now(),
|
||||
model: 'override-model',
|
||||
} as OpenAI.Chat.ChatCompletion;
|
||||
const mockGeminiResponse = new GenerateContentResponse();
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue(
|
||||
mockMessages,
|
||||
);
|
||||
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
|
||||
mockGeminiResponse,
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue(
|
||||
mockOpenAIResponse,
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await pipeline.execute(request, userPromptId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(mockGeminiResponse);
|
||||
expect(
|
||||
(mockConverter as unknown as { setModel: Mock }).setModel,
|
||||
).toHaveBeenCalledWith('test-model');
|
||||
expect(mockClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'test-model',
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tools in request', async () => {
|
||||
// Arrange
|
||||
const request: GenerateContentParameters = {
|
||||
@@ -217,6 +269,9 @@ describe('ContentGenerationPipeline', () => {
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(mockGeminiResponse);
|
||||
expect(
|
||||
(mockConverter as unknown as { setModel: Mock }).setModel,
|
||||
).toHaveBeenCalledWith('test-model');
|
||||
expect(mockConverter.convertGeminiToolsToOpenAI).toHaveBeenCalledWith(
|
||||
request.config!.tools,
|
||||
);
|
||||
|
||||
@@ -40,10 +40,16 @@ export class ContentGenerationPipeline {
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
): Promise<GenerateContentResponse> {
|
||||
// For OpenAI-compatible providers, the configured model is the single source of truth.
|
||||
// We intentionally ignore request.model because upstream callers may pass a model string
|
||||
// that is not valid/available for the OpenAI-compatible backend.
|
||||
const effectiveModel = this.contentGeneratorConfig.model;
|
||||
this.converter.setModel(effectiveModel);
|
||||
return this.executeWithErrorHandling(
|
||||
request,
|
||||
userPromptId,
|
||||
false,
|
||||
effectiveModel,
|
||||
async (openaiRequest) => {
|
||||
const openaiResponse = (await this.client.chat.completions.create(
|
||||
openaiRequest,
|
||||
@@ -64,10 +70,13 @@ export class ContentGenerationPipeline {
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
const effectiveModel = this.contentGeneratorConfig.model;
|
||||
this.converter.setModel(effectiveModel);
|
||||
return this.executeWithErrorHandling(
|
||||
request,
|
||||
userPromptId,
|
||||
true,
|
||||
effectiveModel,
|
||||
async (openaiRequest, context) => {
|
||||
// Stage 1: Create OpenAI stream
|
||||
const stream = (await this.client.chat.completions.create(
|
||||
@@ -224,12 +233,13 @@ export class ContentGenerationPipeline {
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
streaming: boolean = false,
|
||||
effectiveModel: string,
|
||||
): Promise<OpenAI.Chat.ChatCompletionCreateParams> {
|
||||
const messages = this.converter.convertGeminiRequestToOpenAI(request);
|
||||
|
||||
// Apply provider-specific enhancements
|
||||
const baseRequest: OpenAI.Chat.ChatCompletionCreateParams = {
|
||||
model: this.contentGeneratorConfig.model,
|
||||
model: effectiveModel,
|
||||
messages,
|
||||
...this.buildGenerateContentConfig(request),
|
||||
};
|
||||
@@ -335,18 +345,24 @@ export class ContentGenerationPipeline {
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
isStreaming: boolean,
|
||||
effectiveModel: string,
|
||||
executor: (
|
||||
openaiRequest: OpenAI.Chat.ChatCompletionCreateParams,
|
||||
context: RequestContext,
|
||||
) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const context = this.createRequestContext(userPromptId, isStreaming);
|
||||
const context = this.createRequestContext(
|
||||
userPromptId,
|
||||
isStreaming,
|
||||
effectiveModel,
|
||||
);
|
||||
|
||||
try {
|
||||
const openaiRequest = await this.buildRequest(
|
||||
request,
|
||||
userPromptId,
|
||||
isStreaming,
|
||||
effectiveModel,
|
||||
);
|
||||
|
||||
const result = await executor(openaiRequest, context);
|
||||
@@ -378,10 +394,11 @@ export class ContentGenerationPipeline {
|
||||
private createRequestContext(
|
||||
userPromptId: string,
|
||||
isStreaming: boolean,
|
||||
effectiveModel: string,
|
||||
): RequestContext {
|
||||
return {
|
||||
userPromptId,
|
||||
model: this.contentGeneratorConfig.model,
|
||||
model: effectiveModel,
|
||||
authType: this.contentGeneratorConfig.authType || 'unknown',
|
||||
startTime: Date.now(),
|
||||
duration: 0,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Defines the intent returned by the UI layer during a fallback scenario.
|
||||
*/
|
||||
export type FallbackIntent =
|
||||
| 'retry' // Immediately retry the current request with the fallback model.
|
||||
| 'stop' // Switch to fallback for future requests, but stop the current request.
|
||||
| 'auth'; // Stop the current request; user intends to change authentication.
|
||||
|
||||
/**
|
||||
* The interface for the handler provided by the UI layer (e.g., the CLI)
|
||||
* to interact with the user during a fallback scenario.
|
||||
*/
|
||||
export type FallbackModelHandler = (
|
||||
failedModel: string,
|
||||
fallbackModel: string,
|
||||
error?: unknown,
|
||||
) => Promise<FallbackIntent | null>;
|
||||
@@ -9,6 +9,30 @@ export * from './config/config.js';
|
||||
export * from './output/types.js';
|
||||
export * from './output/json-formatter.js';
|
||||
|
||||
// Export models
|
||||
export {
|
||||
type ModelCapabilities,
|
||||
type ModelGenerationConfig,
|
||||
type ModelConfig as ProviderModelConfig,
|
||||
type ModelProvidersConfig,
|
||||
type ResolvedModelConfig,
|
||||
type AvailableModel,
|
||||
type ModelSwitchMetadata,
|
||||
QWEN_OAUTH_MODELS,
|
||||
ModelRegistry,
|
||||
ModelsConfig,
|
||||
type ModelsConfigOptions,
|
||||
type OnModelChangeCallback,
|
||||
// Model configuration resolver
|
||||
resolveModelConfig,
|
||||
validateModelConfig,
|
||||
type ModelConfigSourcesInput,
|
||||
type ModelConfigCliInput,
|
||||
type ModelConfigSettingsInput,
|
||||
type ModelConfigResolutionResult,
|
||||
type ModelConfigValidationResult,
|
||||
} from './models/index.js';
|
||||
|
||||
// Export Core Logic
|
||||
export * from './core/client.js';
|
||||
export * from './core/contentGenerator.js';
|
||||
@@ -21,8 +45,6 @@ export * from './core/geminiRequest.js';
|
||||
export * from './core/coreToolScheduler.js';
|
||||
export * from './core/nonInteractiveToolExecutor.js';
|
||||
|
||||
export * from './fallback/types.js';
|
||||
|
||||
export * from './qwen/qwenOAuth2.js';
|
||||
|
||||
// Export utilities
|
||||
@@ -54,6 +76,9 @@ export * from './utils/projectSummary.js';
|
||||
export * from './utils/promptIdContext.js';
|
||||
export * from './utils/thoughtUtils.js';
|
||||
|
||||
// Config resolution utilities
|
||||
export * from './utils/configResolver.js';
|
||||
|
||||
// Export services
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
export * from './services/gitService.js';
|
||||
|
||||
134
packages/core/src/models/constants.ts
Normal file
134
packages/core/src/models/constants.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
|
||||
import type { ModelConfig } from './types.js';
|
||||
|
||||
type AuthType = import('../core/contentGenerator.js').AuthType;
|
||||
type ContentGeneratorConfig =
|
||||
import('../core/contentGenerator.js').ContentGeneratorConfig;
|
||||
|
||||
/**
|
||||
* Field keys for model-scoped generation config.
|
||||
*
|
||||
* Kept in a small standalone module to avoid circular deps. The `import('...')`
|
||||
* usage is type-only and does not emit runtime imports.
|
||||
*/
|
||||
export const MODEL_GENERATION_CONFIG_FIELDS = [
|
||||
'samplingParams',
|
||||
'timeout',
|
||||
'maxRetries',
|
||||
'disableCacheControl',
|
||||
'schemaCompliance',
|
||||
'reasoning',
|
||||
] as const satisfies ReadonlyArray<keyof ContentGeneratorConfig>;
|
||||
|
||||
/**
|
||||
* Credential-related fields that are part of ContentGeneratorConfig
|
||||
* but not ModelGenerationConfig.
|
||||
*/
|
||||
export const CREDENTIAL_FIELDS = [
|
||||
'model',
|
||||
'apiKey',
|
||||
'apiKeyEnvKey',
|
||||
'baseUrl',
|
||||
] as const satisfies ReadonlyArray<keyof ContentGeneratorConfig>;
|
||||
|
||||
/**
|
||||
* All provider-sourced fields that need to be tracked for source attribution
|
||||
* and cleared when switching from provider to manual credentials.
|
||||
*/
|
||||
export const PROVIDER_SOURCED_FIELDS = [
|
||||
...CREDENTIAL_FIELDS,
|
||||
...MODEL_GENERATION_CONFIG_FIELDS,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Environment variable mappings per authType.
|
||||
*/
|
||||
export interface AuthEnvMapping {
|
||||
apiKey: string[];
|
||||
baseUrl: string[];
|
||||
model: string[];
|
||||
}
|
||||
|
||||
export const AUTH_ENV_MAPPINGS = {
|
||||
openai: {
|
||||
apiKey: ['OPENAI_API_KEY'],
|
||||
baseUrl: ['OPENAI_BASE_URL'],
|
||||
model: ['OPENAI_MODEL', 'QWEN_MODEL'],
|
||||
},
|
||||
anthropic: {
|
||||
apiKey: ['ANTHROPIC_API_KEY'],
|
||||
baseUrl: ['ANTHROPIC_BASE_URL'],
|
||||
model: ['ANTHROPIC_MODEL'],
|
||||
},
|
||||
gemini: {
|
||||
apiKey: ['GEMINI_API_KEY'],
|
||||
baseUrl: [],
|
||||
model: ['GEMINI_MODEL'],
|
||||
},
|
||||
'vertex-ai': {
|
||||
apiKey: ['GOOGLE_API_KEY'],
|
||||
baseUrl: [],
|
||||
model: ['GOOGLE_MODEL'],
|
||||
},
|
||||
'qwen-oauth': {
|
||||
apiKey: [],
|
||||
baseUrl: [],
|
||||
model: [],
|
||||
},
|
||||
} as const satisfies Record<AuthType, AuthEnvMapping>;
|
||||
|
||||
export const DEFAULT_MODELS = {
|
||||
openai: 'qwen3-coder-plus',
|
||||
'qwen-oauth': DEFAULT_QWEN_MODEL,
|
||||
} as Partial<Record<AuthType, string>>;
|
||||
|
||||
export const QWEN_OAUTH_ALLOWED_MODELS = [
|
||||
DEFAULT_QWEN_MODEL,
|
||||
'vision-model',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Hard-coded Qwen OAuth models that are always available.
|
||||
* These cannot be overridden by user configuration.
|
||||
*/
|
||||
export const QWEN_OAUTH_MODELS: ModelConfig[] = [
|
||||
{
|
||||
id: 'coder-model',
|
||||
name: 'Qwen Coder',
|
||||
description:
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
||||
capabilities: { vision: false },
|
||||
generationConfig: {
|
||||
samplingParams: {
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
max_tokens: 8192,
|
||||
},
|
||||
timeout: 60000,
|
||||
maxRetries: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vision-model',
|
||||
name: 'Qwen Vision',
|
||||
description:
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
|
||||
capabilities: { vision: true },
|
||||
generationConfig: {
|
||||
samplingParams: {
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
max_tokens: 8192,
|
||||
},
|
||||
timeout: 60000,
|
||||
maxRetries: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
44
packages/core/src/models/index.ts
Normal file
44
packages/core/src/models/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export {
|
||||
type ModelCapabilities,
|
||||
type ModelGenerationConfig,
|
||||
type ModelConfig,
|
||||
type ModelProvidersConfig,
|
||||
type ResolvedModelConfig,
|
||||
type AvailableModel,
|
||||
type ModelSwitchMetadata,
|
||||
} from './types.js';
|
||||
|
||||
export { ModelRegistry } from './modelRegistry.js';
|
||||
|
||||
export {
|
||||
ModelsConfig,
|
||||
type ModelsConfigOptions,
|
||||
type OnModelChangeCallback,
|
||||
} from './modelsConfig.js';
|
||||
|
||||
export {
|
||||
AUTH_ENV_MAPPINGS,
|
||||
CREDENTIAL_FIELDS,
|
||||
DEFAULT_MODELS,
|
||||
MODEL_GENERATION_CONFIG_FIELDS,
|
||||
PROVIDER_SOURCED_FIELDS,
|
||||
QWEN_OAUTH_ALLOWED_MODELS,
|
||||
QWEN_OAUTH_MODELS,
|
||||
} from './constants.js';
|
||||
|
||||
// Model configuration resolver
|
||||
export {
|
||||
resolveModelConfig,
|
||||
validateModelConfig,
|
||||
type ModelConfigSourcesInput,
|
||||
type ModelConfigCliInput,
|
||||
type ModelConfigSettingsInput,
|
||||
type ModelConfigResolutionResult,
|
||||
type ModelConfigValidationResult,
|
||||
} from './modelConfigResolver.js';
|
||||
125
packages/core/src/models/modelConfigErrors.ts
Normal file
125
packages/core/src/models/modelConfigErrors.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export function getDefaultApiKeyEnvVar(authType: string | undefined): string {
|
||||
switch (authType) {
|
||||
case 'openai':
|
||||
return 'OPENAI_API_KEY';
|
||||
case 'anthropic':
|
||||
return 'ANTHROPIC_API_KEY';
|
||||
case 'gemini':
|
||||
return 'GEMINI_API_KEY';
|
||||
case 'vertex-ai':
|
||||
return 'GOOGLE_API_KEY';
|
||||
default:
|
||||
return 'API_KEY';
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultModelEnvVar(authType: string | undefined): string {
|
||||
switch (authType) {
|
||||
case 'openai':
|
||||
return 'OPENAI_MODEL';
|
||||
case 'anthropic':
|
||||
return 'ANTHROPIC_MODEL';
|
||||
case 'gemini':
|
||||
return 'GEMINI_MODEL';
|
||||
case 'vertex-ai':
|
||||
return 'GOOGLE_MODEL';
|
||||
default:
|
||||
return 'MODEL';
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class ModelConfigError extends Error {
|
||||
abstract readonly code: string;
|
||||
|
||||
protected constructor(message: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class StrictMissingCredentialsError extends ModelConfigError {
|
||||
readonly code = 'STRICT_MISSING_CREDENTIALS';
|
||||
|
||||
constructor(
|
||||
authType: string | undefined,
|
||||
model: string | undefined,
|
||||
envKey?: string,
|
||||
) {
|
||||
const providerKey = authType || '(unknown)';
|
||||
const modelName = model || '(unknown)';
|
||||
super(
|
||||
`Missing credentials for modelProviders model '${modelName}'. ` +
|
||||
(envKey
|
||||
? `Current configured envKey: '${envKey}'. Set that environment variable, or update modelProviders.${providerKey}[].envKey.`
|
||||
: `Configure modelProviders.${providerKey}[].envKey and set that environment variable.`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class StrictMissingModelIdError extends ModelConfigError {
|
||||
readonly code = 'STRICT_MISSING_MODEL_ID';
|
||||
|
||||
constructor(authType: string | undefined) {
|
||||
super(
|
||||
`Missing model id for strict modelProviders resolution (authType: ${authType}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingApiKeyError extends ModelConfigError {
|
||||
readonly code = 'MISSING_API_KEY';
|
||||
|
||||
constructor(params: {
|
||||
authType: string | undefined;
|
||||
model: string | undefined;
|
||||
baseUrl: string | undefined;
|
||||
envKey: string;
|
||||
}) {
|
||||
super(
|
||||
`Missing API key for ${params.authType} auth. ` +
|
||||
`Current model: '${params.model || '(unknown)'}', baseUrl: '${params.baseUrl || '(default)'}'. ` +
|
||||
`Provide an API key via settings (security.auth.apiKey), ` +
|
||||
`or set the environment variable '${params.envKey}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingModelError extends ModelConfigError {
|
||||
readonly code = 'MISSING_MODEL';
|
||||
|
||||
constructor(params: { authType: string | undefined; envKey: string }) {
|
||||
super(
|
||||
`Missing model for ${params.authType} auth. ` +
|
||||
`Set the environment variable '${params.envKey}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingBaseUrlError extends ModelConfigError {
|
||||
readonly code = 'MISSING_BASE_URL';
|
||||
|
||||
constructor(params: {
|
||||
authType: string | undefined;
|
||||
model: string | undefined;
|
||||
}) {
|
||||
super(
|
||||
`Missing baseUrl for modelProviders model '${params.model || '(unknown)'}' (authType: ${params.authType}). ` +
|
||||
`Configure modelProviders.${params.authType || '(unknown)'}[].baseUrl.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingAnthropicBaseUrlEnvError extends ModelConfigError {
|
||||
readonly code = 'MISSING_ANTHROPIC_BASE_URL_ENV';
|
||||
|
||||
constructor() {
|
||||
super('ANTHROPIC_BASE_URL environment variable not found.');
|
||||
}
|
||||
}
|
||||
355
packages/core/src/models/modelConfigResolver.test.ts
Normal file
355
packages/core/src/models/modelConfigResolver.test.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolveModelConfig,
|
||||
validateModelConfig,
|
||||
} from './modelConfigResolver.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
|
||||
describe('modelConfigResolver', () => {
|
||||
describe('resolveModelConfig', () => {
|
||||
describe('OpenAI auth type', () => {
|
||||
it('resolves from CLI with highest priority', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
cli: {
|
||||
model: 'cli-model',
|
||||
apiKey: 'cli-key',
|
||||
baseUrl: 'https://cli.example.com',
|
||||
},
|
||||
settings: {
|
||||
model: 'settings-model',
|
||||
apiKey: 'settings-key',
|
||||
baseUrl: 'https://settings.example.com',
|
||||
},
|
||||
env: {
|
||||
OPENAI_MODEL: 'env-model',
|
||||
OPENAI_API_KEY: 'env-key',
|
||||
OPENAI_BASE_URL: 'https://env.example.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.model).toBe('cli-model');
|
||||
expect(result.config.apiKey).toBe('cli-key');
|
||||
expect(result.config.baseUrl).toBe('https://cli.example.com');
|
||||
|
||||
expect(result.sources['model'].kind).toBe('cli');
|
||||
expect(result.sources['apiKey'].kind).toBe('cli');
|
||||
expect(result.sources['baseUrl'].kind).toBe('cli');
|
||||
});
|
||||
|
||||
it('falls back to env when CLI not provided', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
cli: {},
|
||||
settings: {
|
||||
model: 'settings-model',
|
||||
},
|
||||
env: {
|
||||
OPENAI_MODEL: 'env-model',
|
||||
OPENAI_API_KEY: 'env-key',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.model).toBe('env-model');
|
||||
expect(result.config.apiKey).toBe('env-key');
|
||||
|
||||
expect(result.sources['model'].kind).toBe('env');
|
||||
expect(result.sources['apiKey'].kind).toBe('env');
|
||||
});
|
||||
|
||||
it('falls back to settings when env not provided', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
cli: {},
|
||||
settings: {
|
||||
model: 'settings-model',
|
||||
apiKey: 'settings-key',
|
||||
baseUrl: 'https://settings.example.com',
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.model).toBe('settings-model');
|
||||
expect(result.config.apiKey).toBe('settings-key');
|
||||
expect(result.config.baseUrl).toBe('https://settings.example.com');
|
||||
|
||||
expect(result.sources['model'].kind).toBe('settings');
|
||||
expect(result.sources['apiKey'].kind).toBe('settings');
|
||||
expect(result.sources['baseUrl'].kind).toBe('settings');
|
||||
});
|
||||
|
||||
it('uses default model when nothing provided', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
cli: {},
|
||||
settings: {},
|
||||
env: {
|
||||
OPENAI_API_KEY: 'some-key', // need key to be valid
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.model).toBe('qwen3-coder-plus');
|
||||
expect(result.sources['model'].kind).toBe('default');
|
||||
});
|
||||
|
||||
it('prioritizes modelProvider over CLI', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
cli: {
|
||||
model: 'cli-model',
|
||||
},
|
||||
settings: {},
|
||||
env: {
|
||||
MY_CUSTOM_KEY: 'provider-key',
|
||||
},
|
||||
modelProvider: {
|
||||
id: 'provider-model',
|
||||
name: 'Provider Model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
envKey: 'MY_CUSTOM_KEY',
|
||||
baseUrl: 'https://provider.example.com',
|
||||
generationConfig: {},
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.model).toBe('provider-model');
|
||||
expect(result.config.apiKey).toBe('provider-key');
|
||||
expect(result.config.baseUrl).toBe('https://provider.example.com');
|
||||
|
||||
expect(result.sources['model'].kind).toBe('modelProviders');
|
||||
expect(result.sources['apiKey'].kind).toBe('env');
|
||||
expect(result.sources['apiKey'].via?.kind).toBe('modelProviders');
|
||||
});
|
||||
|
||||
it('reads QWEN_MODEL as fallback for OPENAI_MODEL', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
cli: {},
|
||||
settings: {},
|
||||
env: {
|
||||
QWEN_MODEL: 'qwen-model',
|
||||
OPENAI_API_KEY: 'key',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.model).toBe('qwen-model');
|
||||
expect(result.sources['model'].envKey).toBe('QWEN_MODEL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qwen OAuth auth type', () => {
|
||||
it('uses default model for Qwen OAuth', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
cli: {},
|
||||
settings: {},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.model).toBe(DEFAULT_QWEN_MODEL);
|
||||
expect(result.config.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
|
||||
expect(result.sources['apiKey'].kind).toBe('computed');
|
||||
});
|
||||
|
||||
it('allows vision-model for Qwen OAuth', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
cli: {
|
||||
model: 'vision-model',
|
||||
},
|
||||
settings: {},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.model).toBe('vision-model');
|
||||
expect(result.sources['model'].kind).toBe('cli');
|
||||
});
|
||||
|
||||
it('warns and falls back for unsupported Qwen OAuth models', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
cli: {
|
||||
model: 'unsupported-model',
|
||||
},
|
||||
settings: {},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.model).toBe(DEFAULT_QWEN_MODEL);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain('unsupported-model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anthropic auth type', () => {
|
||||
it('resolves Anthropic config from env', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.USE_ANTHROPIC,
|
||||
cli: {},
|
||||
settings: {},
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: 'anthropic-key',
|
||||
ANTHROPIC_BASE_URL: 'https://anthropic.example.com',
|
||||
ANTHROPIC_MODEL: 'claude-3',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.model).toBe('claude-3');
|
||||
expect(result.config.apiKey).toBe('anthropic-key');
|
||||
expect(result.config.baseUrl).toBe('https://anthropic.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generation config resolution', () => {
|
||||
it('merges generation config from settings', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
cli: {},
|
||||
settings: {
|
||||
apiKey: 'key',
|
||||
generationConfig: {
|
||||
timeout: 60000,
|
||||
maxRetries: 5,
|
||||
samplingParams: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.timeout).toBe(60000);
|
||||
expect(result.config.maxRetries).toBe(5);
|
||||
expect(result.config.samplingParams?.temperature).toBe(0.7);
|
||||
|
||||
expect(result.sources['timeout'].kind).toBe('settings');
|
||||
expect(result.sources['samplingParams'].kind).toBe('settings');
|
||||
});
|
||||
|
||||
it('modelProvider config overrides settings', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
cli: {},
|
||||
settings: {
|
||||
generationConfig: {
|
||||
timeout: 30000,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
MY_KEY: 'key',
|
||||
},
|
||||
modelProvider: {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
envKey: 'MY_KEY',
|
||||
baseUrl: 'https://api.example.com',
|
||||
generationConfig: {
|
||||
timeout: 60000,
|
||||
},
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.timeout).toBe(60000);
|
||||
expect(result.sources['timeout'].kind).toBe('modelProviders');
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy handling', () => {
|
||||
it('includes proxy in config when provided', () => {
|
||||
const result = resolveModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
cli: {},
|
||||
settings: { apiKey: 'key' },
|
||||
env: {},
|
||||
proxy: 'http://proxy.example.com:8080',
|
||||
});
|
||||
|
||||
expect(result.config.proxy).toBe('http://proxy.example.com:8080');
|
||||
expect(result.sources['proxy'].kind).toBe('computed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateModelConfig', () => {
|
||||
it('passes for valid OpenAI config', () => {
|
||||
const result = validateModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
model: 'gpt-4',
|
||||
apiKey: 'sk-xxx',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fails when API key missing', () => {
|
||||
const result = validateModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
model: 'gpt-4',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].message).toContain('Missing API key');
|
||||
});
|
||||
|
||||
it('fails when model missing', () => {
|
||||
const result = validateModelConfig({
|
||||
authType: AuthType.USE_OPENAI,
|
||||
model: '',
|
||||
apiKey: 'sk-xxx',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].message).toContain('Missing model');
|
||||
});
|
||||
|
||||
it('always passes for Qwen OAuth', () => {
|
||||
const result = validateModelConfig({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: DEFAULT_QWEN_MODEL,
|
||||
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('requires baseUrl for Anthropic', () => {
|
||||
const result = validateModelConfig({
|
||||
authType: AuthType.USE_ANTHROPIC,
|
||||
model: 'claude-3',
|
||||
apiKey: 'key',
|
||||
// missing baseUrl
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0].message).toContain('ANTHROPIC_BASE_URL');
|
||||
});
|
||||
|
||||
it('uses strict error messages for modelProvider', () => {
|
||||
const result = validateModelConfig(
|
||||
{
|
||||
authType: AuthType.USE_OPENAI,
|
||||
model: 'my-model',
|
||||
// missing apiKey
|
||||
},
|
||||
true, // isStrictModelProvider
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0].message).toContain('modelProviders');
|
||||
expect(result.errors[0].message).toContain('envKey');
|
||||
});
|
||||
});
|
||||
});
|
||||
362
packages/core/src/models/modelConfigResolver.ts
Normal file
362
packages/core/src/models/modelConfigResolver.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* ModelConfigResolver - Unified resolver for model-related configuration.
|
||||
*
|
||||
* This module consolidates all model configuration resolution logic,
|
||||
* eliminating duplicate code between CLI and Core layers.
|
||||
*
|
||||
* Configuration priority (highest to lowest):
|
||||
* 1. modelProvider - Explicit selection from ModelProviders config
|
||||
* 2. CLI arguments - Command line flags (--model, --openaiApiKey, etc.)
|
||||
* 3. Environment variables - OPENAI_API_KEY, OPENAI_MODEL, etc.
|
||||
* 4. Settings - User/workspace settings file
|
||||
* 5. Defaults - Built-in default values
|
||||
*/
|
||||
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
|
||||
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
import {
|
||||
resolveField,
|
||||
resolveOptionalField,
|
||||
layer,
|
||||
envLayer,
|
||||
cliSource,
|
||||
settingsSource,
|
||||
modelProvidersSource,
|
||||
defaultSource,
|
||||
computedSource,
|
||||
type ConfigSource,
|
||||
type ConfigSources,
|
||||
type ConfigLayer,
|
||||
} from '../utils/configResolver.js';
|
||||
import {
|
||||
AUTH_ENV_MAPPINGS,
|
||||
DEFAULT_MODELS,
|
||||
QWEN_OAUTH_ALLOWED_MODELS,
|
||||
MODEL_GENERATION_CONFIG_FIELDS,
|
||||
} from './constants.js';
|
||||
import type { ResolvedModelConfig } from './types.js';
|
||||
export {
|
||||
validateModelConfig,
|
||||
type ModelConfigValidationResult,
|
||||
} from '../core/contentGenerator.js';
|
||||
|
||||
/**
|
||||
* CLI-provided configuration values
|
||||
*/
|
||||
export interface ModelConfigCliInput {
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings-provided configuration values
|
||||
*/
|
||||
export interface ModelConfigSettingsInput {
|
||||
/** Model name from settings.model.name */
|
||||
model?: string;
|
||||
/** API key from settings.security.auth.apiKey */
|
||||
apiKey?: string;
|
||||
/** Base URL from settings.security.auth.baseUrl */
|
||||
baseUrl?: string;
|
||||
/** Generation config from settings.model.generationConfig */
|
||||
generationConfig?: Partial<ContentGeneratorConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* All input sources for model configuration resolution
|
||||
*/
|
||||
export interface ModelConfigSourcesInput {
|
||||
/** Authentication type */
|
||||
authType: AuthType;
|
||||
|
||||
/** CLI arguments (highest priority for user-provided values) */
|
||||
cli?: ModelConfigCliInput;
|
||||
|
||||
/** Settings file configuration */
|
||||
settings?: ModelConfigSettingsInput;
|
||||
|
||||
/** Environment variables (injected for testability) */
|
||||
env: Record<string, string | undefined>;
|
||||
|
||||
/** Resolved model from ModelProviders (explicit selection, highest priority) */
|
||||
modelProvider?: ResolvedModelConfig;
|
||||
|
||||
/** Proxy URL (computed from Config) */
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of model configuration resolution
|
||||
*/
|
||||
export interface ModelConfigResolutionResult {
|
||||
/** The fully resolved configuration */
|
||||
config: ContentGeneratorConfig;
|
||||
/** Source attribution for each field */
|
||||
sources: ConfigSources;
|
||||
/** Warnings generated during resolution */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve model configuration from all input sources.
|
||||
*
|
||||
* This is the single entry point for model configuration resolution.
|
||||
* It replaces the duplicate logic in:
|
||||
* - packages/cli/src/utils/modelProviderUtils.ts (resolveCliGenerationConfig)
|
||||
* - packages/core/src/core/contentGenerator.ts (resolveContentGeneratorConfigWithSources)
|
||||
*
|
||||
* @param input - All configuration sources
|
||||
* @returns Resolved configuration with source tracking
|
||||
*/
|
||||
export function resolveModelConfig(
|
||||
input: ModelConfigSourcesInput,
|
||||
): ModelConfigResolutionResult {
|
||||
const { authType, cli, settings, env, modelProvider, proxy } = input;
|
||||
const warnings: string[] = [];
|
||||
const sources: ConfigSources = {};
|
||||
|
||||
// Special handling for Qwen OAuth
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
return resolveQwenOAuthConfig(input, warnings);
|
||||
}
|
||||
|
||||
// Get auth-specific env var mappings
|
||||
const envMapping =
|
||||
AUTH_ENV_MAPPINGS[authType] || AUTH_ENV_MAPPINGS[AuthType.USE_OPENAI];
|
||||
|
||||
// Build layers for each field in priority order
|
||||
// Priority: modelProvider > cli > env > settings > default
|
||||
|
||||
// ---- Model ----
|
||||
const modelLayers: Array<ConfigLayer<string>> = [];
|
||||
|
||||
if (modelProvider) {
|
||||
modelLayers.push(
|
||||
layer(
|
||||
modelProvider.id,
|
||||
modelProvidersSource(authType, modelProvider.id, 'model.id'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (cli?.model) {
|
||||
modelLayers.push(layer(cli.model, cliSource('--model')));
|
||||
}
|
||||
for (const envKey of envMapping.model) {
|
||||
modelLayers.push(envLayer(env, envKey));
|
||||
}
|
||||
if (settings?.model) {
|
||||
modelLayers.push(layer(settings.model, settingsSource('model.name')));
|
||||
}
|
||||
|
||||
const defaultModel = DEFAULT_MODELS[authType] || '';
|
||||
const modelResult = resolveField(
|
||||
modelLayers,
|
||||
defaultModel,
|
||||
defaultSource(defaultModel),
|
||||
);
|
||||
sources['model'] = modelResult.source;
|
||||
|
||||
// ---- API Key ----
|
||||
const apiKeyLayers: Array<ConfigLayer<string>> = [];
|
||||
|
||||
// For modelProvider, read from the specified envKey
|
||||
if (modelProvider?.envKey) {
|
||||
const apiKeyFromEnv = env[modelProvider.envKey];
|
||||
if (apiKeyFromEnv) {
|
||||
apiKeyLayers.push(
|
||||
layer(apiKeyFromEnv, {
|
||||
kind: 'env',
|
||||
envKey: modelProvider.envKey,
|
||||
via: modelProvidersSource(authType, modelProvider.id, 'envKey'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (cli?.apiKey) {
|
||||
apiKeyLayers.push(layer(cli.apiKey, cliSource('--openaiApiKey')));
|
||||
}
|
||||
for (const envKey of envMapping.apiKey) {
|
||||
apiKeyLayers.push(envLayer(env, envKey));
|
||||
}
|
||||
if (settings?.apiKey) {
|
||||
apiKeyLayers.push(
|
||||
layer(settings.apiKey, settingsSource('security.auth.apiKey')),
|
||||
);
|
||||
}
|
||||
|
||||
const apiKeyResult = resolveOptionalField(apiKeyLayers);
|
||||
if (apiKeyResult) {
|
||||
sources['apiKey'] = apiKeyResult.source;
|
||||
}
|
||||
|
||||
// ---- Base URL ----
|
||||
const baseUrlLayers: Array<ConfigLayer<string>> = [];
|
||||
|
||||
if (modelProvider?.baseUrl) {
|
||||
baseUrlLayers.push(
|
||||
layer(
|
||||
modelProvider.baseUrl,
|
||||
modelProvidersSource(authType, modelProvider.id, 'baseUrl'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (cli?.baseUrl) {
|
||||
baseUrlLayers.push(layer(cli.baseUrl, cliSource('--openaiBaseUrl')));
|
||||
}
|
||||
for (const envKey of envMapping.baseUrl) {
|
||||
baseUrlLayers.push(envLayer(env, envKey));
|
||||
}
|
||||
if (settings?.baseUrl) {
|
||||
baseUrlLayers.push(
|
||||
layer(settings.baseUrl, settingsSource('security.auth.baseUrl')),
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrlResult = resolveOptionalField(baseUrlLayers);
|
||||
if (baseUrlResult) {
|
||||
sources['baseUrl'] = baseUrlResult.source;
|
||||
}
|
||||
|
||||
// ---- API Key Env Key (for error messages) ----
|
||||
let apiKeyEnvKey: string | undefined;
|
||||
if (modelProvider?.envKey) {
|
||||
apiKeyEnvKey = modelProvider.envKey;
|
||||
sources['apiKeyEnvKey'] = modelProvidersSource(
|
||||
authType,
|
||||
modelProvider.id,
|
||||
'envKey',
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Generation Config (from settings or modelProvider) ----
|
||||
const generationConfig = resolveGenerationConfig(
|
||||
settings?.generationConfig,
|
||||
modelProvider?.generationConfig,
|
||||
authType,
|
||||
modelProvider?.id,
|
||||
sources,
|
||||
);
|
||||
|
||||
// Build final config
|
||||
const config: ContentGeneratorConfig = {
|
||||
authType,
|
||||
model: modelResult.value,
|
||||
apiKey: apiKeyResult?.value,
|
||||
apiKeyEnvKey,
|
||||
baseUrl: baseUrlResult?.value,
|
||||
proxy,
|
||||
...generationConfig,
|
||||
};
|
||||
|
||||
// Add proxy source
|
||||
if (proxy) {
|
||||
sources['proxy'] = computedSource('Config.getProxy()');
|
||||
}
|
||||
|
||||
// Add authType source
|
||||
sources['authType'] = computedSource('provided by caller');
|
||||
|
||||
return { config, sources, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Special resolver for Qwen OAuth authentication.
|
||||
* Qwen OAuth has fixed model options and uses dynamic tokens.
|
||||
*/
|
||||
function resolveQwenOAuthConfig(
|
||||
input: ModelConfigSourcesInput,
|
||||
warnings: string[],
|
||||
): ModelConfigResolutionResult {
|
||||
const { cli, settings, proxy } = input;
|
||||
const sources: ConfigSources = {};
|
||||
|
||||
// Qwen OAuth only allows specific models
|
||||
const allowedModels = new Set<string>(QWEN_OAUTH_ALLOWED_MODELS);
|
||||
|
||||
// Determine requested model
|
||||
const requestedModel = cli?.model || settings?.model;
|
||||
let resolvedModel: string;
|
||||
let modelSource: ConfigSource;
|
||||
|
||||
if (requestedModel && allowedModels.has(requestedModel)) {
|
||||
resolvedModel = requestedModel;
|
||||
modelSource = cli?.model
|
||||
? cliSource('--model')
|
||||
: settingsSource('model.name');
|
||||
} else {
|
||||
if (requestedModel) {
|
||||
warnings.push(
|
||||
`Unsupported Qwen OAuth model '${requestedModel}', falling back to '${DEFAULT_QWEN_MODEL}'.`,
|
||||
);
|
||||
}
|
||||
resolvedModel = DEFAULT_QWEN_MODEL;
|
||||
modelSource = defaultSource(`fallback to '${DEFAULT_QWEN_MODEL}'`);
|
||||
}
|
||||
|
||||
sources['model'] = modelSource;
|
||||
sources['apiKey'] = computedSource('Qwen OAuth dynamic token');
|
||||
sources['authType'] = computedSource('provided by caller');
|
||||
|
||||
if (proxy) {
|
||||
sources['proxy'] = computedSource('Config.getProxy()');
|
||||
}
|
||||
|
||||
// Resolve generation config from settings
|
||||
const generationConfig = resolveGenerationConfig(
|
||||
settings?.generationConfig,
|
||||
undefined,
|
||||
AuthType.QWEN_OAUTH,
|
||||
resolvedModel,
|
||||
sources,
|
||||
);
|
||||
|
||||
const config: ContentGeneratorConfig = {
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: resolvedModel,
|
||||
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
|
||||
proxy,
|
||||
...generationConfig,
|
||||
};
|
||||
|
||||
return { config, sources, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve generation config fields (samplingParams, timeout, etc.)
|
||||
*/
|
||||
function resolveGenerationConfig(
|
||||
settingsConfig: Partial<ContentGeneratorConfig> | undefined,
|
||||
modelProviderConfig: Partial<ContentGeneratorConfig> | undefined,
|
||||
authType: AuthType,
|
||||
modelId: string | undefined,
|
||||
sources: ConfigSources,
|
||||
): Partial<ContentGeneratorConfig> {
|
||||
const result: Partial<ContentGeneratorConfig> = {};
|
||||
|
||||
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
|
||||
// ModelProvider config takes priority
|
||||
if (modelProviderConfig && field in modelProviderConfig) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(result as any)[field] = modelProviderConfig[field];
|
||||
sources[field] = modelProvidersSource(
|
||||
authType,
|
||||
modelId || '',
|
||||
`generationConfig.${field}`,
|
||||
);
|
||||
} else if (settingsConfig && field in settingsConfig) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(result as any)[field] = settingsConfig[field];
|
||||
sources[field] = settingsSource(`model.generationConfig.${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
388
packages/core/src/models/modelRegistry.test.ts
Normal file
388
packages/core/src/models/modelRegistry.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ModelRegistry, QWEN_OAUTH_MODELS } from './modelRegistry.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import type { ModelProvidersConfig } from './types.js';
|
||||
|
||||
describe('ModelRegistry', () => {
|
||||
describe('initialization', () => {
|
||||
it('should always include hard-coded qwen-oauth models', () => {
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH);
|
||||
expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length);
|
||||
expect(qwenModels[0].id).toBe('coder-model');
|
||||
expect(qwenModels[1].id).toBe('vision-model');
|
||||
});
|
||||
|
||||
it('should initialize with empty config', () => {
|
||||
const registry = new ModelRegistry();
|
||||
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
|
||||
QWEN_OAUTH_MODELS.length,
|
||||
);
|
||||
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(0);
|
||||
});
|
||||
|
||||
it('should initialize with custom models config', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'gpt-4-turbo',
|
||||
name: 'GPT-4 Turbo',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const registry = new ModelRegistry(modelProvidersConfig);
|
||||
|
||||
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(openaiModels.length).toBe(1);
|
||||
expect(openaiModels[0].id).toBe('gpt-4-turbo');
|
||||
});
|
||||
|
||||
it('should ignore qwen-oauth models in config (hard-coded)', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
'qwen-oauth': [
|
||||
{
|
||||
id: 'custom-qwen',
|
||||
name: 'Custom Qwen',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const registry = new ModelRegistry(modelProvidersConfig);
|
||||
|
||||
// Should still use hard-coded qwen-oauth models
|
||||
const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH);
|
||||
expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length);
|
||||
expect(qwenModels.find((m) => m.id === 'custom-qwen')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelsForAuthType', () => {
|
||||
let registry: ModelRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'gpt-4-turbo',
|
||||
name: 'GPT-4 Turbo',
|
||||
description: 'Most capable GPT-4',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
capabilities: { vision: true },
|
||||
},
|
||||
{
|
||||
id: 'gpt-3.5-turbo',
|
||||
name: 'GPT-3.5 Turbo',
|
||||
capabilities: { vision: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
registry = new ModelRegistry(modelProvidersConfig);
|
||||
});
|
||||
|
||||
it('should return models for existing authType', () => {
|
||||
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(models.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent authType', () => {
|
||||
const models = registry.getModelsForAuthType(AuthType.USE_VERTEX_AI);
|
||||
expect(models.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return AvailableModel format with correct fields', () => {
|
||||
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
|
||||
const gpt4 = models.find((m) => m.id === 'gpt-4-turbo');
|
||||
|
||||
expect(gpt4).toBeDefined();
|
||||
expect(gpt4?.label).toBe('GPT-4 Turbo');
|
||||
expect(gpt4?.description).toBe('Most capable GPT-4');
|
||||
expect(gpt4?.isVision).toBe(true);
|
||||
expect(gpt4?.authType).toBe(AuthType.USE_OPENAI);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModel', () => {
|
||||
let registry: ModelRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'gpt-4-turbo',
|
||||
name: 'GPT-4 Turbo',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
generationConfig: {
|
||||
samplingParams: {
|
||||
temperature: 0.8,
|
||||
max_tokens: 4096,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
registry = new ModelRegistry(modelProvidersConfig);
|
||||
});
|
||||
|
||||
it('should return resolved model config', () => {
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
|
||||
|
||||
expect(model).toBeDefined();
|
||||
expect(model?.id).toBe('gpt-4-turbo');
|
||||
expect(model?.name).toBe('GPT-4 Turbo');
|
||||
expect(model?.authType).toBe(AuthType.USE_OPENAI);
|
||||
expect(model?.baseUrl).toBe('https://api.openai.com/v1');
|
||||
});
|
||||
|
||||
it('should preserve generationConfig without applying defaults', () => {
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
|
||||
|
||||
expect(model?.generationConfig.samplingParams?.temperature).toBe(0.8);
|
||||
expect(model?.generationConfig.samplingParams?.max_tokens).toBe(4096);
|
||||
// No defaults are applied - only the configured values are present
|
||||
expect(model?.generationConfig.samplingParams?.top_p).toBeUndefined();
|
||||
expect(model?.generationConfig.timeout).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent model', () => {
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'non-existent');
|
||||
expect(model).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent authType', () => {
|
||||
const model = registry.getModel(AuthType.USE_VERTEX_AI, 'some-model');
|
||||
expect(model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasModel', () => {
|
||||
let registry: ModelRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new ModelRegistry({
|
||||
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true for existing model', () => {
|
||||
expect(registry.hasModel(AuthType.USE_OPENAI, 'gpt-4')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existent model', () => {
|
||||
expect(registry.hasModel(AuthType.USE_OPENAI, 'non-existent')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for non-existent authType', () => {
|
||||
expect(registry.hasModel(AuthType.USE_VERTEX_AI, 'gpt-4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultModelForAuthType', () => {
|
||||
it('should return coder-model for qwen-oauth', () => {
|
||||
const registry = new ModelRegistry();
|
||||
const defaultModel = registry.getDefaultModelForAuthType(
|
||||
AuthType.QWEN_OAUTH,
|
||||
);
|
||||
expect(defaultModel?.id).toBe('coder-model');
|
||||
});
|
||||
|
||||
it('should return first model for other authTypes', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [
|
||||
{ id: 'gpt-4', name: 'GPT-4' },
|
||||
{ id: 'gpt-3.5', name: 'GPT-3.5' },
|
||||
],
|
||||
});
|
||||
|
||||
const defaultModel = registry.getDefaultModelForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
);
|
||||
expect(defaultModel?.id).toBe('gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should throw error for model without id', () => {
|
||||
expect(
|
||||
() =>
|
||||
new ModelRegistry({
|
||||
openai: [{ id: '', name: 'No ID' }],
|
||||
}),
|
||||
).toThrow('missing required field: id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default base URLs', () => {
|
||||
it('should apply default dashscope URL for qwen-oauth', () => {
|
||||
const registry = new ModelRegistry();
|
||||
const model = registry.getModel(AuthType.QWEN_OAUTH, 'coder-model');
|
||||
expect(model?.baseUrl).toBe('DYNAMIC_QWEN_OAUTH_BASE_URL');
|
||||
});
|
||||
|
||||
it('should apply default openai URL when not specified', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
||||
});
|
||||
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4');
|
||||
expect(model?.baseUrl).toBe('https://api.openai.com/v1');
|
||||
});
|
||||
|
||||
it('should use custom baseUrl when specified', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'deepseek');
|
||||
expect(model?.baseUrl).toBe('https://api.deepseek.com/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authType key validation', () => {
|
||||
it('should accept valid authType keys', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
||||
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
|
||||
});
|
||||
|
||||
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(openaiModels.length).toBe(1);
|
||||
expect(openaiModels[0].id).toBe('gpt-4');
|
||||
|
||||
const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI);
|
||||
expect(geminiModels.length).toBe(1);
|
||||
expect(geminiModels[0].id).toBe('gemini-pro');
|
||||
});
|
||||
|
||||
it('should skip invalid authType keys with warning', () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const registry = new ModelRegistry({
|
||||
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
||||
'invalid-key': [{ id: 'some-model', name: 'Some Model' }],
|
||||
} as unknown as ModelProvidersConfig);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[ModelRegistry] Invalid authType key'),
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('invalid-key'),
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Expected one of:'),
|
||||
);
|
||||
|
||||
// Valid key should be registered
|
||||
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
|
||||
|
||||
// Invalid key should be skipped (no crash)
|
||||
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(openaiModels.length).toBe(1);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle mixed valid and invalid keys', () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const registry = new ModelRegistry({
|
||||
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
||||
'bad-key-1': [{ id: 'model-1', name: 'Model 1' }],
|
||||
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
|
||||
'bad-key-2': [{ id: 'model-2', name: 'Model 2' }],
|
||||
} as unknown as ModelProvidersConfig);
|
||||
|
||||
// Should warn twice for the two invalid keys
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('bad-key-1'),
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('bad-key-2'),
|
||||
);
|
||||
|
||||
// Valid keys should be registered
|
||||
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
|
||||
expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(1);
|
||||
|
||||
// Invalid keys should be skipped
|
||||
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(openaiModels.length).toBe(1);
|
||||
|
||||
const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI);
|
||||
expect(geminiModels.length).toBe(1);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should list all valid AuthType values in warning message', () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
new ModelRegistry({
|
||||
'invalid-auth': [{ id: 'model', name: 'Model' }],
|
||||
} as unknown as ModelProvidersConfig);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('openai'),
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('qwen-oauth'),
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('gemini'),
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('vertex-ai'),
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('anthropic'),
|
||||
);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should work correctly with getModelsForAuthType after validation', () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const registry = new ModelRegistry({
|
||||
openai: [
|
||||
{ id: 'gpt-4', name: 'GPT-4' },
|
||||
{ id: 'gpt-3.5', name: 'GPT-3.5' },
|
||||
],
|
||||
'invalid-key': [{ id: 'invalid-model', name: 'Invalid Model' }],
|
||||
} as unknown as ModelProvidersConfig);
|
||||
|
||||
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(models.length).toBe(2);
|
||||
expect(models.find((m) => m.id === 'gpt-4')).toBeDefined();
|
||||
expect(models.find((m) => m.id === 'gpt-3.5')).toBeDefined();
|
||||
expect(models.find((m) => m.id === 'invalid-model')).toBeUndefined();
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
180
packages/core/src/models/modelRegistry.ts
Normal file
180
packages/core/src/models/modelRegistry.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import { DEFAULT_OPENAI_BASE_URL } from '../core/openaiContentGenerator/constants.js';
|
||||
import {
|
||||
type ModelConfig,
|
||||
type ModelProvidersConfig,
|
||||
type ResolvedModelConfig,
|
||||
type AvailableModel,
|
||||
} from './types.js';
|
||||
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
import { QWEN_OAUTH_MODELS } from './constants.js';
|
||||
|
||||
export { QWEN_OAUTH_MODELS } from './constants.js';
|
||||
|
||||
/**
|
||||
* Validates if a string key is a valid AuthType enum value.
|
||||
* @param key - The key to validate
|
||||
* @returns The validated AuthType or undefined if invalid
|
||||
*/
|
||||
function validateAuthTypeKey(key: string): AuthType | undefined {
|
||||
// Check if the key is a valid AuthType enum value
|
||||
if (Object.values(AuthType).includes(key as AuthType)) {
|
||||
return key as AuthType;
|
||||
}
|
||||
|
||||
// Invalid key
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central registry for managing model configurations.
|
||||
* Models are organized by authType.
|
||||
*/
|
||||
export class ModelRegistry {
|
||||
private modelsByAuthType: Map<AuthType, Map<string, ResolvedModelConfig>>;
|
||||
|
||||
private getDefaultBaseUrl(authType: AuthType): string {
|
||||
switch (authType) {
|
||||
case AuthType.QWEN_OAUTH:
|
||||
return 'DYNAMIC_QWEN_OAUTH_BASE_URL';
|
||||
case AuthType.USE_OPENAI:
|
||||
return DEFAULT_OPENAI_BASE_URL;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
constructor(modelProvidersConfig?: ModelProvidersConfig) {
|
||||
this.modelsByAuthType = new Map();
|
||||
|
||||
// Always register qwen-oauth models (hard-coded, cannot be overridden)
|
||||
this.registerAuthTypeModels(AuthType.QWEN_OAUTH, QWEN_OAUTH_MODELS);
|
||||
|
||||
// Register user-configured models for other authTypes
|
||||
if (modelProvidersConfig) {
|
||||
for (const [rawKey, models] of Object.entries(modelProvidersConfig)) {
|
||||
const authType = validateAuthTypeKey(rawKey);
|
||||
|
||||
if (!authType) {
|
||||
console.warn(
|
||||
`[ModelRegistry] Invalid authType key "${rawKey}" in modelProviders config. Expected one of: ${Object.values(AuthType).join(', ')}. Skipping.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip qwen-oauth as it uses hard-coded models
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.registerAuthTypeModels(authType, models);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register models for an authType
|
||||
*/
|
||||
private registerAuthTypeModels(
|
||||
authType: AuthType,
|
||||
models: ModelConfig[],
|
||||
): void {
|
||||
const modelMap = new Map<string, ResolvedModelConfig>();
|
||||
|
||||
for (const config of models) {
|
||||
const resolved = this.resolveModelConfig(config, authType);
|
||||
modelMap.set(config.id, resolved);
|
||||
}
|
||||
|
||||
this.modelsByAuthType.set(authType, modelMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models for a specific authType.
|
||||
* This is used by /model command to show only relevant models.
|
||||
*/
|
||||
getModelsForAuthType(authType: AuthType): AvailableModel[] {
|
||||
const models = this.modelsByAuthType.get(authType);
|
||||
if (!models) return [];
|
||||
|
||||
return Array.from(models.values()).map((model) => ({
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
description: model.description,
|
||||
capabilities: model.capabilities,
|
||||
authType: model.authType,
|
||||
isVision: model.capabilities?.vision ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model configuration by authType and modelId
|
||||
*/
|
||||
getModel(
|
||||
authType: AuthType,
|
||||
modelId: string,
|
||||
): ResolvedModelConfig | undefined {
|
||||
const models = this.modelsByAuthType.get(authType);
|
||||
return models?.get(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model exists for given authType
|
||||
*/
|
||||
hasModel(authType: AuthType, modelId: string): boolean {
|
||||
const models = this.modelsByAuthType.get(authType);
|
||||
return models?.has(modelId) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model for an authType.
|
||||
* For qwen-oauth, returns the coder model.
|
||||
* For others, returns the first configured model.
|
||||
*/
|
||||
getDefaultModelForAuthType(
|
||||
authType: AuthType,
|
||||
): ResolvedModelConfig | undefined {
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
return this.getModel(authType, DEFAULT_QWEN_MODEL);
|
||||
}
|
||||
const models = this.modelsByAuthType.get(authType);
|
||||
if (!models || models.size === 0) return undefined;
|
||||
return Array.from(models.values())[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve model config by applying defaults
|
||||
*/
|
||||
private resolveModelConfig(
|
||||
config: ModelConfig,
|
||||
authType: AuthType,
|
||||
): ResolvedModelConfig {
|
||||
this.validateModelConfig(config, authType);
|
||||
|
||||
return {
|
||||
...config,
|
||||
authType,
|
||||
name: config.name || config.id,
|
||||
baseUrl: config.baseUrl || this.getDefaultBaseUrl(authType),
|
||||
generationConfig: config.generationConfig ?? {},
|
||||
capabilities: config.capabilities || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate model configuration
|
||||
*/
|
||||
private validateModelConfig(config: ModelConfig, authType: AuthType): void {
|
||||
if (!config.id) {
|
||||
throw new Error(
|
||||
`Model config in authType '${authType}' missing required field: id`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
451
packages/core/src/models/modelsConfig.test.ts
Normal file
451
packages/core/src/models/modelsConfig.test.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* @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 { 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;
|
||||
}
|
||||
|
||||
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 = deepClone(modelsConfig.getGenerationConfig());
|
||||
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 = modelsConfig.getGenerationConfig();
|
||||
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 = deepClone(modelsConfig.getGenerationConfig());
|
||||
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 preserve an explicit apiKey when switching models if envKey is missing in the environment', 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,
|
||||
initialModelId: 'model-a',
|
||||
modelProvidersConfig,
|
||||
});
|
||||
|
||||
// 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 = modelsConfig.getGenerationConfig();
|
||||
expect(gc.model).toBe('model-b');
|
||||
expect(gc.apiKey).toBe('manual-key');
|
||||
expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED');
|
||||
});
|
||||
|
||||
it('should preserve settings generationConfig when model is updated via updateCredentials even if it matches modelProviders', () => {
|
||||
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,
|
||||
initialModelId: 'model-a',
|
||||
modelProvidersConfig,
|
||||
generationConfig: {
|
||||
model: 'model-a',
|
||||
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 the model via updateCredentials (e.g. key prompt flow).
|
||||
// Even if the model ID matches a modelProviders entry, we must not apply provider defaults
|
||||
// that would overwrite settings.model.generationConfig.
|
||||
modelsConfig.updateCredentials({ model: 'model-a' });
|
||||
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.USE_OPENAI,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
|
||||
const gc = modelsConfig.getGenerationConfig();
|
||||
expect(gc.model).toBe('model-a');
|
||||
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 preserve settings generationConfig across multiple auth refreshes 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const modelsConfig = new ModelsConfig({
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
initialModelId: 'model-a',
|
||||
modelProvidersConfig,
|
||||
generationConfig: {
|
||||
model: 'model-a',
|
||||
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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
modelsConfig.updateCredentials({
|
||||
apiKey: 'manual-key',
|
||||
baseUrl: 'https://manual.example.com/v1',
|
||||
model: 'model-a',
|
||||
});
|
||||
|
||||
// First auth refresh
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.USE_OPENAI,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
// Second auth refresh should still preserve settings generationConfig
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.USE_OPENAI,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
|
||||
const gc = modelsConfig.getGenerationConfig();
|
||||
expect(gc.model).toBe('model-a');
|
||||
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 = modelsConfig.getGenerationConfig();
|
||||
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 = modelsConfig.getGenerationConfig();
|
||||
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 = modelsConfig.getGenerationConfig();
|
||||
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 = modelsConfig.getGenerationConfig();
|
||||
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, 'vision-model');
|
||||
|
||||
const gc = modelsConfig.getGenerationConfig();
|
||||
expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
|
||||
expect(gc.apiKeyEnvKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
697
packages/core/src/models/modelsConfig.ts
Normal file
697
packages/core/src/models/modelsConfig.ts
Normal file
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
|
||||
import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js';
|
||||
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
|
||||
import { ModelRegistry } from './modelRegistry.js';
|
||||
import {
|
||||
type ModelProvidersConfig,
|
||||
type ResolvedModelConfig,
|
||||
type AvailableModel,
|
||||
type ModelSwitchMetadata,
|
||||
} from './types.js';
|
||||
import {
|
||||
MODEL_GENERATION_CONFIG_FIELDS,
|
||||
CREDENTIAL_FIELDS,
|
||||
PROVIDER_SOURCED_FIELDS,
|
||||
} from './constants.js';
|
||||
|
||||
export {
|
||||
MODEL_GENERATION_CONFIG_FIELDS,
|
||||
CREDENTIAL_FIELDS,
|
||||
PROVIDER_SOURCED_FIELDS,
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback for when the model changes.
|
||||
* Used by Config to refresh auth/ContentGenerator when needed.
|
||||
*/
|
||||
export type OnModelChangeCallback = (
|
||||
authType: AuthType,
|
||||
requiresRefresh: boolean,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Options for creating ModelsConfig
|
||||
*/
|
||||
export interface ModelsConfigOptions {
|
||||
/** Initial authType from settings */
|
||||
initialAuthType?: AuthType;
|
||||
/** Initial model ID from settings */
|
||||
initialModelId?: string;
|
||||
/** Model providers configuration */
|
||||
modelProvidersConfig?: ModelProvidersConfig;
|
||||
/** Generation config from CLI/settings */
|
||||
generationConfig?: Partial<ContentGeneratorConfig>;
|
||||
/** Source tracking for generation config */
|
||||
generationConfigSources?: ContentGeneratorConfigSources;
|
||||
/** Callback when model changes require refresh */
|
||||
onModelChange?: OnModelChangeCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* ModelsConfig manages all model selection logic and state.
|
||||
*
|
||||
* This class encapsulates:
|
||||
* - ModelRegistry for model configuration storage
|
||||
* - Current authType and modelId selection
|
||||
* - Generation config management
|
||||
* - Model switching logic
|
||||
*
|
||||
* Config uses this as a thin entry point for all model-related operations.
|
||||
*/
|
||||
export class ModelsConfig {
|
||||
private readonly modelRegistry: ModelRegistry;
|
||||
|
||||
// Current selection state
|
||||
private currentAuthType: AuthType;
|
||||
private currentModelId: string;
|
||||
|
||||
// Generation config state
|
||||
private _generationConfig: Partial<ContentGeneratorConfig>;
|
||||
private generationConfigSources: ContentGeneratorConfigSources;
|
||||
|
||||
// Flag for strict model provider selection
|
||||
private strictModelProviderSelection: boolean = false;
|
||||
|
||||
// One-shot flag for qwen-oauth credential caching
|
||||
private requireCachedQwenCredentialsOnce: boolean = false;
|
||||
|
||||
// One-shot flag indicating credentials were manually set via updateCredentials()
|
||||
// When true, syncAfterAuthRefresh should NOT override these credentials with
|
||||
// modelProviders defaults (even if the model ID matches a registry entry).
|
||||
//
|
||||
// This must be persistent across auth refreshes, because refreshAuth() can be
|
||||
// triggered multiple times after a credential prompt flow. We only clear this
|
||||
// flag when we explicitly apply modelProvider defaults (i.e. when the user
|
||||
// switches to a registry model via switchModel).
|
||||
private hasManualCredentials: boolean = false;
|
||||
|
||||
// Callback for notifying Config of model changes
|
||||
private onModelChange?: OnModelChangeCallback;
|
||||
|
||||
// Flag indicating whether authType was explicitly provided (not defaulted)
|
||||
private readonly authTypeWasExplicitlyProvided: boolean;
|
||||
|
||||
private static deepClone<T>(value: T): T {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => ModelsConfig.deepClone(v)) as T;
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
out[key] = ModelsConfig.deepClone(
|
||||
(value as Record<string, unknown>)[key],
|
||||
);
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
private snapshotState(): {
|
||||
currentAuthType: AuthType;
|
||||
currentModelId: string;
|
||||
generationConfig: Partial<ContentGeneratorConfig>;
|
||||
generationConfigSources: ContentGeneratorConfigSources;
|
||||
strictModelProviderSelection: boolean;
|
||||
requireCachedQwenCredentialsOnce: boolean;
|
||||
hasManualCredentials: boolean;
|
||||
} {
|
||||
return {
|
||||
currentAuthType: this.currentAuthType,
|
||||
currentModelId: this.currentModelId,
|
||||
generationConfig: ModelsConfig.deepClone(this._generationConfig),
|
||||
generationConfigSources: ModelsConfig.deepClone(
|
||||
this.generationConfigSources,
|
||||
),
|
||||
strictModelProviderSelection: this.strictModelProviderSelection,
|
||||
requireCachedQwenCredentialsOnce: this.requireCachedQwenCredentialsOnce,
|
||||
hasManualCredentials: this.hasManualCredentials,
|
||||
};
|
||||
}
|
||||
|
||||
private restoreState(
|
||||
snapshot: ReturnType<ModelsConfig['snapshotState']>,
|
||||
): void {
|
||||
this.currentAuthType = snapshot.currentAuthType;
|
||||
this.currentModelId = snapshot.currentModelId;
|
||||
this._generationConfig = snapshot.generationConfig;
|
||||
this.generationConfigSources = snapshot.generationConfigSources;
|
||||
this.strictModelProviderSelection = snapshot.strictModelProviderSelection;
|
||||
this.requireCachedQwenCredentialsOnce =
|
||||
snapshot.requireCachedQwenCredentialsOnce;
|
||||
this.hasManualCredentials = snapshot.hasManualCredentials;
|
||||
}
|
||||
|
||||
constructor(options: ModelsConfigOptions = {}) {
|
||||
this.modelRegistry = new ModelRegistry(options.modelProvidersConfig);
|
||||
this.onModelChange = options.onModelChange;
|
||||
|
||||
// Initialize generation config
|
||||
this._generationConfig = {
|
||||
model: options.initialModelId,
|
||||
...(options.generationConfig || {}),
|
||||
};
|
||||
this.generationConfigSources = options.generationConfigSources || {};
|
||||
|
||||
// Track if authType was explicitly provided
|
||||
this.authTypeWasExplicitlyProvided = options.initialAuthType !== undefined;
|
||||
|
||||
// Initialize selection state
|
||||
this.currentAuthType = options.initialAuthType || AuthType.QWEN_OAUTH;
|
||||
this.currentModelId = options.initialModelId || '';
|
||||
|
||||
// Validate and initialize default selection
|
||||
this.initializeDefaultSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default selection based on settings/environment.
|
||||
*
|
||||
* Note: The generationConfig passed to ModelsConfig should already be fully
|
||||
* resolved by ModelConfigResolver, which handles CLI args, env vars, and settings.
|
||||
* This method primarily validates and sets up internal state.
|
||||
*/
|
||||
private initializeDefaultSelection(): void {
|
||||
// If generationConfig already has a model (resolved by ModelConfigResolver),
|
||||
// use that as the current selection
|
||||
if (this._generationConfig.model) {
|
||||
this.currentModelId = this._generationConfig.model;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if persisted model selection is valid
|
||||
if (
|
||||
this.currentModelId &&
|
||||
this.modelRegistry.hasModel(this.currentAuthType, this.currentModelId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use registry default
|
||||
const defaultModel = this.modelRegistry.getDefaultModelForAuthType(
|
||||
this.currentAuthType,
|
||||
);
|
||||
if (defaultModel) {
|
||||
this.currentModelId = defaultModel.id;
|
||||
if (!this._generationConfig.model) {
|
||||
this._generationConfig.model = defaultModel.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current model ID
|
||||
*/
|
||||
getModel(): string {
|
||||
return (
|
||||
this._generationConfig.model || this.currentModelId || DEFAULT_QWEN_MODEL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authType
|
||||
*/
|
||||
getCurrentAuthType(): AuthType {
|
||||
return this.currentAuthType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authType was explicitly provided (via CLI or settings).
|
||||
* If false, the default QWEN_OAUTH is being used.
|
||||
*/
|
||||
wasAuthTypeExplicitlyProvided(): boolean {
|
||||
return this.authTypeWasExplicitlyProvided;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models for current authType
|
||||
*/
|
||||
getAvailableModels(): AvailableModel[] {
|
||||
return this.modelRegistry.getModelsForAuthType(this.currentAuthType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models for a specific authType
|
||||
*/
|
||||
getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] {
|
||||
return this.modelRegistry.getModelsForAuthType(authType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model exists for the given authType
|
||||
*/
|
||||
hasModel(authType: AuthType, modelId: string): boolean {
|
||||
return this.modelRegistry.hasModel(authType, modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model programmatically (e.g., VLM auto-switch, fallback).
|
||||
* Supports both registry models and raw model IDs.
|
||||
*/
|
||||
async setModel(
|
||||
newModel: string,
|
||||
metadata?: ModelSwitchMetadata,
|
||||
): Promise<void> {
|
||||
// Special case: qwen-oauth VLM auto-switch - hot update in place
|
||||
if (
|
||||
this.currentAuthType === AuthType.QWEN_OAUTH &&
|
||||
(newModel === DEFAULT_QWEN_MODEL || newModel === 'vision-model')
|
||||
) {
|
||||
this.strictModelProviderSelection = false;
|
||||
this._generationConfig.model = newModel;
|
||||
this.currentModelId = newModel;
|
||||
this.generationConfigSources['model'] = {
|
||||
kind: 'programmatic',
|
||||
detail: metadata?.reason || 'setModel',
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// If model exists in registry, use full switch logic
|
||||
if (this.modelRegistry.hasModel(this.currentAuthType, newModel)) {
|
||||
await this.switchModel(this.currentAuthType, newModel);
|
||||
return;
|
||||
}
|
||||
|
||||
// Raw model override: update generation config in-place
|
||||
this.strictModelProviderSelection = false;
|
||||
this._generationConfig.model = newModel;
|
||||
this.currentModelId = newModel;
|
||||
this.generationConfigSources['model'] = {
|
||||
kind: 'programmatic',
|
||||
detail: metadata?.reason || 'setModel',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch model (and optionally authType) via registry-backed selection.
|
||||
* This is a superset of the previous split APIs for model-only vs authType+model switching.
|
||||
*/
|
||||
async switchModel(
|
||||
authType: AuthType,
|
||||
modelId: string,
|
||||
options?: { requireCachedCredentials?: boolean },
|
||||
_metadata?: ModelSwitchMetadata,
|
||||
): Promise<void> {
|
||||
const snapshot = this.snapshotState();
|
||||
if (authType === AuthType.QWEN_OAUTH && options?.requireCachedCredentials) {
|
||||
this.requireCachedQwenCredentialsOnce = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const isAuthTypeChange = authType !== this.currentAuthType;
|
||||
this.currentAuthType = authType;
|
||||
|
||||
const model = this.modelRegistry.getModel(authType, modelId);
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
`Model '${modelId}' not found for authType '${authType}'`,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply model defaults
|
||||
this.applyResolvedModelDefaults(model);
|
||||
|
||||
// Update selection state
|
||||
this.currentModelId = modelId;
|
||||
|
||||
const requiresRefresh = isAuthTypeChange
|
||||
? true
|
||||
: this.checkRequiresRefresh(snapshot.currentModelId);
|
||||
|
||||
if (this.onModelChange) {
|
||||
await this.onModelChange(authType, requiresRefresh);
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
this.restoreState(snapshot);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get generation config for ContentGenerator creation
|
||||
*/
|
||||
getGenerationConfig(): Partial<ContentGeneratorConfig> {
|
||||
return this._generationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get generation config sources for debugging/UI
|
||||
*/
|
||||
getGenerationConfigSources(): ContentGeneratorConfigSources {
|
||||
return this.generationConfigSources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credentials in generation config.
|
||||
* Sets a flag to prevent syncAfterAuthRefresh from overriding these credentials.
|
||||
*
|
||||
* When credentials are manually set, we clear all provider-sourced configuration
|
||||
* to maintain provider atomicity (either fully applied or not at all).
|
||||
* Other layers (CLI, env, settings, defaults) will participate in resolve.
|
||||
*/
|
||||
updateCredentials(credentials: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}): void {
|
||||
/**
|
||||
* If any fields are updated here, we treat the resulting config as manually overridden
|
||||
* and avoid applying modelProvider defaults during the next auth refresh.
|
||||
*
|
||||
* Clear all provider-sourced configuration to maintain provider atomicity.
|
||||
* This ensures that when user manually sets credentials, the provider config
|
||||
* is either fully applied (via switchModel) or not at all.
|
||||
*/
|
||||
if (credentials.apiKey || credentials.baseUrl || credentials.model) {
|
||||
this.hasManualCredentials = true;
|
||||
this.clearProviderSourcedConfig();
|
||||
}
|
||||
|
||||
if (credentials.apiKey) {
|
||||
this._generationConfig.apiKey = credentials.apiKey;
|
||||
this.generationConfigSources['apiKey'] = {
|
||||
kind: 'programmatic',
|
||||
detail: 'updateCredentials',
|
||||
};
|
||||
}
|
||||
if (credentials.baseUrl) {
|
||||
this._generationConfig.baseUrl = credentials.baseUrl;
|
||||
this.generationConfigSources['baseUrl'] = {
|
||||
kind: 'programmatic',
|
||||
detail: 'updateCredentials',
|
||||
};
|
||||
}
|
||||
if (credentials.model) {
|
||||
this._generationConfig.model = credentials.model;
|
||||
this.currentModelId = credentials.model;
|
||||
this.generationConfigSources['model'] = {
|
||||
kind: 'programmatic',
|
||||
detail: 'updateCredentials',
|
||||
};
|
||||
}
|
||||
// When credentials are manually set, disable strict model provider selection
|
||||
// so validation doesn't require envKey-based credentials
|
||||
this.strictModelProviderSelection = false;
|
||||
// Clear apiKeyEnvKey to prevent validation from requiring environment variable
|
||||
this._generationConfig.apiKeyEnvKey = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear configuration fields that were sourced from modelProviders.
|
||||
* This ensures provider config atomicity when user manually sets credentials.
|
||||
* Other layers (CLI, env, settings, defaults) will participate in resolve.
|
||||
*/
|
||||
private clearProviderSourcedConfig(): void {
|
||||
for (const field of PROVIDER_SOURCED_FIELDS) {
|
||||
const source = this.generationConfigSources[field];
|
||||
if (source?.kind === 'modelProviders') {
|
||||
// Clear the value - let other layers resolve it
|
||||
delete (this._generationConfig as Record<string, unknown>)[field];
|
||||
delete this.generationConfigSources[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether strict model provider selection is enabled
|
||||
*/
|
||||
isStrictModelProviderSelection(): boolean {
|
||||
return this.strictModelProviderSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset strict model provider selection flag
|
||||
*/
|
||||
resetStrictModelProviderSelection(): void {
|
||||
this.strictModelProviderSelection = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and consume the one-shot cached credentials flag
|
||||
*/
|
||||
consumeRequireCachedCredentialsFlag(): boolean {
|
||||
const value = this.requireCachedQwenCredentialsOnce;
|
||||
this.requireCachedQwenCredentialsOnce = false;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply resolved model config to generation config
|
||||
*/
|
||||
private applyResolvedModelDefaults(model: ResolvedModelConfig): void {
|
||||
this.strictModelProviderSelection = true;
|
||||
const previousApiKey = this._generationConfig.apiKey;
|
||||
const previousApiKeyEnvKey = this._generationConfig.apiKeyEnvKey;
|
||||
const hadManualCredentials = this.hasManualCredentials;
|
||||
// We're explicitly applying modelProvider defaults now, so manual overrides
|
||||
// should no longer block syncAfterAuthRefresh from applying provider defaults.
|
||||
this.hasManualCredentials = false;
|
||||
|
||||
this._generationConfig.model = model.id;
|
||||
this.generationConfigSources['model'] = {
|
||||
kind: 'modelProviders',
|
||||
authType: model.authType,
|
||||
modelId: model.id,
|
||||
detail: 'model.id',
|
||||
};
|
||||
|
||||
// Clear credentials to avoid reusing previous model's API key
|
||||
|
||||
// For Qwen OAuth, apiKey must always be a placeholder. It will be dynamically
|
||||
// replaced when building requests. Do not preserve any previous key or read
|
||||
// from envKey.
|
||||
//
|
||||
// (OpenAI client instantiation requires an apiKey even though it will be
|
||||
// replaced later.)
|
||||
if (this.currentAuthType === AuthType.QWEN_OAUTH) {
|
||||
this._generationConfig.apiKey = 'QWEN_OAUTH_DYNAMIC_TOKEN';
|
||||
this.generationConfigSources['apiKey'] = {
|
||||
kind: 'computed',
|
||||
detail: 'Qwen OAuth placeholder token',
|
||||
};
|
||||
this._generationConfig.apiKeyEnvKey = undefined;
|
||||
delete this.generationConfigSources['apiKeyEnvKey'];
|
||||
} else {
|
||||
this._generationConfig.apiKey = undefined;
|
||||
this._generationConfig.apiKeyEnvKey = undefined;
|
||||
}
|
||||
|
||||
// Read API key from environment variable if envKey is specified
|
||||
if (model.envKey !== undefined) {
|
||||
const apiKey = process.env[model.envKey];
|
||||
if (apiKey) {
|
||||
this._generationConfig.apiKey = apiKey;
|
||||
this.generationConfigSources['apiKey'] = {
|
||||
kind: 'env',
|
||||
envKey: model.envKey,
|
||||
via: {
|
||||
kind: 'modelProviders',
|
||||
authType: model.authType,
|
||||
modelId: model.id,
|
||||
detail: 'envKey',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// If the user provided an API key via CLI/settings/updateCredentials, keep it.
|
||||
// We only refuse to reuse a previous key when it is explicitly tied to a
|
||||
// different envKey (e.g. switching between two configured accounts).
|
||||
const canPreservePreviousKey =
|
||||
!!previousApiKey &&
|
||||
(hadManualCredentials ||
|
||||
previousApiKeyEnvKey === undefined ||
|
||||
previousApiKeyEnvKey === model.envKey);
|
||||
|
||||
if (canPreservePreviousKey) {
|
||||
this._generationConfig.apiKey = previousApiKey;
|
||||
this.generationConfigSources['apiKey'] = {
|
||||
kind: 'computed',
|
||||
detail: `preserved previous apiKey (missing env: ${model.envKey})`,
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
`[ModelsConfig] Environment variable '${model.envKey}' is not set for model '${model.id}'. ` +
|
||||
`API key will not be available.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this._generationConfig.apiKeyEnvKey = model.envKey;
|
||||
this.generationConfigSources['apiKeyEnvKey'] = {
|
||||
kind: 'modelProviders',
|
||||
authType: model.authType,
|
||||
modelId: model.id,
|
||||
detail: 'envKey',
|
||||
};
|
||||
}
|
||||
|
||||
// Base URL
|
||||
this._generationConfig.baseUrl = model.baseUrl;
|
||||
this.generationConfigSources['baseUrl'] = {
|
||||
kind: 'modelProviders',
|
||||
authType: model.authType,
|
||||
modelId: model.id,
|
||||
detail: 'baseUrl',
|
||||
};
|
||||
|
||||
// Generation config
|
||||
const gc = model.generationConfig;
|
||||
this._generationConfig.samplingParams = { ...(gc.samplingParams || {}) };
|
||||
this.generationConfigSources['samplingParams'] = {
|
||||
kind: 'modelProviders',
|
||||
authType: model.authType,
|
||||
modelId: model.id,
|
||||
detail: 'generationConfig.samplingParams',
|
||||
};
|
||||
|
||||
this._generationConfig.timeout = gc.timeout;
|
||||
this.generationConfigSources['timeout'] = {
|
||||
kind: 'modelProviders',
|
||||
authType: model.authType,
|
||||
modelId: model.id,
|
||||
detail: 'generationConfig.timeout',
|
||||
};
|
||||
|
||||
this._generationConfig.maxRetries = gc.maxRetries;
|
||||
this.generationConfigSources['maxRetries'] = {
|
||||
kind: 'modelProviders',
|
||||
authType: model.authType,
|
||||
modelId: model.id,
|
||||
detail: 'generationConfig.maxRetries',
|
||||
};
|
||||
|
||||
this._generationConfig.disableCacheControl = gc.disableCacheControl;
|
||||
this.generationConfigSources['disableCacheControl'] = {
|
||||
kind: 'modelProviders',
|
||||
authType: model.authType,
|
||||
modelId: model.id,
|
||||
detail: 'generationConfig.disableCacheControl',
|
||||
};
|
||||
|
||||
this._generationConfig.schemaCompliance = gc.schemaCompliance;
|
||||
this.generationConfigSources['schemaCompliance'] = {
|
||||
kind: 'modelProviders',
|
||||
authType: model.authType,
|
||||
modelId: model.id,
|
||||
detail: 'generationConfig.schemaCompliance',
|
||||
};
|
||||
|
||||
this._generationConfig.reasoning = gc.reasoning;
|
||||
this.generationConfigSources['reasoning'] = {
|
||||
kind: 'modelProviders',
|
||||
authType: model.authType,
|
||||
modelId: model.id,
|
||||
detail: 'generationConfig.reasoning',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model switch requires ContentGenerator refresh.
|
||||
*
|
||||
* Note: This method is ONLY called by switchModel() for same-authType model switches.
|
||||
* Cross-authType switches use switchModel(authType, modelId), which always requires full refresh.
|
||||
*
|
||||
* When this method is called:
|
||||
* - this.currentAuthType is already the target authType
|
||||
* - We're checking if switching between two models within the SAME authType needs refresh
|
||||
*
|
||||
* Examples:
|
||||
* - Qwen OAuth: coder-model -> vision-model (same authType, hot-update safe)
|
||||
* - OpenAI: model-a -> model-b with same envKey (same authType, hot-update safe)
|
||||
* - OpenAI: gpt-4 -> deepseek-chat with different envKey (same authType, needs refresh)
|
||||
*
|
||||
* Cross-authType scenarios:
|
||||
* - OpenAI -> Qwen OAuth: handled by switchModel(authType, modelId), always refreshes
|
||||
* - Qwen OAuth -> OpenAI: handled by switchModel(authType, modelId), always refreshes
|
||||
*/
|
||||
private checkRequiresRefresh(previousModelId: string): boolean {
|
||||
// For Qwen OAuth, model switches within the same authType can always be hot-updated
|
||||
// (coder-model <-> vision-model don't require ContentGenerator recreation)
|
||||
if (this.currentAuthType === AuthType.QWEN_OAUTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get previous and current model configs
|
||||
const previousModel = this.modelRegistry.getModel(
|
||||
this.currentAuthType,
|
||||
previousModelId,
|
||||
);
|
||||
const currentModel = this.modelRegistry.getModel(
|
||||
this.currentAuthType,
|
||||
this.currentModelId,
|
||||
);
|
||||
|
||||
// If either model is not in registry, require refresh to be safe
|
||||
if (!previousModel || !currentModel) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if critical fields changed that require ContentGenerator recreation
|
||||
const criticalFieldsChanged =
|
||||
previousModel.envKey !== currentModel.envKey ||
|
||||
previousModel.baseUrl !== currentModel.baseUrl;
|
||||
|
||||
if (criticalFieldsChanged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For other auth types with strict model provider selection,
|
||||
// if no critical fields changed, we can still hot-update
|
||||
// (e.g., switching between two OpenAI models with same envKey and baseUrl)
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by Config.refreshAuth to sync state after auth refresh.
|
||||
*
|
||||
* IMPORTANT: If credentials were manually set via updateCredentials(),
|
||||
* we should NOT override them with modelProvider defaults.
|
||||
* This handles the case where user inputs credentials via OpenAIKeyPrompt
|
||||
* after removing environment variables for a previously selected model.
|
||||
*/
|
||||
syncAfterAuthRefresh(authType: AuthType, modelId?: string): void {
|
||||
// Check if we have manually set credentials that should be preserved
|
||||
const preserveManualCredentials = this.hasManualCredentials;
|
||||
|
||||
// If credentials were manually set, don't apply modelProvider defaults
|
||||
// Just update the authType and preserve the manually set credentials
|
||||
if (preserveManualCredentials) {
|
||||
this.strictModelProviderSelection = false;
|
||||
this.currentAuthType = authType;
|
||||
if (modelId) {
|
||||
this.currentModelId = modelId;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.strictModelProviderSelection = false;
|
||||
|
||||
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
|
||||
const resolved = this.modelRegistry.getModel(authType, modelId);
|
||||
if (resolved) {
|
||||
this.applyResolvedModelDefaults(resolved);
|
||||
this.currentAuthType = authType;
|
||||
this.currentModelId = modelId;
|
||||
}
|
||||
} else {
|
||||
this.currentAuthType = authType;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update callback for model changes
|
||||
*/
|
||||
setOnModelChange(callback: OnModelChangeCallback): void {
|
||||
this.onModelChange = callback;
|
||||
}
|
||||
}
|
||||
101
packages/core/src/models/types.ts
Normal file
101
packages/core/src/models/types.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
AuthType,
|
||||
ContentGeneratorConfig,
|
||||
} from '../core/contentGenerator.js';
|
||||
|
||||
/**
|
||||
* Model capabilities configuration
|
||||
*/
|
||||
export interface ModelCapabilities {
|
||||
/** Supports image/vision inputs */
|
||||
vision?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-scoped generation configuration.
|
||||
*
|
||||
* Keep this consistent with {@link ContentGeneratorConfig} so modelProviders can
|
||||
* feed directly into content generator resolution without shape conversion.
|
||||
*/
|
||||
export type ModelGenerationConfig = Pick<
|
||||
ContentGeneratorConfig,
|
||||
| 'samplingParams'
|
||||
| 'timeout'
|
||||
| 'maxRetries'
|
||||
| 'disableCacheControl'
|
||||
| 'schemaCompliance'
|
||||
| 'reasoning'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Model configuration for a single model within an authType
|
||||
*/
|
||||
export interface ModelConfig {
|
||||
/** Unique model ID within authType (e.g., "qwen-coder", "gpt-4-turbo") */
|
||||
id: string;
|
||||
/** Display name (defaults to id) */
|
||||
name?: string;
|
||||
/** Model description */
|
||||
description?: string;
|
||||
/** Environment variable name to read API key from (e.g., "OPENAI_API_KEY") */
|
||||
envKey?: string;
|
||||
/** API endpoint override */
|
||||
baseUrl?: string;
|
||||
/** Model capabilities, reserve for future use. Now we do not read this to determine multi-modal support or other capabilities. */
|
||||
capabilities?: ModelCapabilities;
|
||||
/** Generation configuration (sampling parameters) */
|
||||
generationConfig?: ModelGenerationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model providers configuration grouped by authType
|
||||
*/
|
||||
export type ModelProvidersConfig = {
|
||||
[authType: string]: ModelConfig[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolved model config with all defaults applied
|
||||
*/
|
||||
export interface ResolvedModelConfig extends ModelConfig {
|
||||
/** AuthType this model belongs to (always present from map key) */
|
||||
authType: AuthType;
|
||||
/** Display name (always present, defaults to id) */
|
||||
name: string;
|
||||
/** Environment variable name to read API key from (optional, provider-specific) */
|
||||
envKey?: string;
|
||||
/** API base URL (always present, has default per authType) */
|
||||
baseUrl: string;
|
||||
/** Generation config (always present, merged with defaults) */
|
||||
generationConfig: ModelGenerationConfig;
|
||||
/** Capabilities (always present, defaults to {}) */
|
||||
capabilities: ModelCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model info for UI display
|
||||
*/
|
||||
export interface AvailableModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
capabilities?: ModelCapabilities;
|
||||
authType: AuthType;
|
||||
isVision?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for model switch operations
|
||||
*/
|
||||
export interface ModelSwitchMetadata {
|
||||
/** Reason for the switch */
|
||||
reason?: string;
|
||||
/** Additional context */
|
||||
context?: string;
|
||||
}
|
||||
@@ -22,10 +22,11 @@ import {
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { Config, type ConfigParameters } from '../config/config.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
|
||||
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
import {
|
||||
createContentGenerator,
|
||||
createContentGeneratorConfig,
|
||||
resolveContentGeneratorConfigWithSources,
|
||||
AuthType,
|
||||
} from '../core/contentGenerator.js';
|
||||
import { GeminiChat } from '../core/geminiChat.js';
|
||||
@@ -42,7 +43,33 @@ import type {
|
||||
import { SubagentTerminateMode } from './types.js';
|
||||
|
||||
vi.mock('../core/geminiChat.js');
|
||||
vi.mock('../core/contentGenerator.js');
|
||||
vi.mock('../core/contentGenerator.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../core/contentGenerator.js')>();
|
||||
const { DEFAULT_QWEN_MODEL } = await import('../config/models.js');
|
||||
return {
|
||||
...actual,
|
||||
createContentGenerator: vi.fn().mockResolvedValue({
|
||||
generateContent: vi.fn(),
|
||||
generateContentStream: vi.fn(),
|
||||
countTokens: vi.fn().mockResolvedValue({ totalTokens: 100 }),
|
||||
embedContent: vi.fn(),
|
||||
useSummarizedThinking: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
createContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
model: DEFAULT_QWEN_MODEL,
|
||||
authType: actual.AuthType.USE_GEMINI,
|
||||
}),
|
||||
resolveContentGeneratorConfigWithSources: vi.fn().mockReturnValue({
|
||||
config: {
|
||||
model: DEFAULT_QWEN_MODEL,
|
||||
authType: actual.AuthType.USE_GEMINI,
|
||||
apiKey: 'test-api-key',
|
||||
},
|
||||
sources: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('../utils/environmentContext.js', () => ({
|
||||
getEnvironmentContext: vi.fn().mockResolvedValue([{ text: 'Env Context' }]),
|
||||
getInitialChatHistory: vi.fn(async (_config, extraHistory) => [
|
||||
@@ -65,7 +92,7 @@ async function createMockConfig(
|
||||
toolRegistryMocks = {},
|
||||
): Promise<{ config: Config; toolRegistry: ToolRegistry }> {
|
||||
const configParams: ConfigParameters = {
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
model: DEFAULT_QWEN_MODEL,
|
||||
targetDir: '.',
|
||||
debugMode: false,
|
||||
cwd: process.cwd(),
|
||||
@@ -89,7 +116,7 @@ async function createMockConfig(
|
||||
|
||||
// Mock getContentGeneratorConfig to return a valid config
|
||||
vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
model: DEFAULT_QWEN_MODEL,
|
||||
authType: AuthType.USE_GEMINI,
|
||||
});
|
||||
|
||||
@@ -192,9 +219,17 @@ describe('subagent.ts', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
vi.mocked(createContentGeneratorConfig).mockReturnValue({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
model: DEFAULT_QWEN_MODEL,
|
||||
authType: undefined,
|
||||
});
|
||||
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({
|
||||
config: {
|
||||
model: DEFAULT_QWEN_MODEL,
|
||||
authType: AuthType.USE_GEMINI,
|
||||
apiKey: 'test-api-key',
|
||||
},
|
||||
sources: {},
|
||||
});
|
||||
|
||||
mockSendMessageStream = vi.fn();
|
||||
vi.mocked(GeminiChat).mockImplementation(
|
||||
|
||||
141
packages/core/src/utils/configResolver.test.ts
Normal file
141
packages/core/src/utils/configResolver.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolveField,
|
||||
resolveOptionalField,
|
||||
layer,
|
||||
envLayer,
|
||||
cliSource,
|
||||
settingsSource,
|
||||
defaultSource,
|
||||
} from './configResolver.js';
|
||||
|
||||
describe('configResolver', () => {
|
||||
describe('resolveField', () => {
|
||||
it('returns first present value from layers', () => {
|
||||
const result = resolveField(
|
||||
[
|
||||
layer(undefined, cliSource('--model')),
|
||||
envLayer({ MODEL: 'from-env' }, 'MODEL'),
|
||||
layer('from-settings', settingsSource('model.name')),
|
||||
],
|
||||
'default-model',
|
||||
);
|
||||
|
||||
expect(result.value).toBe('from-env');
|
||||
expect(result.source).toEqual({ kind: 'env', envKey: 'MODEL' });
|
||||
});
|
||||
|
||||
it('returns default when all layers are undefined', () => {
|
||||
const result = resolveField(
|
||||
[layer(undefined, cliSource('--model')), envLayer({}, 'MODEL')],
|
||||
'default-model',
|
||||
defaultSource('default-model'),
|
||||
);
|
||||
|
||||
expect(result.value).toBe('default-model');
|
||||
expect(result.source).toEqual({
|
||||
kind: 'default',
|
||||
detail: 'default-model',
|
||||
});
|
||||
});
|
||||
|
||||
it('respects layer priority order', () => {
|
||||
const result = resolveField(
|
||||
[
|
||||
layer('cli-value', cliSource('--model')),
|
||||
envLayer({ MODEL: 'env-value' }, 'MODEL'),
|
||||
layer('settings-value', settingsSource('model.name')),
|
||||
],
|
||||
'default',
|
||||
);
|
||||
|
||||
expect(result.value).toBe('cli-value');
|
||||
expect(result.source.kind).toBe('cli');
|
||||
});
|
||||
|
||||
it('skips empty strings', () => {
|
||||
const result = resolveField(
|
||||
[
|
||||
layer('', cliSource('--model')),
|
||||
envLayer({ MODEL: 'env-value' }, 'MODEL'),
|
||||
],
|
||||
'default',
|
||||
);
|
||||
|
||||
expect(result.value).toBe('env-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOptionalField', () => {
|
||||
it('returns undefined when no value present', () => {
|
||||
const result = resolveOptionalField([
|
||||
layer(undefined, cliSource('--key')),
|
||||
envLayer({}, 'KEY'),
|
||||
]);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns first present value', () => {
|
||||
const result = resolveOptionalField([
|
||||
layer(undefined, cliSource('--key')),
|
||||
envLayer({ KEY: 'found' }, 'KEY'),
|
||||
]);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.value).toBe('found');
|
||||
expect(result!.source.kind).toBe('env');
|
||||
});
|
||||
});
|
||||
|
||||
describe('envLayer', () => {
|
||||
it('creates layer from environment variable', () => {
|
||||
const env = { MY_VAR: 'my-value' };
|
||||
const result = envLayer(env, 'MY_VAR');
|
||||
|
||||
expect(result.value).toBe('my-value');
|
||||
expect(result.source).toEqual({ kind: 'env', envKey: 'MY_VAR' });
|
||||
});
|
||||
|
||||
it('handles missing environment variable', () => {
|
||||
const env = {};
|
||||
const result = envLayer(env, 'MISSING_VAR');
|
||||
|
||||
expect(result.value).toBeUndefined();
|
||||
expect(result.source).toEqual({ kind: 'env', envKey: 'MISSING_VAR' });
|
||||
});
|
||||
|
||||
it('supports transform function', () => {
|
||||
const env = { PORT: '3000' };
|
||||
const result = envLayer(env, 'PORT', (v) => parseInt(v, 10));
|
||||
|
||||
expect(result.value).toBe(3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('source factory functions', () => {
|
||||
it('creates CLI source', () => {
|
||||
expect(cliSource('--model')).toEqual({ kind: 'cli', detail: '--model' });
|
||||
});
|
||||
|
||||
it('creates settings source', () => {
|
||||
expect(settingsSource('model.name')).toEqual({
|
||||
kind: 'settings',
|
||||
settingsPath: 'model.name',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates default source', () => {
|
||||
expect(defaultSource('my-default')).toEqual({
|
||||
kind: 'default',
|
||||
detail: 'my-default',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
222
packages/core/src/utils/configResolver.ts
Normal file
222
packages/core/src/utils/configResolver.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generic multi-source configuration resolver utilities.
|
||||
*
|
||||
* This module provides reusable tools for resolving configuration values
|
||||
* from multiple sources (CLI, env, settings, etc.) with priority ordering
|
||||
* and source tracking.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Known source kinds for configuration values.
|
||||
* Extensible for domain-specific needs.
|
||||
*/
|
||||
export type ConfigSourceKind =
|
||||
| 'cli'
|
||||
| 'env'
|
||||
| 'settings'
|
||||
| 'modelProviders'
|
||||
| 'default'
|
||||
| 'computed'
|
||||
| 'programmatic'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Source metadata for a configuration value.
|
||||
* Tracks where the value came from for debugging and UI display.
|
||||
*/
|
||||
export interface ConfigSource {
|
||||
/** The kind/category of the source */
|
||||
kind: ConfigSourceKind;
|
||||
/** Additional detail about the source (e.g., '--model' for CLI) */
|
||||
detail?: string;
|
||||
/** Environment variable key if kind is 'env' */
|
||||
envKey?: string;
|
||||
/** Settings path if kind is 'settings' (e.g., 'model.name') */
|
||||
settingsPath?: string;
|
||||
/** Auth type if relevant (for modelProviders) */
|
||||
authType?: string;
|
||||
/** Model ID if relevant (for modelProviders) */
|
||||
modelId?: string;
|
||||
/** Indirect source - when a value is derived via another source */
|
||||
via?: Omit<ConfigSource, 'via'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of field names to their sources
|
||||
*/
|
||||
export type ConfigSources = Record<string, ConfigSource>;
|
||||
|
||||
/**
|
||||
* A configuration layer represents a potential source for a value.
|
||||
* Layers are evaluated in priority order (first non-undefined wins).
|
||||
*/
|
||||
export interface ConfigLayer<T> {
|
||||
/** The value from this layer (undefined means not present) */
|
||||
value: T | undefined;
|
||||
/** Source metadata for this layer */
|
||||
source: ConfigSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving a single field
|
||||
*/
|
||||
export interface ResolvedField<T> {
|
||||
/** The resolved value */
|
||||
value: T;
|
||||
/** Source metadata indicating where the value came from */
|
||||
source: ConfigSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single configuration field from multiple layers.
|
||||
*
|
||||
* Layers are evaluated in order. The first layer with a defined,
|
||||
* non-empty value wins. If no layer has a value, the default is used.
|
||||
*
|
||||
* @param layers - Configuration layers in priority order (highest first)
|
||||
* @param defaultValue - Default value if no layer provides one
|
||||
* @param defaultSource - Source metadata for the default value
|
||||
* @returns The resolved value and its source
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const model = resolveField(
|
||||
* [
|
||||
* { value: argv.model, source: { kind: 'cli', detail: '--model' } },
|
||||
* { value: env['OPENAI_MODEL'], source: { kind: 'env', envKey: 'OPENAI_MODEL' } },
|
||||
* { value: settings.model, source: { kind: 'settings', settingsPath: 'model.name' } },
|
||||
* ],
|
||||
* 'default-model',
|
||||
* { kind: 'default', detail: 'default-model' }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function resolveField<T>(
|
||||
layers: Array<ConfigLayer<T>>,
|
||||
defaultValue: T,
|
||||
defaultSource: ConfigSource = { kind: 'default' },
|
||||
): ResolvedField<T> {
|
||||
for (const layer of layers) {
|
||||
if (isValuePresent(layer.value)) {
|
||||
return { value: layer.value, source: layer.source };
|
||||
}
|
||||
}
|
||||
return { value: defaultValue, source: defaultSource };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a field that may not have a default (optional field).
|
||||
*
|
||||
* @param layers - Configuration layers in priority order
|
||||
* @returns The resolved value and source, or undefined if not found
|
||||
*/
|
||||
export function resolveOptionalField<T>(
|
||||
layers: Array<ConfigLayer<T>>,
|
||||
): ResolvedField<T> | undefined {
|
||||
for (const layer of layers) {
|
||||
if (isValuePresent(layer.value)) {
|
||||
return { value: layer.value, source: layer.source };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is "present" (not undefined, not null, not empty string).
|
||||
*
|
||||
* @param value - The value to check
|
||||
* @returns true if the value should be considered present
|
||||
*/
|
||||
function isValuePresent<T>(value: T | undefined | null): value is T {
|
||||
if (value === undefined || value === null) {
|
||||
return false;
|
||||
}
|
||||
// Treat empty strings as not present
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CLI source descriptor
|
||||
*/
|
||||
export function cliSource(detail: string): ConfigSource {
|
||||
return { kind: 'cli', detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an environment variable source descriptor
|
||||
*/
|
||||
function envSource(envKey: string): ConfigSource {
|
||||
return { kind: 'env', envKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a settings source descriptor
|
||||
*/
|
||||
export function settingsSource(settingsPath: string): ConfigSource {
|
||||
return { kind: 'settings', settingsPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a modelProviders source descriptor
|
||||
*/
|
||||
export function modelProvidersSource(
|
||||
authType: string,
|
||||
modelId: string,
|
||||
detail?: string,
|
||||
): ConfigSource {
|
||||
return { kind: 'modelProviders', authType, modelId, detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default value source descriptor
|
||||
*/
|
||||
export function defaultSource(detail?: string): ConfigSource {
|
||||
return { kind: 'default', detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a computed value source descriptor
|
||||
*/
|
||||
export function computedSource(detail?: string): ConfigSource {
|
||||
return { kind: 'computed', detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a layer from an environment variable
|
||||
*/
|
||||
export function envLayer<T = string>(
|
||||
env: Record<string, string | undefined>,
|
||||
key: string,
|
||||
transform?: (value: string) => T,
|
||||
): ConfigLayer<T> {
|
||||
const rawValue = env[key];
|
||||
const value =
|
||||
rawValue !== undefined
|
||||
? transform
|
||||
? transform(rawValue)
|
||||
: (rawValue as unknown as T)
|
||||
: undefined;
|
||||
return {
|
||||
value,
|
||||
source: envSource(key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a layer with a static value and source
|
||||
*/
|
||||
export function layer<T>(
|
||||
value: T | undefined,
|
||||
source: ConfigSource,
|
||||
): ConfigLayer<T> {
|
||||
return { value, source };
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Config } from '../config/config.js';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
setSimulate429,
|
||||
disableSimulationAfterFallback,
|
||||
shouldSimulate429,
|
||||
resetRequestCounter,
|
||||
} from './testUtils.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
// Import the new types (Assuming this test file is in packages/core/src/utils/)
|
||||
import type { FallbackModelHandler } from '../fallback/types.js';
|
||||
|
||||
vi.mock('node:fs');
|
||||
|
||||
// Update the description to reflect that this tests the retry utility's integration
|
||||
describe('Retry Utility Fallback Integration', () => {
|
||||
let config: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
config = new Config({
|
||||
targetDir: '/test',
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: 'gemini-2.5-pro',
|
||||
});
|
||||
|
||||
// Reset simulation state for each test
|
||||
setSimulate429(false);
|
||||
resetRequestCounter();
|
||||
});
|
||||
|
||||
// This test validates the Config's ability to store and execute the handler contract.
|
||||
it('should execute the injected FallbackHandler contract correctly', async () => {
|
||||
// Set up a minimal handler for testing, ensuring it matches the new type.
|
||||
const fallbackHandler: FallbackModelHandler = async () => 'retry';
|
||||
|
||||
// Use the generalized setter
|
||||
config.setFallbackModelHandler(fallbackHandler);
|
||||
|
||||
// Call the handler directly via the config property
|
||||
const result = await config.fallbackModelHandler!(
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
|
||||
// Verify it returns the correct intent
|
||||
expect(result).toBe('retry');
|
||||
});
|
||||
|
||||
// This test validates the test utilities themselves.
|
||||
it('should properly disable simulation state after fallback (Test Utility)', () => {
|
||||
// Enable simulation
|
||||
setSimulate429(true);
|
||||
|
||||
// Verify simulation is enabled
|
||||
expect(shouldSimulate429()).toBe(true);
|
||||
|
||||
// Disable simulation after fallback
|
||||
disableSimulationAfterFallback();
|
||||
|
||||
// Verify simulation is now disabled
|
||||
expect(shouldSimulate429()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { createHash } from 'node:crypto';
|
||||
import { type Content, Type } from '@google/genai';
|
||||
import { type BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { LruCache } from './LruCache.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
|
||||
import { promptIdContext } from './promptIdContext.js';
|
||||
|
||||
const MAX_CACHE_SIZE = 50;
|
||||
@@ -149,7 +149,7 @@ export async function FixLLMEditWithInstruction(
|
||||
contents,
|
||||
schema: SearchReplaceEditSchema,
|
||||
abortSignal,
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
model: DEFAULT_QWEN_FLASH_MODEL,
|
||||
systemInstruction: EDIT_SYS_PROMPT,
|
||||
promptId,
|
||||
maxAttempts: 1,
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
GenerateContentResponse,
|
||||
} from '@google/genai';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
|
||||
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
|
||||
import { getResponseText, partToString } from './partUtils.js';
|
||||
|
||||
/**
|
||||
@@ -86,7 +86,7 @@ export async function summarizeToolOutput(
|
||||
contents,
|
||||
toolOutputSummarizerConfig,
|
||||
abortSignal,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_QWEN_FLASH_MODEL,
|
||||
)) as unknown as GenerateContentResponse;
|
||||
return getResponseText(parsedResponse) || textToSummarize;
|
||||
} catch (error) {
|
||||
|
||||
24
packages/sdk-java/.editorconfig
Normal file
24
packages/sdk-java/.editorconfig
Normal file
@@ -0,0 +1,24 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
ij_continuation_indent_size = 8
|
||||
|
||||
[*.java]
|
||||
ij_java_doc_align_exception_comments = false
|
||||
ij_java_doc_align_param_comments = false
|
||||
|
||||
[*.{yaml, yml, sh, ps1}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{md, mkd, markdown}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[{**/res/**.xml, **/AndroidManifest.xml}]
|
||||
ij_continuation_indent_size = 4
|
||||
14
packages/sdk-java/.gitignore
vendored
Normal file
14
packages/sdk-java/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
# Mac
|
||||
.DS_Store
|
||||
|
||||
# Maven
|
||||
log/
|
||||
target/
|
||||
|
||||
/docs/
|
||||
201
packages/sdk-java/LICENSE
Normal file
201
packages/sdk-java/LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
378
packages/sdk-java/QWEN.md
Normal file
378
packages/sdk-java/QWEN.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# Qwen Code Java SDK
|
||||
|
||||
## Project Overview
|
||||
|
||||
The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
|
||||
|
||||
**Context Information:**
|
||||
|
||||
- Current Date: Monday 5 January 2026
|
||||
- Operating System: darwin
|
||||
- Working Directory: /Users/weigeng/repos/qwen-code/packages/sdk-java
|
||||
|
||||
## Project Details
|
||||
|
||||
- **Group ID**: com.alibaba
|
||||
- **Artifact ID**: qwencode-sdk (as per pom.xml)
|
||||
- **Version**: 0.0.1-SNAPSHOT
|
||||
- **Packaging**: JAR
|
||||
- **Java Version**: 1.8+ (source and target)
|
||||
- **License**: Apache-2.0
|
||||
|
||||
## Architecture
|
||||
|
||||
The SDK follows a layered architecture:
|
||||
|
||||
- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
|
||||
- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
|
||||
- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
|
||||
- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
|
||||
- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
|
||||
|
||||
## Key Components
|
||||
|
||||
### Main Classes
|
||||
|
||||
- `QwenCodeCli`: Main entry point with static methods for simple queries
|
||||
- `Session`: Manages communication sessions with the CLI
|
||||
- `Transport`: Abstracts the communication mechanism (currently using process transport)
|
||||
- `ProcessTransport`: Implementation that communicates via process execution
|
||||
- `TransportOptions`: Configuration class for transport layer settings
|
||||
- `SessionEventSimpleConsumers`: High-level event handler for processing responses
|
||||
- `AssistantContentSimpleConsumers`: Handles different types of content within assistant messages
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **Logging**: ch.qos.logback:logback-classic
|
||||
- **Utilities**: org.apache.commons:commons-lang3
|
||||
- **JSON Processing**: com.alibaba.fastjson2:fastjson2
|
||||
- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Java 8 or higher
|
||||
- Apache Maven 3.6.0 or higher
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Compile the project
|
||||
mvn compile
|
||||
|
||||
# Run tests
|
||||
mvn test
|
||||
|
||||
# Package the JAR
|
||||
mvn package
|
||||
|
||||
# Install to local repository
|
||||
mvn install
|
||||
|
||||
# Run checkstyle verification
|
||||
mvn checkstyle:check
|
||||
|
||||
# Generate Javadoc
|
||||
mvn javadoc:javadoc
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The project includes basic unit tests using JUnit 5. The main test class `QwenCodeCliTest` demonstrates how to use the SDK to make simple queries to the Qwen Code CLI.
|
||||
|
||||
### Code Quality
|
||||
|
||||
The project uses Checkstyle for code formatting and style enforcement. The configuration is defined in `checkstyle.xml` and includes rules for:
|
||||
|
||||
- Whitespace and indentation
|
||||
- Naming conventions
|
||||
- Import ordering
|
||||
- Code structure
|
||||
- Line endings (LF only)
|
||||
- No trailing whitespace
|
||||
- 8-space indentation for line wrapping
|
||||
|
||||
## Development Conventions
|
||||
|
||||
### Coding Standards
|
||||
|
||||
- Java 8 language features are supported
|
||||
- Follow standard Java naming conventions
|
||||
- Use UTF-8 encoding for source files
|
||||
- Line endings should be LF (Unix-style)
|
||||
- No trailing whitespace allowed
|
||||
- Use 8-space indentation for line wrapping
|
||||
|
||||
### Testing Practices
|
||||
|
||||
- Write unit tests using JUnit 5
|
||||
- Test classes should be in the `src/test/java` directory
|
||||
- Follow the naming convention `*Test.java` for test classes
|
||||
- Use appropriate assertions to validate functionality
|
||||
|
||||
### Documentation
|
||||
|
||||
- API documentation should follow Javadoc conventions
|
||||
- Update README files when adding new features
|
||||
- Include examples in documentation
|
||||
|
||||
## API Reference
|
||||
|
||||
### QwenCodeCli Class
|
||||
|
||||
The main class provides several primary methods:
|
||||
|
||||
- `simpleQuery(String prompt)`: Synchronous method that returns a list of responses
|
||||
- `simpleQuery(String prompt, TransportOptions transportOptions)`: Synchronous method with custom transport options
|
||||
- `simpleQuery(String prompt, TransportOptions transportOptions, AssistantContentConsumers assistantContentConsumers)`: Advanced method with custom content consumers
|
||||
- `newSession()`: Creates a new session with default options
|
||||
- `newSession(TransportOptions transportOptions)`: Creates a new session with custom options
|
||||
|
||||
### Permission Modes
|
||||
|
||||
The SDK supports different permission modes for controlling tool execution:
|
||||
|
||||
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
|
||||
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
|
||||
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
|
||||
- **`yolo`**: All tools execute automatically without confirmation.
|
||||
|
||||
### Transport Options
|
||||
|
||||
The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI:
|
||||
|
||||
- `pathToQwenExecutable`: Path to the Qwen Code CLI executable
|
||||
- `cwd`: Working directory for the CLI process
|
||||
- `model`: AI model to use for the session
|
||||
- `permissionMode`: Permission mode that controls tool execution
|
||||
- `env`: Environment variables to pass to the CLI process
|
||||
- `maxSessionTurns`: Limits the number of conversation turns in a session
|
||||
- `coreTools`: List of core tools that should be available to the AI
|
||||
- `excludeTools`: List of tools to exclude from being available to the AI
|
||||
- `allowedTools`: List of tools that are pre-approved for use without additional confirmation
|
||||
- `authType`: Authentication type to use for the session
|
||||
- `includePartialMessages`: Enables receiving partial messages during streaming responses
|
||||
- `skillsEnable`: Enables or disables skills functionality for the session
|
||||
- `turnTimeout`: Timeout for a complete turn of conversation
|
||||
- `messageTimeout`: Timeout for individual messages within a turn
|
||||
- `resumeSessionId`: ID of a previous session to resume
|
||||
- `otherOptions`: Additional command-line options to pass to the CLI
|
||||
|
||||
### Session Control Features
|
||||
|
||||
- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options
|
||||
- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state
|
||||
- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process
|
||||
- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session
|
||||
- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt
|
||||
- **Dynamic model switching**: Use `session.setModel()` to change the model during a session
|
||||
- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session
|
||||
|
||||
### Thread Pool Configuration
|
||||
|
||||
The SDK uses a thread pool for managing concurrent operations with the following default configuration:
|
||||
|
||||
- **Core Pool Size**: 30 threads
|
||||
- **Maximum Pool Size**: 100 threads
|
||||
- **Keep-Alive Time**: 60 seconds
|
||||
- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue)
|
||||
- **Thread Naming**: "qwen_code_cli-pool-{number}"
|
||||
- **Daemon Threads**: false
|
||||
- **Rejected Execution Handler**: CallerRunsPolicy
|
||||
|
||||
### Session Event Consumers and Assistant Content Consumers
|
||||
|
||||
The SDK provides two key interfaces for handling events and content from the CLI:
|
||||
|
||||
#### SessionEventConsumers Interface
|
||||
|
||||
The `SessionEventConsumers` interface provides callbacks for different types of messages during a session:
|
||||
|
||||
- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage)
|
||||
- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage)
|
||||
- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
|
||||
- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
|
||||
- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage)
|
||||
- `onOtherMessage`: Handles other types of messages (receives Session and String message)
|
||||
- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse)
|
||||
- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
|
||||
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest<CLIControlPermissionRequest>, returns Behavior)
|
||||
|
||||
#### AssistantContentConsumers Interface
|
||||
|
||||
The `AssistantContentConsumers` interface handles different types of content within assistant messages:
|
||||
|
||||
- `onText`: Handles text content (receives Session and TextAssistantContent)
|
||||
- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent)
|
||||
- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent)
|
||||
- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent)
|
||||
- `onOtherContent`: Handles other content types (receives Session and AssistantContent)
|
||||
- `onUsage`: Handles usage information (receives Session and AssistantUsage)
|
||||
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
|
||||
- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)
|
||||
|
||||
#### Relationship Between the Interfaces
|
||||
|
||||
**Important Note on Event Hierarchy:**
|
||||
|
||||
- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.)
|
||||
- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)
|
||||
|
||||
**Processor Relationship:**
|
||||
|
||||
- `SessionEventConsumers` → `AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)
|
||||
|
||||
**Event Derivation Relationships:**
|
||||
|
||||
- `onAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage`
|
||||
- `onPartialAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`
|
||||
- `onControlRequest` → `onPermissionRequest`, `onOtherControlRequest`
|
||||
|
||||
**Event Timeout Relationships:**
|
||||
|
||||
Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:
|
||||
|
||||
- `onSystemMessage` ↔ `onSystemMessageTimeout`
|
||||
- `onResultMessage` ↔ `onResultMessageTimeout`
|
||||
- `onAssistantMessage` ↔ `onAssistantMessageTimeout`
|
||||
- `onPartialAssistantMessage` ↔ `onPartialAssistantMessageTimeout`
|
||||
- `onUserMessage` ↔ `onUserMessageTimeout`
|
||||
- `onOtherMessage` ↔ `onOtherMessageTimeout`
|
||||
- `onControlResponse` ↔ `onControlResponseTimeout`
|
||||
- `onControlRequest` ↔ `onControlRequestTimeout`
|
||||
|
||||
For AssistantContentConsumers timeout methods:
|
||||
|
||||
- `onText` ↔ `onTextTimeout`
|
||||
- `onThinking` ↔ `onThinkingTimeout`
|
||||
- `onToolUse` ↔ `onToolUseTimeout`
|
||||
- `onToolResult` ↔ `onToolResultTimeout`
|
||||
- `onOtherContent` ↔ `onOtherContentTimeout`
|
||||
- `onPermissionRequest` ↔ `onPermissionRequestTimeout`
|
||||
- `onOtherControlRequest` ↔ `onOtherControlRequestTimeout`
|
||||
|
||||
**Default Timeout Values:**
|
||||
|
||||
- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
|
||||
- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)
|
||||
|
||||
**Timeout Hierarchy Requirements:**
|
||||
|
||||
For proper operation, the following timeout relationships should be maintained:
|
||||
|
||||
- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values
|
||||
- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values
|
||||
|
||||
#### Relationship Between the Interfaces
|
||||
|
||||
- `AssistantContentSimpleConsumers` is the default implementation of `AssistantContentConsumers`
|
||||
- `SessionEventSimpleConsumers` is the concrete implementation that combines both interfaces and depends on an `AssistantContentConsumers` instance to handle content within assistant messages
|
||||
- The timeout methods in `SessionEventConsumers` now include the message object as a parameter (e.g., `onSystemMessageTimeout(Session session, SDKSystemMessage systemMessage)`)
|
||||
|
||||
Event processing is subject to the timeout settings configured in `TransportOptions` and `SessionEventConsumers`. For detailed timeout configuration options, see the "Timeout" section above.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
The SDK includes several example files in `src/test/java/com/alibaba/qwen/code/cli/example/` that demonstrate different aspects of the API:
|
||||
|
||||
### Basic Usage
|
||||
|
||||
- `QuickStartExample.java`: Demonstrates simple query usage, transport options configuration, and streaming content handling
|
||||
|
||||
### Session Control
|
||||
|
||||
- `SessionExample.java`: Shows session control features including permission mode changes, model switching, interruption, and event handling
|
||||
|
||||
### Configuration
|
||||
|
||||
- `ThreadPoolConfigurationExample.java`: Shows how to configure the thread pool used by the SDK
|
||||
|
||||
## Error Handling
|
||||
|
||||
The SDK provides specific exception types for different error scenarios:
|
||||
|
||||
- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.)
|
||||
- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response
|
||||
- `SessionClosedException`: Thrown when attempting to use a closed session
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── example/
|
||||
│ └── java/
|
||||
│ └── com/
|
||||
│ └── alibaba/
|
||||
│ └── qwen/
|
||||
│ └── code/
|
||||
│ └── example/
|
||||
├── main/
|
||||
│ └── java/
|
||||
│ └── com/
|
||||
│ └── alibaba/
|
||||
│ └── qwen/
|
||||
│ └── code/
|
||||
│ └── cli/
|
||||
│ ├── QwenCodeCli.java
|
||||
│ ├── protocol/
|
||||
│ ├── session/
|
||||
│ ├── transport/
|
||||
│ └── utils/
|
||||
└── test/
|
||||
├── java/
|
||||
│ └── com/
|
||||
│ └── alibaba/
|
||||
│ └── qwen/
|
||||
│ └── code/
|
||||
│ └── cli/
|
||||
│ ├── QwenCodeCliTest.java
|
||||
│ ├── session/
|
||||
│ │ └── SessionTest.java
|
||||
│ └── transport/
|
||||
│ ├── PermissionModeTest.java
|
||||
│ └── process/
|
||||
│ └── ProcessTransportTest.java
|
||||
└── temp/
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- `pom.xml`: Maven build configuration and dependencies
|
||||
- `checkstyle.xml`: Code style and formatting rules
|
||||
- `.editorconfig`: Editor configuration settings
|
||||
|
||||
## FAQ / Troubleshooting
|
||||
|
||||
### Q: Do I need to install the Qwen CLI separately?
|
||||
|
||||
A: No, from v0.1.1, the CLI is bundled with the SDK, so no standalone CLI installation is needed.
|
||||
|
||||
### Q: What Java versions are supported?
|
||||
|
||||
A: The SDK requires Java 1.8 or higher.
|
||||
|
||||
### Q: How do I handle long-running requests?
|
||||
|
||||
A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`.
|
||||
|
||||
### Q: Why are some tools not executing?
|
||||
|
||||
A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools.
|
||||
|
||||
### Q: How do I resume a previous session?
|
||||
|
||||
A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session.
|
||||
|
||||
### Q: Can I customize the environment for the CLI process?
|
||||
|
||||
A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process.
|
||||
|
||||
### Q: What happens if the CLI process crashes?
|
||||
|
||||
A: The SDK will throw appropriate exceptions. Make sure to handle `SessionControlException` and implement retry logic if needed.
|
||||
|
||||
## Maintainers
|
||||
|
||||
- **Developer**: skyfire (gengwei.gw(at)alibaba-inc.com)
|
||||
- **Organization**: Alibaba Group
|
||||
312
packages/sdk-java/README.md
Normal file
312
packages/sdk-java/README.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Qwen Code Java SDK
|
||||
|
||||
The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java >= 1.8
|
||||
- Maven >= 3.6.0 (for building from source)
|
||||
- qwen-code >= 0.5.0
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **Logging**: ch.qos.logback:logback-classic
|
||||
- **Utilities**: org.apache.commons:commons-lang3
|
||||
- **JSON Processing**: com.alibaba.fastjson2:fastjson2
|
||||
- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
|
||||
|
||||
## Installation
|
||||
|
||||
Add the following dependency to your Maven `pom.xml`:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>qwencode-sdk</artifactId>
|
||||
<version>{$version}</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Or if using Gradle, add to your `build.gradle`:
|
||||
|
||||
```gradle
|
||||
implementation 'com.alibaba:qwencode-sdk:{$version}'
|
||||
```
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Compile the project
|
||||
mvn compile
|
||||
|
||||
# Run tests
|
||||
mvn test
|
||||
|
||||
# Package the JAR
|
||||
mvn package
|
||||
|
||||
# Install to local repository
|
||||
mvn install
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method:
|
||||
|
||||
```java
|
||||
public static void runSimpleExample() {
|
||||
List<String> result = QwenCodeCli.simpleQuery("hello world");
|
||||
result.forEach(logger::info);
|
||||
}
|
||||
```
|
||||
|
||||
For more advanced usage with custom transport options:
|
||||
|
||||
```java
|
||||
public static void runTransportOptionsExample() {
|
||||
TransportOptions options = new TransportOptions()
|
||||
.setModel("qwen3-coder-flash")
|
||||
.setPermissionMode(PermissionMode.AUTO_EDIT)
|
||||
.setCwd("./")
|
||||
.setEnv(new HashMap<String, String>() {{put("CUSTOM_VAR", "value");}})
|
||||
.setIncludePartialMessages(true)
|
||||
.setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))
|
||||
.setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))
|
||||
.setAllowedTools(Arrays.asList("read_file", "write_file", "list_directory"));
|
||||
|
||||
List<String> result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options);
|
||||
result.forEach(logger::info);
|
||||
}
|
||||
```
|
||||
|
||||
For streaming content handling with custom content consumers:
|
||||
|
||||
```java
|
||||
public static void runStreamingExample() {
|
||||
QwenCodeCli.simpleQuery("who are you, what are your capabilities?",
|
||||
new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() {
|
||||
|
||||
@Override
|
||||
public void onText(Session session, TextAssistantContent textAssistantContent) {
|
||||
logger.info("Text content received: {}", textAssistantContent.getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) {
|
||||
logger.info("Thinking content received: {}", thingkingAssistantContent.getThinking());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) {
|
||||
logger.info("Tool use content received: {} with arguments: {}",
|
||||
toolUseContent, toolUseContent.getInput());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) {
|
||||
logger.info("Tool result content received: {}", toolResultContent.getContent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOtherContent(Session session, AssistantContent<?> other) {
|
||||
logger.info("Other content received: {}", other);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUsage(Session session, AssistantUsage assistantUsage) {
|
||||
logger.info("Usage information received: Input tokens: {}, Output tokens: {}",
|
||||
assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens());
|
||||
}
|
||||
}.setDefaultPermissionOperation(Operation.allow));
|
||||
logger.info("Streaming example completed.");
|
||||
}
|
||||
```
|
||||
|
||||
other examples see src/test/java/com/alibaba/qwen/code/cli/example
|
||||
|
||||
## Architecture
|
||||
|
||||
The SDK follows a layered architecture:
|
||||
|
||||
- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
|
||||
- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
|
||||
- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
|
||||
- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
|
||||
- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
|
||||
|
||||
## Key Features
|
||||
|
||||
### Permission Modes
|
||||
|
||||
The SDK supports different permission modes for controlling tool execution:
|
||||
|
||||
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
|
||||
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
|
||||
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
|
||||
- **`yolo`**: All tools execute automatically without confirmation.
|
||||
|
||||
### Session Event Consumers and Assistant Content Consumers
|
||||
|
||||
The SDK provides two key interfaces for handling events and content from the CLI:
|
||||
|
||||
#### SessionEventConsumers Interface
|
||||
|
||||
The `SessionEventConsumers` interface provides callbacks for different types of messages during a session:
|
||||
|
||||
- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage)
|
||||
- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage)
|
||||
- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
|
||||
- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
|
||||
- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage)
|
||||
- `onOtherMessage`: Handles other types of messages (receives Session and String message)
|
||||
- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse)
|
||||
- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
|
||||
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest<CLIControlPermissionRequest>, returns Behavior)
|
||||
|
||||
#### AssistantContentConsumers Interface
|
||||
|
||||
The `AssistantContentConsumers` interface handles different types of content within assistant messages:
|
||||
|
||||
- `onText`: Handles text content (receives Session and TextAssistantContent)
|
||||
- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent)
|
||||
- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent)
|
||||
- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent)
|
||||
- `onOtherContent`: Handles other content types (receives Session and AssistantContent)
|
||||
- `onUsage`: Handles usage information (receives Session and AssistantUsage)
|
||||
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
|
||||
- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)
|
||||
|
||||
#### Relationship Between the Interfaces
|
||||
|
||||
**Important Note on Event Hierarchy:**
|
||||
|
||||
- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.)
|
||||
- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)
|
||||
|
||||
**Processor Relationship:**
|
||||
|
||||
- `SessionEventConsumers` → `AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)
|
||||
|
||||
**Event Derivation Relationships:**
|
||||
|
||||
- `onAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage`
|
||||
- `onPartialAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`
|
||||
- `onControlRequest` → `onPermissionRequest`, `onOtherControlRequest`
|
||||
|
||||
**Event Timeout Relationships:**
|
||||
|
||||
Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:
|
||||
|
||||
- `onSystemMessage` ↔ `onSystemMessageTimeout`
|
||||
- `onResultMessage` ↔ `onResultMessageTimeout`
|
||||
- `onAssistantMessage` ↔ `onAssistantMessageTimeout`
|
||||
- `onPartialAssistantMessage` ↔ `onPartialAssistantMessageTimeout`
|
||||
- `onUserMessage` ↔ `onUserMessageTimeout`
|
||||
- `onOtherMessage` ↔ `onOtherMessageTimeout`
|
||||
- `onControlResponse` ↔ `onControlResponseTimeout`
|
||||
- `onControlRequest` ↔ `onControlRequestTimeout`
|
||||
|
||||
For AssistantContentConsumers timeout methods:
|
||||
|
||||
- `onText` ↔ `onTextTimeout`
|
||||
- `onThinking` ↔ `onThinkingTimeout`
|
||||
- `onToolUse` ↔ `onToolUseTimeout`
|
||||
- `onToolResult` ↔ `onToolResultTimeout`
|
||||
- `onOtherContent` ↔ `onOtherContentTimeout`
|
||||
- `onPermissionRequest` ↔ `onPermissionRequestTimeout`
|
||||
- `onOtherControlRequest` ↔ `onOtherControlRequestTimeout`
|
||||
|
||||
**Default Timeout Values:**
|
||||
|
||||
- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
|
||||
- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)
|
||||
|
||||
**Timeout Hierarchy Requirements:**
|
||||
|
||||
For proper operation, the following timeout relationships should be maintained:
|
||||
|
||||
- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values
|
||||
- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values
|
||||
|
||||
### Transport Options
|
||||
|
||||
The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI:
|
||||
|
||||
- `pathToQwenExecutable`: Path to the Qwen Code CLI executable
|
||||
- `cwd`: Working directory for the CLI process
|
||||
- `model`: AI model to use for the session
|
||||
- `permissionMode`: Permission mode that controls tool execution
|
||||
- `env`: Environment variables to pass to the CLI process
|
||||
- `maxSessionTurns`: Limits the number of conversation turns in a session
|
||||
- `coreTools`: List of core tools that should be available to the AI
|
||||
- `excludeTools`: List of tools to exclude from being available to the AI
|
||||
- `allowedTools`: List of tools that are pre-approved for use without additional confirmation
|
||||
- `authType`: Authentication type to use for the session
|
||||
- `includePartialMessages`: Enables receiving partial messages during streaming responses
|
||||
- `skillsEnable`: Enables or disables skills functionality for the session
|
||||
- `turnTimeout`: Timeout for a complete turn of conversation
|
||||
- `messageTimeout`: Timeout for individual messages within a turn
|
||||
- `resumeSessionId`: ID of a previous session to resume
|
||||
- `otherOptions`: Additional command-line options to pass to the CLI
|
||||
|
||||
### Session Control Features
|
||||
|
||||
- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options
|
||||
- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state
|
||||
- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process
|
||||
- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session
|
||||
- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt
|
||||
- **Dynamic model switching**: Use `session.setModel()` to change the model during a session
|
||||
- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session
|
||||
|
||||
### Thread Pool Configuration
|
||||
|
||||
The SDK uses a thread pool for managing concurrent operations with the following default configuration:
|
||||
|
||||
- **Core Pool Size**: 30 threads
|
||||
- **Maximum Pool Size**: 100 threads
|
||||
- **Keep-Alive Time**: 60 seconds
|
||||
- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue)
|
||||
- **Thread Naming**: "qwen_code_cli-pool-{number}"
|
||||
- **Daemon Threads**: false
|
||||
- **Rejected Execution Handler**: CallerRunsPolicy
|
||||
|
||||
## Error Handling
|
||||
|
||||
The SDK provides specific exception types for different error scenarios:
|
||||
|
||||
- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.)
|
||||
- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response
|
||||
- `SessionClosedException`: Thrown when attempting to use a closed session
|
||||
|
||||
## FAQ / Troubleshooting
|
||||
|
||||
### Q: Do I need to install the Qwen CLI separately?
|
||||
|
||||
A: No, from v0.1.1, the CLI is bundled with the SDK, so no standalone CLI installation is needed.
|
||||
|
||||
### Q: What Java versions are supported?
|
||||
|
||||
A: The SDK requires Java 1.8 or higher.
|
||||
|
||||
### Q: How do I handle long-running requests?
|
||||
|
||||
A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`.
|
||||
|
||||
### Q: Why are some tools not executing?
|
||||
|
||||
A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools.
|
||||
|
||||
### Q: How do I resume a previous session?
|
||||
|
||||
A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session.
|
||||
|
||||
### Q: Can I customize the environment for the CLI process?
|
||||
|
||||
A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 - see [LICENSE](./LICENSE) for details.
|
||||
131
packages/sdk-java/checkstyle.xml
Normal file
131
packages/sdk-java/checkstyle.xml
Normal file
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
|
||||
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
|
||||
<module name="Checker">
|
||||
<module name="FileTabCharacter" />
|
||||
<module name="NewlineAtEndOfFile">
|
||||
<property name="lineSeparator" value="lf" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="\r" />
|
||||
<property name="message" value="Line contains carriage return" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value=" \n" />
|
||||
<property name="message" value="Line has trailing whitespace" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="\n\n\n" />
|
||||
<property name="message" value="Multiple consecutive blank lines" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="\n\n\Z" />
|
||||
<property name="message" value="Blank line before end of file" />
|
||||
</module>
|
||||
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="\{\n\n" />
|
||||
<property name="message" value="Blank line after opening brace" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="\n\n\s*\}" />
|
||||
<property name="message" value="Blank line before closing brace" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="->\s*\{\s+\}" />
|
||||
<property name="message" value="Whitespace inside empty lambda body" />
|
||||
</module>
|
||||
|
||||
<module name="TreeWalker">
|
||||
<module name="SuppressWarningsHolder" />
|
||||
|
||||
<module name="EmptyBlock">
|
||||
<property name="option" value="text" />
|
||||
<property name="tokens" value="
|
||||
LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY, LITERAL_IF,
|
||||
LITERAL_FOR, LITERAL_TRY, LITERAL_WHILE, INSTANCE_INIT, STATIC_INIT" />
|
||||
</module>
|
||||
<module name="EmptyStatement" />
|
||||
<module name="EmptyForInitializerPad" />
|
||||
<module name="MethodParamPad">
|
||||
<property name="allowLineBreaks" value="true" />
|
||||
<property name="option" value="nospace" />
|
||||
</module>
|
||||
<module name="ParenPad" />
|
||||
<module name="TypecastParenPad" />
|
||||
<module name="NeedBraces" />
|
||||
<module name="LeftCurly">
|
||||
<property name="option" value="eol" />
|
||||
<property name="tokens" value="
|
||||
LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY, LITERAL_FOR,
|
||||
LITERAL_IF, LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE" />
|
||||
</module>
|
||||
<module name="GenericWhitespace" />
|
||||
<module name="WhitespaceAfter" />
|
||||
<module name="NoWhitespaceAfter" />
|
||||
<module name="NoWhitespaceBefore" />
|
||||
<module name="SingleSpaceSeparator" />
|
||||
<module name="Indentation">
|
||||
<property name="throwsIndent" value="8" />
|
||||
<property name="lineWrappingIndentation" value="8" />
|
||||
</module>
|
||||
|
||||
<module name="UpperEll" />
|
||||
<module name="DefaultComesLast" />
|
||||
<module name="ArrayTypeStyle" />
|
||||
<module name="ModifierOrder" />
|
||||
<module name="OneStatementPerLine" />
|
||||
<module name="StringLiteralEquality" />
|
||||
<module name="MutableException" />
|
||||
<module name="EqualsHashCode" />
|
||||
<module name="ExplicitInitialization" />
|
||||
<module name="OneTopLevelClass" />
|
||||
|
||||
<module name="MemberName" />
|
||||
<module name="PackageName" />
|
||||
<module name="ClassTypeParameterName">
|
||||
<property name="format" value="^[A-Z][0-9]?$" />
|
||||
</module>
|
||||
<module name="MethodTypeParameterName">
|
||||
<property name="format" value="^[A-Z][0-9]?$" />
|
||||
</module>
|
||||
<module name="AnnotationUseStyle">
|
||||
<property name="trailingArrayComma" value="ignore" />
|
||||
</module>
|
||||
|
||||
<module name="RedundantImport" />
|
||||
<module name="UnusedImports" />
|
||||
<!-- <module name="ImportOrder">-->
|
||||
<!-- <property name="groups" value="*,javax,java" />-->
|
||||
<!-- <property name="separated" value="true" />-->
|
||||
<!-- <property name="option" value="bottom" />-->
|
||||
<!-- <property name="sortStaticImportsAlphabetically" value="true" />-->
|
||||
<!-- </module>-->
|
||||
|
||||
<module name="WhitespaceAround">
|
||||
<property name="allowEmptyConstructors" value="true" />
|
||||
<property name="allowEmptyMethods" value="true" />
|
||||
<property name="allowEmptyLambdas" value="true" />
|
||||
<property name="ignoreEnhancedForColon" value="false" />
|
||||
<property name="tokens" value="
|
||||
ASSIGN, BAND, BAND_ASSIGN, BOR, BOR_ASSIGN, BSR, BSR_ASSIGN,
|
||||
BXOR, BXOR_ASSIGN, COLON, DIV, DIV_ASSIGN, DO_WHILE, EQUAL, GE, GT, LAND,
|
||||
LAMBDA, LE, LITERAL_ASSERT, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE,
|
||||
LITERAL_FINALLY, LITERAL_FOR, LITERAL_IF, LITERAL_RETURN, LITERAL_SWITCH,
|
||||
LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE,
|
||||
LOR, LT, MINUS, MINUS_ASSIGN, MOD, MOD_ASSIGN, NOT_EQUAL,
|
||||
PLUS, PLUS_ASSIGN, QUESTION, SL, SLIST, SL_ASSIGN, SR, SR_ASSIGN,
|
||||
STAR, STAR_ASSIGN, TYPE_EXTENSION_AND" />
|
||||
</module>
|
||||
|
||||
<module name="WhitespaceAfter" />
|
||||
|
||||
<module name="NoWhitespaceAfter">
|
||||
<property name="tokens" value="DOT" />
|
||||
<property name="allowLineBreaks" value="false" />
|
||||
</module>
|
||||
|
||||
<module name="MissingOverride"/>
|
||||
</module>
|
||||
</module>
|
||||
193
packages/sdk-java/pom.xml
Normal file
193
packages/sdk-java/pom.xml
Normal file
@@ -0,0 +1,193 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>qwencode-sdk</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
<version>0.0.1-alpha</version>
|
||||
<name>qwencode-sdk</name>
|
||||
<description>The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface
|
||||
to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
|
||||
</description>
|
||||
<url>https://maven.apache.org</url>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache 2</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
|
||||
<distribution>repo</distribution>
|
||||
<comments>A business-friendly OSS license</comments>
|
||||
</license>
|
||||
</licenses>
|
||||
<scm>
|
||||
<url>https://github.com/QwenLM/qwen-code</url>
|
||||
<connection>scm:git:https://github.com/QwenLM/qwen-code.git</connection>
|
||||
</scm>
|
||||
<properties>
|
||||
<maven.compiler.target>1.8</maven.compiler.target>
|
||||
<maven.compiler.source>1.8</maven.compiler.source>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<checkstyle-maven-plugin.version>3.6.0</checkstyle-maven-plugin.version>
|
||||
<jacoco-maven-plugin.version>0.8.12</jacoco-maven-plugin.version>
|
||||
<junit5.version>5.14.1</junit5.version>
|
||||
<logback-classic.version>1.3.16</logback-classic.version>
|
||||
<fastjson2.version>2.0.60</fastjson2.version>
|
||||
<maven-compiler-plugin.version>3.13.0</maven-compiler-plugin.version>
|
||||
<central-publishing-maven-plugin.version>9</central-publishing-maven-plugin.version>
|
||||
<maven-source-plugin.version>2</maven-source-plugin.version>
|
||||
<maven-javadoc-plugin.version>2.9.1</maven-javadoc-plugin.version>
|
||||
<maven-gpg-plugin.version>1.5</maven-gpg-plugin.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit</groupId>
|
||||
<artifactId>junit-bom</artifactId>
|
||||
<type>pom</type>
|
||||
<version>${junit5.version}</version>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>${logback-classic.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>3.20.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.fastjson2</groupId>
|
||||
<artifactId>fastjson2</artifactId>
|
||||
<version>${fastjson2.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>${checkstyle-maven-plugin.version}</version>
|
||||
<configuration>
|
||||
<configLocation>checkstyle.xml</configLocation>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco-maven-plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<version>0.${central-publishing-maven-plugin.version}.0</version>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<publishingServerId>central</publishingServerId>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-source-plugin</artifactId>
|
||||
<version>${maven-source-plugin.version}.2.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-sources</id>
|
||||
<goals>
|
||||
<goal>jar-no-fork</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-javadoc-plugin</artifactId>
|
||||
<version>${maven-javadoc-plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-javadocs</id>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<version>${maven-gpg-plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-artifacts</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>sign</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<organization>
|
||||
<name>Alibaba Group</name>
|
||||
<url>https://github.com/alibaba</url>
|
||||
</organization>
|
||||
<developers>
|
||||
<developer>
|
||||
<id>skyfire</id>
|
||||
<name>skyfire</name>
|
||||
<email>gengwei.gw(at)alibaba-inc.com</email>
|
||||
<roles>
|
||||
<role>Developer</role>
|
||||
<role>Designer</role>
|
||||
</roles>
|
||||
<timezone>+8</timezone>
|
||||
<url>https://github.com/gwinthis</url>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<distributionManagement>
|
||||
<snapshotRepository>
|
||||
<id>central</id>
|
||||
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
|
||||
</snapshotRepository>
|
||||
<repository>
|
||||
<id>central</id>
|
||||
<url>https://central.sonatype.org/service/local/staging/deploy/maven2/</url>
|
||||
</repository>
|
||||
</distributionManagement>
|
||||
</project>
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.alibaba.qwen.code.cli;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation;
|
||||
import com.alibaba.qwen.code.cli.session.Session;
|
||||
import com.alibaba.qwen.code.cli.session.event.consumers.AssistantContentConsumers;
|
||||
import com.alibaba.qwen.code.cli.session.event.consumers.AssistantContentSimpleConsumers;
|
||||
import com.alibaba.qwen.code.cli.session.event.consumers.SessionEventSimpleConsumers;
|
||||
import com.alibaba.qwen.code.cli.transport.Transport;
|
||||
import com.alibaba.qwen.code.cli.transport.TransportOptions;
|
||||
import com.alibaba.qwen.code.cli.transport.process.ProcessTransport;
|
||||
import com.alibaba.qwen.code.cli.utils.MyConcurrentUtils;
|
||||
import com.alibaba.qwen.code.cli.utils.Timeout;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Main entry point for interacting with the Qwen Code CLI. Provides static methods for simple queries and session management.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public class QwenCodeCli {
|
||||
private static final Logger log = LoggerFactory.getLogger(QwenCodeCli.class);
|
||||
|
||||
/**
|
||||
* Sends a simple query to the Qwen Code CLI and returns a list of responses.
|
||||
*
|
||||
* @param prompt The input prompt to send to the CLI
|
||||
* @return A list of strings representing the CLI's responses
|
||||
*/
|
||||
public static List<String> simpleQuery(String prompt) {
|
||||
return simpleQuery(prompt, new TransportOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a simple query with custom transport options.
|
||||
*
|
||||
* @param prompt The input prompt to send to the CLI
|
||||
* @param transportOptions Configuration options for the transport layer
|
||||
* @return A list of strings representing the CLI's responses
|
||||
*/
|
||||
public static List<String> simpleQuery(String prompt, TransportOptions transportOptions) {
|
||||
final List<String> response = new ArrayList<>();
|
||||
MyConcurrentUtils.runAndWait(() -> simpleQuery(prompt, transportOptions, new AssistantContentSimpleConsumers() {
|
||||
@Override
|
||||
public void onText(Session session, TextAssistantContent textAssistantContent) {
|
||||
response.add(textAssistantContent.getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) {
|
||||
response.add(thingkingAssistantContent.getThinking());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent) {
|
||||
response.add(JSON.toJSONString(toolUseAssistantContent.getContentOfAssistant()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent) {
|
||||
response.add(JSON.toJSONString(toolResultAssistantContent));
|
||||
}
|
||||
|
||||
public void onOtherContent(Session session, AssistantContent<?> other) {
|
||||
response.add(JSON.toJSONString(other.getContentOfAssistant()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUsage(Session session, AssistantUsage assistantUsage) {
|
||||
log.info("received usage {} of message {}", assistantUsage.getUsage(), assistantUsage.getMessageId());
|
||||
}
|
||||
}.setDefaultPermissionOperation(Operation.allow)), Timeout.TIMEOUT_30_MINUTES);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a query with custom content consumers.
|
||||
*
|
||||
* @param prompt The input prompt to send to the CLI
|
||||
* @param transportOptions Configuration options for the transport layer
|
||||
* @param assistantContentConsumers Consumers for handling different types of assistant content
|
||||
*/
|
||||
public static void simpleQuery(String prompt, TransportOptions transportOptions, AssistantContentConsumers assistantContentConsumers) {
|
||||
Session session = newSession(transportOptions);
|
||||
try {
|
||||
session.sendPrompt(prompt, new SessionEventSimpleConsumers()
|
||||
.setAssistantContentConsumer(assistantContentConsumers));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("sendPrompt error!", e);
|
||||
} finally {
|
||||
try {
|
||||
session.close();
|
||||
} catch (Exception e) {
|
||||
log.error("close session error!", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session with default transport options.
|
||||
*
|
||||
* @return A new Session instance
|
||||
*/
|
||||
public static Session newSession() {
|
||||
return newSession(new TransportOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session with custom transport options.
|
||||
*
|
||||
* @param transportOptions Configuration options for the transport layer
|
||||
* @return A new Session instance
|
||||
*/
|
||||
public static Session newSession(TransportOptions transportOptions) {
|
||||
Transport transport;
|
||||
try {
|
||||
transport = new ProcessTransport(transportOptions);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("initialized ProcessTransport error!", e);
|
||||
}
|
||||
|
||||
Session session;
|
||||
try {
|
||||
session = new Session(transport);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("initialized Session error!", e);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Represents content from the assistant in a Qwen Code session.
|
||||
*
|
||||
* @param <C> The type of content
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public interface AssistantContent<C> {
|
||||
/**
|
||||
* Gets the type of the assistant content.
|
||||
*
|
||||
* @return The type of the assistant content
|
||||
*/
|
||||
String getType();
|
||||
|
||||
/**
|
||||
* Gets the actual content from the assistant.
|
||||
*
|
||||
* @return The content from the assistant
|
||||
*/
|
||||
C getContentOfAssistant();
|
||||
|
||||
/**
|
||||
* Gets the message ID associated with this content.
|
||||
*
|
||||
* @return The message ID
|
||||
*/
|
||||
String getMessageId();
|
||||
|
||||
/**
|
||||
* Represents text content from the assistant.
|
||||
*/
|
||||
interface TextAssistantContent extends AssistantContent<String> {
|
||||
/**
|
||||
* Gets the text content.
|
||||
*
|
||||
* @return The text content
|
||||
*/
|
||||
String getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents thinking content from the assistant.
|
||||
*/
|
||||
interface ThingkingAssistantContent extends AssistantContent<String> {
|
||||
/**
|
||||
* Gets the thinking content.
|
||||
*
|
||||
* @return The thinking content
|
||||
*/
|
||||
String getThinking();
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents tool use content from the assistant.
|
||||
*/
|
||||
interface ToolUseAssistantContent extends AssistantContent<Map<String, Object>> {
|
||||
/**
|
||||
* Gets the tool input.
|
||||
*
|
||||
* @return The tool input
|
||||
*/
|
||||
Map<String, Object> getInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents tool result content from the assistant.
|
||||
*/
|
||||
interface ToolResultAssistantContent extends AssistantContent<String> {
|
||||
/**
|
||||
* Gets whether the tool result indicates an error.
|
||||
*
|
||||
* @return Whether the tool result indicates an error
|
||||
*/
|
||||
Boolean getIsError();
|
||||
|
||||
/**
|
||||
* Gets the tool result content.
|
||||
*
|
||||
* @return The tool result content
|
||||
*/
|
||||
String getContent();
|
||||
|
||||
/**
|
||||
* Gets the tool use ID.
|
||||
*
|
||||
* @return The tool use ID
|
||||
*/
|
||||
String getToolUseId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
|
||||
/**
|
||||
* Represents usage information for an assistant message.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public class AssistantUsage {
|
||||
/**
|
||||
* The ID of the message.
|
||||
*/
|
||||
String messageId;
|
||||
/**
|
||||
* The usage information.
|
||||
*/
|
||||
Usage usage;
|
||||
|
||||
/**
|
||||
* Gets the message ID.
|
||||
*
|
||||
* @return The message ID
|
||||
*/
|
||||
public String getMessageId() {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message ID.
|
||||
*
|
||||
* @param messageId The message ID
|
||||
*/
|
||||
public void setMessageId(String messageId) {
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the usage information.
|
||||
*
|
||||
* @return The usage information
|
||||
*/
|
||||
public Usage getUsage() {
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the usage information.
|
||||
*
|
||||
* @param usage The usage information
|
||||
*/
|
||||
public void setUsage(Usage usage) {
|
||||
this.usage = usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new AssistantUsage instance.
|
||||
*
|
||||
* @param messageId The message ID
|
||||
* @param usage The usage information
|
||||
*/
|
||||
public AssistantUsage(String messageId, Usage usage) {
|
||||
this.messageId = messageId;
|
||||
this.usage = usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>toString.</p>
|
||||
*
|
||||
* @return a {@link java.lang.String} object.
|
||||
*/
|
||||
public String toString() {
|
||||
return JSON.toJSONString(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
|
||||
/**
|
||||
* Represents a permission denial from the CLI.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public class CLIPermissionDenial {
|
||||
/**
|
||||
* The name of the denied tool.
|
||||
*/
|
||||
@JSONField(name = "tool_name")
|
||||
private String toolName;
|
||||
|
||||
/**
|
||||
* The ID of the denied tool use.
|
||||
*/
|
||||
@JSONField(name = "tool_use_id")
|
||||
private String toolUseId;
|
||||
|
||||
/**
|
||||
* The input for the denied tool.
|
||||
*/
|
||||
@JSONField(name = "tool_input")
|
||||
private Object toolInput;
|
||||
|
||||
/**
|
||||
* Gets the name of the denied tool.
|
||||
*
|
||||
* @return The name of the denied tool
|
||||
*/
|
||||
public String getToolName() {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name of the denied tool.
|
||||
*
|
||||
* @param toolName The name of the denied tool
|
||||
*/
|
||||
public void setToolName(String toolName) {
|
||||
this.toolName = toolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ID of the denied tool use.
|
||||
*
|
||||
* @return The ID of the denied tool use
|
||||
*/
|
||||
public String getToolUseId() {
|
||||
return toolUseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the ID of the denied tool use.
|
||||
*
|
||||
* @param toolUseId The ID of the denied tool use
|
||||
*/
|
||||
public void setToolUseId(String toolUseId) {
|
||||
this.toolUseId = toolUseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the input for the denied tool.
|
||||
*
|
||||
* @return The input for the denied tool
|
||||
*/
|
||||
public Object getToolInput() {
|
||||
return toolInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the input for the denied tool.
|
||||
*
|
||||
* @param toolInput The input for the denied tool
|
||||
*/
|
||||
public void setToolInput(Object toolInput) {
|
||||
this.toolInput = toolInput;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
|
||||
/**
|
||||
* Represents the capabilities of the Qwen Code CLI.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public class Capabilities {
|
||||
/**
|
||||
* Whether the CLI can handle can_use_tool requests.
|
||||
*/
|
||||
@JSONField(name = "can_handle_can_use_tool")
|
||||
boolean canHandleCanUseTool;
|
||||
|
||||
/**
|
||||
* Whether the CLI can handle hook callbacks.
|
||||
*/
|
||||
@JSONField(name = "can_handle_hook_callback")
|
||||
boolean canHandleHookCallback;
|
||||
|
||||
/**
|
||||
* Whether the CLI can set permission mode.
|
||||
*/
|
||||
@JSONField(name = "can_set_permission_mode")
|
||||
boolean canSetPermissionMode;
|
||||
|
||||
/**
|
||||
* Whether the CLI can set the model.
|
||||
*/
|
||||
@JSONField(name = "can_set_model")
|
||||
boolean canSetModel;
|
||||
|
||||
/**
|
||||
* Whether the CLI can handle MCP messages.
|
||||
*/
|
||||
@JSONField(name = "can_handle_mcp_message")
|
||||
boolean canHandleMcpMessage;
|
||||
|
||||
/**
|
||||
* Checks if the CLI can handle can_use_tool requests.
|
||||
*
|
||||
* @return true if the CLI can handle can_use_tool requests, false otherwise
|
||||
*/
|
||||
public boolean isCanHandleCanUseTool() {
|
||||
return canHandleCanUseTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the CLI can handle can_use_tool requests.
|
||||
*
|
||||
* @param canHandleCanUseTool Whether the CLI can handle can_use_tool requests
|
||||
*/
|
||||
public void setCanHandleCanUseTool(boolean canHandleCanUseTool) {
|
||||
this.canHandleCanUseTool = canHandleCanUseTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the CLI can handle hook callbacks.
|
||||
*
|
||||
* @return true if the CLI can handle hook callbacks, false otherwise
|
||||
*/
|
||||
public boolean isCanHandleHookCallback() {
|
||||
return canHandleHookCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the CLI can handle hook callbacks.
|
||||
*
|
||||
* @param canHandleHookCallback Whether the CLI can handle hook callbacks
|
||||
*/
|
||||
public void setCanHandleHookCallback(boolean canHandleHookCallback) {
|
||||
this.canHandleHookCallback = canHandleHookCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the CLI can set permission mode.
|
||||
*
|
||||
* @return true if the CLI can set permission mode, false otherwise
|
||||
*/
|
||||
public boolean isCanSetPermissionMode() {
|
||||
return canSetPermissionMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the CLI can set permission mode.
|
||||
*
|
||||
* @param canSetPermissionMode Whether the CLI can set permission mode
|
||||
*/
|
||||
public void setCanSetPermissionMode(boolean canSetPermissionMode) {
|
||||
this.canSetPermissionMode = canSetPermissionMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the CLI can set the model.
|
||||
*
|
||||
* @return true if the CLI can set the model, false otherwise
|
||||
*/
|
||||
public boolean isCanSetModel() {
|
||||
return canSetModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the CLI can set the model.
|
||||
*
|
||||
* @param canSetModel Whether the CLI can set the model
|
||||
*/
|
||||
public void setCanSetModel(boolean canSetModel) {
|
||||
this.canSetModel = canSetModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the CLI can handle MCP messages.
|
||||
*
|
||||
* @return true if the CLI can handle MCP messages, false otherwise
|
||||
*/
|
||||
public boolean isCanHandleMcpMessage() {
|
||||
return canHandleMcpMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the CLI can handle MCP messages.
|
||||
*
|
||||
* @param canHandleMcpMessage Whether the CLI can handle MCP messages
|
||||
*/
|
||||
public void setCanHandleMcpMessage(boolean canHandleMcpMessage) {
|
||||
this.canHandleMcpMessage = canHandleMcpMessage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
|
||||
/**
|
||||
* Extends the Usage class with additional usage information.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public class ExtendedUsage extends Usage {
|
||||
/**
|
||||
* Server tool use information.
|
||||
*/
|
||||
@JSONField(name = "server_tool_use")
|
||||
private ServerToolUse serverToolUse;
|
||||
|
||||
/**
|
||||
* Service tier information.
|
||||
*/
|
||||
@JSONField(name = "service_tier")
|
||||
private String serviceTier;
|
||||
|
||||
/**
|
||||
* Cache creation information.
|
||||
*/
|
||||
@JSONField(name = "cache_creation")
|
||||
private CacheCreation cacheCreation;
|
||||
|
||||
/**
|
||||
* Gets the server tool use information.
|
||||
*
|
||||
* @return The server tool use information
|
||||
*/
|
||||
public ServerToolUse getServerToolUse() {
|
||||
return serverToolUse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the server tool use information.
|
||||
*
|
||||
* @param serverToolUse The server tool use information
|
||||
*/
|
||||
public void setServerToolUse(ServerToolUse serverToolUse) {
|
||||
this.serverToolUse = serverToolUse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the service tier information.
|
||||
*
|
||||
* @return The service tier information
|
||||
*/
|
||||
public String getServiceTier() {
|
||||
return serviceTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the service tier information.
|
||||
*
|
||||
* @param serviceTier The service tier information
|
||||
*/
|
||||
public void setServiceTier(String serviceTier) {
|
||||
this.serviceTier = serviceTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cache creation information.
|
||||
*
|
||||
* @return The cache creation information
|
||||
*/
|
||||
public CacheCreation getCacheCreation() {
|
||||
return cacheCreation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cache creation information.
|
||||
*
|
||||
* @param cacheCreation The cache creation information
|
||||
*/
|
||||
public void setCacheCreation(CacheCreation cacheCreation) {
|
||||
this.cacheCreation = cacheCreation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents server tool use information.
|
||||
*/
|
||||
public static class ServerToolUse {
|
||||
/**
|
||||
* Number of web search requests.
|
||||
*/
|
||||
@JSONField(name = "web_search_requests")
|
||||
private int webSearchRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents cache creation information.
|
||||
*/
|
||||
public static class CacheCreation {
|
||||
/**
|
||||
* Number of ephemeral 1-hour input tokens.
|
||||
*/
|
||||
@JSONField(name = "ephemeral_1h_input_tokens")
|
||||
private int ephemeral1hInputTokens;
|
||||
|
||||
/**
|
||||
* Number of ephemeral 5-minute input tokens.
|
||||
*/
|
||||
@JSONField(name = "ephemeral_5m_input_tokens")
|
||||
private int ephemeral5mInputTokens;
|
||||
|
||||
/**
|
||||
* Gets the number of ephemeral 1-hour input tokens.
|
||||
*
|
||||
* @return The number of ephemeral 1-hour input tokens
|
||||
*/
|
||||
public int getEphemeral1hInputTokens() {
|
||||
return ephemeral1hInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of ephemeral 1-hour input tokens.
|
||||
*
|
||||
* @param ephemeral1hInputTokens The number of ephemeral 1-hour input tokens
|
||||
*/
|
||||
public void setEphemeral1hInputTokens(int ephemeral1hInputTokens) {
|
||||
this.ephemeral1hInputTokens = ephemeral1hInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of ephemeral 5-minute input tokens.
|
||||
*
|
||||
* @return The number of ephemeral 5-minute input tokens
|
||||
*/
|
||||
public int getEphemeral5mInputTokens() {
|
||||
return ephemeral5mInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of ephemeral 5-minute input tokens.
|
||||
*
|
||||
* @param ephemeral5mInputTokens The number of ephemeral 5-minute input tokens
|
||||
*/
|
||||
public void setEphemeral5mInputTokens(int ephemeral5mInputTokens) {
|
||||
this.ephemeral5mInputTokens = ephemeral5mInputTokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data;
|
||||
|
||||
/**
|
||||
* Configuration for initializing the CLI.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public class InitializeConfig {
|
||||
/**
|
||||
* Hooks configuration.
|
||||
*/
|
||||
String hooks;
|
||||
/**
|
||||
* SDK MCP servers configuration.
|
||||
*/
|
||||
String sdkMcpServers;
|
||||
/**
|
||||
* MCP servers configuration.
|
||||
*/
|
||||
String mcpServers;
|
||||
/**
|
||||
* Agents configuration.
|
||||
*/
|
||||
String agents;
|
||||
|
||||
/**
|
||||
* Gets the hooks configuration.
|
||||
*
|
||||
* @return The hooks configuration
|
||||
*/
|
||||
public String getHooks() {
|
||||
return hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the hooks configuration.
|
||||
*
|
||||
* @param hooks The hooks configuration
|
||||
*/
|
||||
public void setHooks(String hooks) {
|
||||
this.hooks = hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the SDK MCP servers configuration.
|
||||
*
|
||||
* @return The SDK MCP servers configuration
|
||||
*/
|
||||
public String getSdkMcpServers() {
|
||||
return sdkMcpServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the SDK MCP servers configuration.
|
||||
*
|
||||
* @param sdkMcpServers The SDK MCP servers configuration
|
||||
*/
|
||||
public void setSdkMcpServers(String sdkMcpServers) {
|
||||
this.sdkMcpServers = sdkMcpServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the MCP servers configuration.
|
||||
*
|
||||
* @return The MCP servers configuration
|
||||
*/
|
||||
public String getMcpServers() {
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the MCP servers configuration.
|
||||
*
|
||||
* @param mcpServers The MCP servers configuration
|
||||
*/
|
||||
public void setMcpServers(String mcpServers) {
|
||||
this.mcpServers = mcpServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the agents configuration.
|
||||
*
|
||||
* @return The agents configuration
|
||||
*/
|
||||
public String getAgents() {
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the agents configuration.
|
||||
*
|
||||
* @param agents The agents configuration
|
||||
*/
|
||||
public void setAgents(String agents) {
|
||||
this.agents = agents;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data;
|
||||
|
||||
/**
|
||||
* Represents usage information for a specific model.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public class ModelUsage {
|
||||
/**
|
||||
* Number of input tokens.
|
||||
*/
|
||||
private int inputTokens;
|
||||
/**
|
||||
* Number of output tokens.
|
||||
*/
|
||||
private int outputTokens;
|
||||
/**
|
||||
* Number of cache read input tokens.
|
||||
*/
|
||||
private int cacheReadInputTokens;
|
||||
/**
|
||||
* Number of cache creation input tokens.
|
||||
*/
|
||||
private int cacheCreationInputTokens;
|
||||
/**
|
||||
* Number of web search requests.
|
||||
*/
|
||||
private int webSearchRequests;
|
||||
/**
|
||||
* Context window size.
|
||||
*/
|
||||
private int contextWindow;
|
||||
|
||||
/**
|
||||
* Gets the number of input tokens.
|
||||
*
|
||||
* @return The number of input tokens
|
||||
*/
|
||||
public int getInputTokens() {
|
||||
return inputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of input tokens.
|
||||
*
|
||||
* @param inputTokens The number of input tokens
|
||||
*/
|
||||
public void setInputTokens(int inputTokens) {
|
||||
this.inputTokens = inputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of output tokens.
|
||||
*
|
||||
* @return The number of output tokens
|
||||
*/
|
||||
public int getOutputTokens() {
|
||||
return outputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of output tokens.
|
||||
*
|
||||
* @param outputTokens The number of output tokens
|
||||
*/
|
||||
public void setOutputTokens(int outputTokens) {
|
||||
this.outputTokens = outputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of cache read input tokens.
|
||||
*
|
||||
* @return The number of cache read input tokens
|
||||
*/
|
||||
public int getCacheReadInputTokens() {
|
||||
return cacheReadInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of cache read input tokens.
|
||||
*
|
||||
* @param cacheReadInputTokens The number of cache read input tokens
|
||||
*/
|
||||
public void setCacheReadInputTokens(int cacheReadInputTokens) {
|
||||
this.cacheReadInputTokens = cacheReadInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of cache creation input tokens.
|
||||
*
|
||||
* @return The number of cache creation input tokens
|
||||
*/
|
||||
public int getCacheCreationInputTokens() {
|
||||
return cacheCreationInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of cache creation input tokens.
|
||||
*
|
||||
* @param cacheCreationInputTokens The number of cache creation input tokens
|
||||
*/
|
||||
public void setCacheCreationInputTokens(int cacheCreationInputTokens) {
|
||||
this.cacheCreationInputTokens = cacheCreationInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of web search requests.
|
||||
*
|
||||
* @return The number of web search requests
|
||||
*/
|
||||
public int getWebSearchRequests() {
|
||||
return webSearchRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of web search requests.
|
||||
*
|
||||
* @param webSearchRequests The number of web search requests
|
||||
*/
|
||||
public void setWebSearchRequests(int webSearchRequests) {
|
||||
this.webSearchRequests = webSearchRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the context window size.
|
||||
*
|
||||
* @return The context window size
|
||||
*/
|
||||
public int getContextWindow() {
|
||||
return contextWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the context window size.
|
||||
*
|
||||
* @param contextWindow The context window size
|
||||
*/
|
||||
public void setContextWindow(int contextWindow) {
|
||||
this.contextWindow = contextWindow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data;
|
||||
|
||||
/**
|
||||
* Represents different permission modes for the CLI.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public enum PermissionMode {
|
||||
/**
|
||||
* Default permission mode.
|
||||
*/
|
||||
DEFAULT("default"),
|
||||
/**
|
||||
* Plan permission mode.
|
||||
*/
|
||||
PLAN("plan"),
|
||||
/**
|
||||
* Auto-edit permission mode.
|
||||
*/
|
||||
AUTO_EDIT("auto-edit"),
|
||||
/**
|
||||
* YOLO permission mode.
|
||||
*/
|
||||
YOLO("yolo");
|
||||
|
||||
private final String value;
|
||||
|
||||
PermissionMode(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the string value of the permission mode.
|
||||
*
|
||||
* @return The string value of the permission mode
|
||||
*/
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the permission mode from its string value.
|
||||
*
|
||||
* @param value The string value
|
||||
* @return The corresponding permission mode
|
||||
*/
|
||||
public static PermissionMode fromValue(String value) {
|
||||
for (PermissionMode mode : PermissionMode.values()) {
|
||||
if (mode.value.equals(value)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown permission mode: " + value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
|
||||
/**
|
||||
* Represents usage information for a message.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public class Usage {
|
||||
/**
|
||||
* Number of input tokens.
|
||||
*/
|
||||
@JSONField(name = "input_tokens")
|
||||
private Integer inputTokens;
|
||||
/**
|
||||
* Number of output tokens.
|
||||
*/
|
||||
@JSONField(name = "output_tokens")
|
||||
private Integer outputTokens;
|
||||
/**
|
||||
* Number of cache creation input tokens.
|
||||
*/
|
||||
@JSONField(name = "cache_creation_input_tokens")
|
||||
private Integer cacheCreationInputTokens;
|
||||
/**
|
||||
* Number of cache read input tokens.
|
||||
*/
|
||||
@JSONField(name = "cache_read_input_tokens")
|
||||
private Integer cacheReadInputTokens;
|
||||
/**
|
||||
* Total number of tokens.
|
||||
*/
|
||||
@JSONField(name = "total_tokens")
|
||||
private Integer totalTokens;
|
||||
|
||||
/**
|
||||
* Gets the number of input tokens.
|
||||
*
|
||||
* @return The number of input tokens
|
||||
*/
|
||||
public Integer getInputTokens() {
|
||||
return inputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of input tokens.
|
||||
*
|
||||
* @param inputTokens The number of input tokens
|
||||
*/
|
||||
public void setInputTokens(Integer inputTokens) {
|
||||
this.inputTokens = inputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of output tokens.
|
||||
*
|
||||
* @return The number of output tokens
|
||||
*/
|
||||
public Integer getOutputTokens() {
|
||||
return outputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of output tokens.
|
||||
*
|
||||
* @param outputTokens The number of output tokens
|
||||
*/
|
||||
public void setOutputTokens(Integer outputTokens) {
|
||||
this.outputTokens = outputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of cache creation input tokens.
|
||||
*
|
||||
* @return The number of cache creation input tokens
|
||||
*/
|
||||
public Integer getCacheCreationInputTokens() {
|
||||
return cacheCreationInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of cache creation input tokens.
|
||||
*
|
||||
* @param cacheCreationInputTokens The number of cache creation input tokens
|
||||
*/
|
||||
public void setCacheCreationInputTokens(Integer cacheCreationInputTokens) {
|
||||
this.cacheCreationInputTokens = cacheCreationInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of cache read input tokens.
|
||||
*
|
||||
* @return The number of cache read input tokens
|
||||
*/
|
||||
public Integer getCacheReadInputTokens() {
|
||||
return cacheReadInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of cache read input tokens.
|
||||
*
|
||||
* @param cacheReadInputTokens The number of cache read input tokens
|
||||
*/
|
||||
public void setCacheReadInputTokens(Integer cacheReadInputTokens) {
|
||||
this.cacheReadInputTokens = cacheReadInputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total number of tokens.
|
||||
*
|
||||
* @return The total number of tokens
|
||||
*/
|
||||
public Integer getTotalTokens() {
|
||||
return totalTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the total number of tokens.
|
||||
*
|
||||
* @param totalTokens The total number of tokens
|
||||
*/
|
||||
public void setTotalTokens(Integer totalTokens) {
|
||||
this.totalTokens = totalTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>toString.</p>
|
||||
*
|
||||
* @return a {@link java.lang.String} object.
|
||||
*/
|
||||
public String toString() {
|
||||
return JSON.toJSONString(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data.behavior;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
|
||||
/**
|
||||
* Represents an allow behavior that permits an operation.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "operation", typeName = "allow")
|
||||
public class Allow extends Behavior {
|
||||
/**
|
||||
* Creates a new Allow instance and sets the behavior to allow.
|
||||
*/
|
||||
public Allow() {
|
||||
super();
|
||||
this.behavior = Operation.allow;
|
||||
}
|
||||
/**
|
||||
* Updated input for the operation.
|
||||
*/
|
||||
Map<String, Object> updatedInput;
|
||||
|
||||
/**
|
||||
* Gets the updated input.
|
||||
*
|
||||
* @return The updated input
|
||||
*/
|
||||
public Map<String, Object> getUpdatedInput() {
|
||||
return updatedInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the updated input.
|
||||
*
|
||||
* @param updatedInput The updated input
|
||||
* @return This instance for method chaining
|
||||
*/
|
||||
public Allow setUpdatedInput(Map<String, Object> updatedInput) {
|
||||
this.updatedInput = updatedInput;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data.behavior;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
|
||||
/**
|
||||
* Base class for behavior objects that define how the CLI should handle requests.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "operation", typeName = "Behavior", seeAlso = {Allow.class, Deny.class})
|
||||
public class Behavior {
|
||||
/**
|
||||
* The behavior operation (allow or deny).
|
||||
*/
|
||||
Operation behavior;
|
||||
|
||||
/**
|
||||
* Gets the behavior operation.
|
||||
*
|
||||
* @return The behavior operation
|
||||
*/
|
||||
public Operation getBehavior() {
|
||||
return behavior;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the behavior operation.
|
||||
*
|
||||
* @param behavior The behavior operation
|
||||
*/
|
||||
public void setBehavior(Operation behavior) {
|
||||
this.behavior = behavior;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the type of operation.
|
||||
*/
|
||||
public enum Operation {
|
||||
/**
|
||||
* Allow the operation.
|
||||
*/
|
||||
allow,
|
||||
/**
|
||||
* Deny the operation.
|
||||
*/
|
||||
deny
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default behavior (deny with message).
|
||||
*
|
||||
* @return The default behavior
|
||||
*/
|
||||
public static Behavior defaultBehavior() {
|
||||
return denyBehavior();
|
||||
}
|
||||
|
||||
public static Behavior denyBehavior() {
|
||||
return new Deny().setMessage("Default Behavior Permission denied");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.data.behavior;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
|
||||
/**
|
||||
* Represents a deny behavior that rejects an operation.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "operation", typeName = "deny")
|
||||
public class Deny extends Behavior {
|
||||
/**
|
||||
* Creates a new Deny instance and sets the behavior to deny.
|
||||
*/
|
||||
public Deny() {
|
||||
super();
|
||||
this.behavior = Operation.deny;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message explaining why the operation was denied.
|
||||
*/
|
||||
String message;
|
||||
|
||||
/**
|
||||
* Gets the denial message.
|
||||
*
|
||||
* @return The denial message
|
||||
*/
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the denial message.
|
||||
*
|
||||
* @param message The denial message
|
||||
* @return This instance for method chaining
|
||||
*/
|
||||
public Deny setMessage(String message) {
|
||||
this.message = message;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message;
|
||||
|
||||
/**
|
||||
* Represents a message in the Qwen Code protocol.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public interface Message {
|
||||
/**
|
||||
* Gets the type of the message.
|
||||
*
|
||||
* @return The type of the message
|
||||
*/
|
||||
String getType();
|
||||
|
||||
/**
|
||||
* Gets the ID of the message.
|
||||
*
|
||||
* @return The ID of the message
|
||||
*/
|
||||
String getMessageId();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
|
||||
/**
|
||||
* Base class for messages in the Qwen Code protocol.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(alphabetic = false, typeKey = "type", typeName = "MessageBase")
|
||||
public class MessageBase implements Message{
|
||||
/**
|
||||
* The type of the message.
|
||||
*/
|
||||
protected String type;
|
||||
|
||||
/**
|
||||
* The ID of the message.
|
||||
*/
|
||||
@JSONField(name = "message_id")
|
||||
protected String messageId;
|
||||
|
||||
/**
|
||||
* <p>toString.</p>
|
||||
*
|
||||
* @return a {@link java.lang.String} object.
|
||||
*/
|
||||
public String toString() {
|
||||
return JSON.toJSONString(this);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the type of the message.
|
||||
*
|
||||
* @param type The type of the message
|
||||
*/
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getMessageId() {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the ID of the message.
|
||||
*
|
||||
* @param messageId The ID of the message
|
||||
*/
|
||||
public void setMessageId(String messageId) {
|
||||
this.messageId = messageId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.CLIPermissionDenial;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.ExtendedUsage;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.Usage;
|
||||
|
||||
/**
|
||||
* Represents a result message from the SDK.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "result")
|
||||
public class SDKResultMessage extends MessageBase {
|
||||
/**
|
||||
* The subtype of the result.
|
||||
*/
|
||||
private String subtype; // 'error_max_turns' | 'error_during_execution'
|
||||
/**
|
||||
* The UUID of the message.
|
||||
*/
|
||||
private String uuid;
|
||||
|
||||
/**
|
||||
* The session ID.
|
||||
*/
|
||||
@JSONField(name = "session_id")
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* Whether the result represents an error.
|
||||
*/
|
||||
@JSONField(name = "is_error")
|
||||
private boolean isError = true;
|
||||
|
||||
/**
|
||||
* Duration in milliseconds.
|
||||
*/
|
||||
@JSONField(name = "duration_ms")
|
||||
private Long durationMs;
|
||||
|
||||
/**
|
||||
* API duration in milliseconds.
|
||||
*/
|
||||
@JSONField(name = "duration_api_ms")
|
||||
private Long durationApiMs;
|
||||
|
||||
/**
|
||||
* Number of turns.
|
||||
*/
|
||||
@JSONField(name = "num_turns")
|
||||
private Integer numTurns;
|
||||
/**
|
||||
* Usage information.
|
||||
*/
|
||||
private ExtendedUsage usage;
|
||||
/**
|
||||
* Model usage information.
|
||||
*/
|
||||
private Map<String, Usage> modelUsage;
|
||||
|
||||
/**
|
||||
* List of permission denials.
|
||||
*/
|
||||
@JSONField(name = "permission_denials")
|
||||
private List<CLIPermissionDenial> permissionDenials;
|
||||
/**
|
||||
* Error information.
|
||||
*/
|
||||
private Error error;
|
||||
|
||||
/**
|
||||
* Creates a new SDKResultMessage instance and sets the type to "result".
|
||||
*/
|
||||
public SDKResultMessage() {
|
||||
super();
|
||||
this.type = "result";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subtype of the result.
|
||||
*
|
||||
* @return The subtype of the result
|
||||
*/
|
||||
public String getSubtype() {
|
||||
return subtype;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the subtype of the result.
|
||||
*
|
||||
* @param subtype The subtype of the result
|
||||
*/
|
||||
public void setSubtype(String subtype) {
|
||||
this.subtype = subtype;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UUID of the message.
|
||||
*
|
||||
* @return The UUID of the message
|
||||
*/
|
||||
public String getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the UUID of the message.
|
||||
*
|
||||
* @param uuid The UUID of the message
|
||||
*/
|
||||
public void setUuid(String uuid) {
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the session ID.
|
||||
*
|
||||
* @return The session ID
|
||||
*/
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the session ID.
|
||||
*
|
||||
* @param sessionId The session ID
|
||||
*/
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the result represents an error.
|
||||
*
|
||||
* @return Whether the result represents an error
|
||||
*/
|
||||
public boolean isError() {
|
||||
return isError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the result represents an error.
|
||||
*
|
||||
* @param error Whether the result represents an error
|
||||
*/
|
||||
public void setError(boolean error) {
|
||||
isError = error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the duration in milliseconds.
|
||||
*
|
||||
* @return The duration in milliseconds
|
||||
*/
|
||||
public Long getDurationMs() {
|
||||
return durationMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the duration in milliseconds.
|
||||
*
|
||||
* @param durationMs The duration in milliseconds
|
||||
*/
|
||||
public void setDurationMs(Long durationMs) {
|
||||
this.durationMs = durationMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the API duration in milliseconds.
|
||||
*
|
||||
* @return The API duration in milliseconds
|
||||
*/
|
||||
public Long getDurationApiMs() {
|
||||
return durationApiMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the API duration in milliseconds.
|
||||
*
|
||||
* @param durationApiMs The API duration in milliseconds
|
||||
*/
|
||||
public void setDurationApiMs(Long durationApiMs) {
|
||||
this.durationApiMs = durationApiMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of turns.
|
||||
*
|
||||
* @return The number of turns
|
||||
*/
|
||||
public Integer getNumTurns() {
|
||||
return numTurns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of turns.
|
||||
*
|
||||
* @param numTurns The number of turns
|
||||
*/
|
||||
public void setNumTurns(Integer numTurns) {
|
||||
this.numTurns = numTurns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the usage information.
|
||||
*
|
||||
* @return The usage information
|
||||
*/
|
||||
public ExtendedUsage getUsage() {
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the usage information.
|
||||
*
|
||||
* @param usage The usage information
|
||||
*/
|
||||
public void setUsage(ExtendedUsage usage) {
|
||||
this.usage = usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the model usage information.
|
||||
*
|
||||
* @return The model usage information
|
||||
*/
|
||||
public Map<String, Usage> getModelUsage() {
|
||||
return modelUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the model usage information.
|
||||
*
|
||||
* @param modelUsage The model usage information
|
||||
*/
|
||||
public void setModelUsage(Map<String, Usage> modelUsage) {
|
||||
this.modelUsage = modelUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of permission denials.
|
||||
*
|
||||
* @return The list of permission denials
|
||||
*/
|
||||
public List<CLIPermissionDenial> getPermissionDenials() {
|
||||
return permissionDenials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the list of permission denials.
|
||||
*
|
||||
* @param permissionDenials The list of permission denials
|
||||
*/
|
||||
public void setPermissionDenials(List<CLIPermissionDenial> permissionDenials) {
|
||||
this.permissionDenials = permissionDenials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the error information.
|
||||
*
|
||||
* @return The error information
|
||||
*/
|
||||
public Error getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the error information.
|
||||
*
|
||||
* @param error The error information
|
||||
*/
|
||||
public void setError(Error error) {
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents error information.
|
||||
*/
|
||||
public static class Error {
|
||||
/**
|
||||
* Error type.
|
||||
*/
|
||||
private String type;
|
||||
/**
|
||||
* Error message.
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* Gets the error type.
|
||||
*
|
||||
* @return The error type
|
||||
*/
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the error type.
|
||||
*
|
||||
* @param type The error type
|
||||
*/
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the error message.
|
||||
*
|
||||
* @return The error message
|
||||
*/
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the error message.
|
||||
*
|
||||
* @param message The error message
|
||||
*/
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
|
||||
/**
|
||||
* Represents a system message from the SDK.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "system")
|
||||
public class SDKSystemMessage extends MessageBase {
|
||||
/**
|
||||
* The subtype of the system message.
|
||||
*/
|
||||
private String subtype;
|
||||
/**
|
||||
* The UUID of the message.
|
||||
*/
|
||||
private String uuid;
|
||||
/**
|
||||
* The session ID.
|
||||
*/
|
||||
@JSONField(name = "session_id")
|
||||
private String sessionId;
|
||||
/**
|
||||
* Additional data.
|
||||
*/
|
||||
private Object data;
|
||||
/**
|
||||
* Current working directory.
|
||||
*/
|
||||
private String cwd;
|
||||
/**
|
||||
* List of available tools.
|
||||
*/
|
||||
private List<String> tools;
|
||||
/**
|
||||
* List of MCP servers.
|
||||
*/
|
||||
@JSONField(name = "mcp_servers")
|
||||
private List<McpServer> mcpServers;
|
||||
/**
|
||||
* Model information.
|
||||
*/
|
||||
private String model;
|
||||
/**
|
||||
* Permission mode.
|
||||
*/
|
||||
@JSONField(name = "permission_mode")
|
||||
private String permissionMode;
|
||||
/**
|
||||
* Available slash commands.
|
||||
*/
|
||||
@JSONField(name = "slash_commands")
|
||||
private List<String> slashCommands;
|
||||
/**
|
||||
* Qwen Code version.
|
||||
*/
|
||||
@JSONField(name = "qwen_code_version")
|
||||
private String qwenCodeVersion;
|
||||
/**
|
||||
* Output style.
|
||||
*/
|
||||
@JSONField(name = "output_style")
|
||||
private String outputStyle;
|
||||
/**
|
||||
* Available agents.
|
||||
*/
|
||||
private List<String> agents;
|
||||
/**
|
||||
* Available skills.
|
||||
*/
|
||||
private List<String> skills;
|
||||
/**
|
||||
* Capabilities information.
|
||||
*/
|
||||
private Map<String, Object> capabilities;
|
||||
/**
|
||||
* Compact metadata.
|
||||
*/
|
||||
@JSONField(name = "compact_metadata")
|
||||
private CompactMetadata compactMetadata;
|
||||
|
||||
/**
|
||||
* Creates a new SDKSystemMessage instance and sets the type to "system".
|
||||
*/
|
||||
public SDKSystemMessage() {
|
||||
super();
|
||||
this.type = "system";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subtype of the system message.
|
||||
*
|
||||
* @return The subtype of the system message
|
||||
*/
|
||||
public String getSubtype() {
|
||||
return subtype;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the subtype of the system message.
|
||||
*
|
||||
* @param subtype The subtype of the system message
|
||||
*/
|
||||
public void setSubtype(String subtype) {
|
||||
this.subtype = subtype;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UUID of the message.
|
||||
*
|
||||
* @return The UUID of the message
|
||||
*/
|
||||
public String getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the UUID of the message.
|
||||
*
|
||||
* @param uuid The UUID of the message
|
||||
*/
|
||||
public void setUuid(String uuid) {
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the session ID.
|
||||
*
|
||||
* @return The session ID
|
||||
*/
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the session ID.
|
||||
*
|
||||
* @param sessionId The session ID
|
||||
*/
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the additional data.
|
||||
*
|
||||
* @return The additional data
|
||||
*/
|
||||
public Object getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the additional data.
|
||||
*
|
||||
* @param data The additional data
|
||||
*/
|
||||
public void setData(Object data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current working directory.
|
||||
*
|
||||
* @return The current working directory
|
||||
*/
|
||||
public String getCwd() {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current working directory.
|
||||
*
|
||||
* @param cwd The current working directory
|
||||
*/
|
||||
public void setCwd(String cwd) {
|
||||
this.cwd = cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of available tools.
|
||||
*
|
||||
* @return The list of available tools
|
||||
*/
|
||||
public List<String> getTools() {
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the list of available tools.
|
||||
*
|
||||
* @param tools The list of available tools
|
||||
*/
|
||||
public void setTools(List<String> tools) {
|
||||
this.tools = tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of MCP servers.
|
||||
*
|
||||
* @return The list of MCP servers
|
||||
*/
|
||||
public List<McpServer> getMcpServers() {
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the list of MCP servers.
|
||||
*
|
||||
* @param mcpServers The list of MCP servers
|
||||
*/
|
||||
public void setMcpServers(List<McpServer> mcpServers) {
|
||||
this.mcpServers = mcpServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the model information.
|
||||
*
|
||||
* @return The model information
|
||||
*/
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the model information.
|
||||
*
|
||||
* @param model The model information
|
||||
*/
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the permission mode.
|
||||
*
|
||||
* @return The permission mode
|
||||
*/
|
||||
public String getPermissionMode() {
|
||||
return permissionMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the permission mode.
|
||||
*
|
||||
* @param permissionMode The permission mode
|
||||
*/
|
||||
public void setPermissionMode(String permissionMode) {
|
||||
this.permissionMode = permissionMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the available slash commands.
|
||||
*
|
||||
* @return The available slash commands
|
||||
*/
|
||||
public List<String> getSlashCommands() {
|
||||
return slashCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the available slash commands.
|
||||
*
|
||||
* @param slashCommands The available slash commands
|
||||
*/
|
||||
public void setSlashCommands(List<String> slashCommands) {
|
||||
this.slashCommands = slashCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Qwen Code version.
|
||||
*
|
||||
* @return The Qwen Code version
|
||||
*/
|
||||
public String getQwenCodeVersion() {
|
||||
return qwenCodeVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Qwen Code version.
|
||||
*
|
||||
* @param qwenCodeVersion The Qwen Code version
|
||||
*/
|
||||
public void setQwenCodeVersion(String qwenCodeVersion) {
|
||||
this.qwenCodeVersion = qwenCodeVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the output style.
|
||||
*
|
||||
* @return The output style
|
||||
*/
|
||||
public String getOutputStyle() {
|
||||
return outputStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the output style.
|
||||
*
|
||||
* @param outputStyle The output style
|
||||
*/
|
||||
public void setOutputStyle(String outputStyle) {
|
||||
this.outputStyle = outputStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the available agents.
|
||||
*
|
||||
* @return The available agents
|
||||
*/
|
||||
public List<String> getAgents() {
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the available agents.
|
||||
*
|
||||
* @param agents The available agents
|
||||
*/
|
||||
public void setAgents(List<String> agents) {
|
||||
this.agents = agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the available skills.
|
||||
*
|
||||
* @return The available skills
|
||||
*/
|
||||
public List<String> getSkills() {
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the available skills.
|
||||
*
|
||||
* @param skills The available skills
|
||||
*/
|
||||
public void setSkills(List<String> skills) {
|
||||
this.skills = skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the capabilities information.
|
||||
*
|
||||
* @return The capabilities information
|
||||
*/
|
||||
public Map<String, Object> getCapabilities() {
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the capabilities information.
|
||||
*
|
||||
* @param capabilities The capabilities information
|
||||
*/
|
||||
public void setCapabilities(Map<String, Object> capabilities) {
|
||||
this.capabilities = capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the compact metadata.
|
||||
*
|
||||
* @return The compact metadata
|
||||
*/
|
||||
public CompactMetadata getCompactMetadata() {
|
||||
return compactMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the compact metadata.
|
||||
*
|
||||
* @param compactMetadata The compact metadata
|
||||
*/
|
||||
public void setCompactMetadata(CompactMetadata compactMetadata) {
|
||||
this.compactMetadata = compactMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents MCP server information.
|
||||
*/
|
||||
public static class McpServer {
|
||||
/**
|
||||
* Server name.
|
||||
*/
|
||||
private String name;
|
||||
/**
|
||||
* Server status.
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* Gets the server name.
|
||||
*
|
||||
* @return The server name
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the server name.
|
||||
*
|
||||
* @param name The server name
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the server status.
|
||||
*
|
||||
* @return The server status
|
||||
*/
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the server status.
|
||||
*
|
||||
* @param status The server status
|
||||
*/
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents compact metadata.
|
||||
*/
|
||||
public static class CompactMetadata {
|
||||
/**
|
||||
* Trigger information.
|
||||
*/
|
||||
private String trigger;
|
||||
|
||||
/**
|
||||
* Pre-tokens information.
|
||||
*/
|
||||
@JSONField(name = "pre_tokens")
|
||||
private Integer preTokens;
|
||||
|
||||
/**
|
||||
* Gets the trigger information.
|
||||
*
|
||||
* @return The trigger information
|
||||
*/
|
||||
public String getTrigger() {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the trigger information.
|
||||
*
|
||||
* @param trigger The trigger information
|
||||
*/
|
||||
public void setTrigger(String trigger) {
|
||||
this.trigger = trigger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the pre-tokens information.
|
||||
*
|
||||
* @return The pre-tokens information
|
||||
*/
|
||||
public Integer getPreTokens() {
|
||||
return preTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the pre-tokens information.
|
||||
*
|
||||
* @param preTokens The pre-tokens information
|
||||
*/
|
||||
public void setPreTokens(Integer preTokens) {
|
||||
this.preTokens = preTokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
|
||||
/**
|
||||
* Represents a user message in the SDK protocol.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "user")
|
||||
public class SDKUserMessage extends MessageBase {
|
||||
/**
|
||||
* The UUID of the message.
|
||||
*/
|
||||
private String uuid;
|
||||
|
||||
/**
|
||||
* The session ID.
|
||||
*/
|
||||
@JSONField(name = "session_id")
|
||||
private String sessionId;
|
||||
/**
|
||||
* The API user message.
|
||||
*/
|
||||
private final APIUserMessage message = new APIUserMessage();
|
||||
|
||||
/**
|
||||
* The parent tool use ID.
|
||||
*/
|
||||
@JSONField(name = "parent_tool_use_id")
|
||||
private String parentToolUseId;
|
||||
/**
|
||||
* Additional options.
|
||||
*/
|
||||
private Map<String, String> options;
|
||||
|
||||
/**
|
||||
* Creates a new SDKUserMessage instance and sets the type to "user".
|
||||
*/
|
||||
public SDKUserMessage() {
|
||||
super();
|
||||
this.setType("user");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UUID of the message.
|
||||
*
|
||||
* @return The UUID of the message
|
||||
*/
|
||||
public String getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the UUID of the message.
|
||||
*
|
||||
* @param uuid The UUID of the message
|
||||
*/
|
||||
public void setUuid(String uuid) {
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the session ID.
|
||||
*
|
||||
* @return The session ID
|
||||
*/
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the session ID.
|
||||
*
|
||||
* @param sessionId The session ID
|
||||
* @return This instance for method chaining
|
||||
*/
|
||||
public SDKUserMessage setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the content of the message.
|
||||
*
|
||||
* @param content The content of the message
|
||||
* @return This instance for method chaining
|
||||
*/
|
||||
public SDKUserMessage setContent(String content) {
|
||||
message.setContent(content);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the content of the message.
|
||||
*
|
||||
* @return The content of the message
|
||||
*/
|
||||
public String getContent() {
|
||||
return message.getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent tool use ID.
|
||||
*
|
||||
* @return The parent tool use ID
|
||||
*/
|
||||
public String getParentToolUseId() {
|
||||
return parentToolUseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the parent tool use ID.
|
||||
*
|
||||
* @param parentToolUseId The parent tool use ID
|
||||
* @return This instance for method chaining
|
||||
*/
|
||||
public SDKUserMessage setParentToolUseId(String parentToolUseId) {
|
||||
this.parentToolUseId = parentToolUseId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the additional options.
|
||||
*
|
||||
* @return The additional options
|
||||
*/
|
||||
public Map<String, String> getOptions() {
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the additional options.
|
||||
*
|
||||
* @param options The additional options
|
||||
* @return This instance for method chaining
|
||||
*/
|
||||
public SDKUserMessage setOptions(Map<String, String> options) {
|
||||
this.options = options;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the API user message.
|
||||
*/
|
||||
public static class APIUserMessage {
|
||||
/**
|
||||
* User role.
|
||||
*/
|
||||
private String role = "user";
|
||||
/**
|
||||
* Message content.
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* Gets the user role.
|
||||
*
|
||||
* @return The user role
|
||||
*/
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user role.
|
||||
*
|
||||
* @param role The user role
|
||||
*/
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message content.
|
||||
*
|
||||
* @return The message content
|
||||
*/
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message content.
|
||||
*
|
||||
* @param content The message content
|
||||
*/
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.Usage;
|
||||
import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock;
|
||||
|
||||
/**
|
||||
* Represents an API assistant message.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public class APIAssistantMessage {
|
||||
/**
|
||||
* Message ID.
|
||||
*/
|
||||
private String id;
|
||||
/**
|
||||
* Message type.
|
||||
*/
|
||||
private String type = "message";
|
||||
/**
|
||||
* Message role.
|
||||
*/
|
||||
private String role = "assistant";
|
||||
/**
|
||||
* Message model.
|
||||
*/
|
||||
private String model;
|
||||
/**
|
||||
* Message content.
|
||||
*/
|
||||
private List<ContentBlock<?>> content;
|
||||
|
||||
/**
|
||||
* Stop reason.
|
||||
*/
|
||||
@JSONField(name = "stop_reason")
|
||||
private String stopReason;
|
||||
/**
|
||||
* Usage information.
|
||||
*/
|
||||
private Usage usage;
|
||||
|
||||
/**
|
||||
* Gets the message ID.
|
||||
*
|
||||
* @return The message ID
|
||||
*/
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message ID.
|
||||
*
|
||||
* @param id The message ID
|
||||
*/
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message type.
|
||||
*
|
||||
* @return The message type
|
||||
*/
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message type.
|
||||
*
|
||||
* @param type The message type
|
||||
*/
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message role.
|
||||
*
|
||||
* @return The message role
|
||||
*/
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message role.
|
||||
*
|
||||
* @param role The message role
|
||||
*/
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message model.
|
||||
*
|
||||
* @return The message model
|
||||
*/
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message model.
|
||||
*
|
||||
* @param model The message model
|
||||
*/
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the stop reason.
|
||||
*
|
||||
* @return The stop reason
|
||||
*/
|
||||
public String getStopReason() {
|
||||
return stopReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the stop reason.
|
||||
*
|
||||
* @param stopReason The stop reason
|
||||
*/
|
||||
public void setStopReason(String stopReason) {
|
||||
this.stopReason = stopReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the usage information.
|
||||
*
|
||||
* @return The usage information
|
||||
*/
|
||||
public Usage getUsage() {
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the usage information.
|
||||
*
|
||||
* @param usage The usage information
|
||||
*/
|
||||
public void setUsage(Usage usage) {
|
||||
this.usage = usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message content.
|
||||
*
|
||||
* @return The message content
|
||||
*/
|
||||
public List<ContentBlock<?>> getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message content.
|
||||
*
|
||||
* @param content The message content
|
||||
*/
|
||||
public void setContent(List<ContentBlock<?>> content) {
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
import com.alibaba.qwen.code.cli.protocol.message.MessageBase;
|
||||
|
||||
/**
|
||||
* Represents an SDK assistant message.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "assistant")
|
||||
public class SDKAssistantMessage extends MessageBase {
|
||||
/**
|
||||
* The UUID of the message.
|
||||
*/
|
||||
private String uuid;
|
||||
|
||||
/**
|
||||
* The session ID.
|
||||
*/
|
||||
@JSONField(name = "session_id")
|
||||
private String sessionId;
|
||||
/**
|
||||
* The API assistant message.
|
||||
*/
|
||||
private APIAssistantMessage message;
|
||||
|
||||
/**
|
||||
* The parent tool use ID.
|
||||
*/
|
||||
@JSONField(name = "parent_tool_use_id")
|
||||
private String parentToolUseId;
|
||||
|
||||
/**
|
||||
* Creates a new SDKAssistantMessage instance and sets the type to "assistant".
|
||||
*/
|
||||
public SDKAssistantMessage() {
|
||||
super();
|
||||
this.type = "assistant";
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getMessageId() {
|
||||
return this.getUuid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UUID of the message.
|
||||
*
|
||||
* @return The UUID of the message
|
||||
*/
|
||||
public String getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the UUID of the message.
|
||||
*
|
||||
* @param uuid The UUID of the message
|
||||
*/
|
||||
public void setUuid(String uuid) {
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the session ID.
|
||||
*
|
||||
* @return The session ID
|
||||
*/
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the session ID.
|
||||
*
|
||||
* @param sessionId The session ID
|
||||
*/
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the API assistant message.
|
||||
*
|
||||
* @return The API assistant message
|
||||
*/
|
||||
public APIAssistantMessage getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the API assistant message.
|
||||
*
|
||||
* @param message The API assistant message
|
||||
*/
|
||||
public void setMessage(APIAssistantMessage message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent tool use ID.
|
||||
*
|
||||
* @return The parent tool use ID
|
||||
*/
|
||||
public String getParentToolUseId() {
|
||||
return parentToolUseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the parent tool use ID.
|
||||
*
|
||||
* @param parentToolUseId The parent tool use ID
|
||||
*/
|
||||
public void setParentToolUseId(String parentToolUseId) {
|
||||
this.parentToolUseId = parentToolUseId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
import com.alibaba.qwen.code.cli.protocol.message.MessageBase;
|
||||
import com.alibaba.qwen.code.cli.protocol.message.assistant.event.StreamEvent;
|
||||
|
||||
/**
|
||||
* Represents a partial assistant message during streaming.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "stream_event")
|
||||
public class SDKPartialAssistantMessage extends MessageBase {
|
||||
/**
|
||||
* The UUID of the message.
|
||||
*/
|
||||
private String uuid;
|
||||
|
||||
/**
|
||||
* The session ID.
|
||||
*/
|
||||
@JSONField(name = "session_id")
|
||||
private String sessionId;
|
||||
/**
|
||||
* The stream event.
|
||||
*/
|
||||
private StreamEvent event;
|
||||
|
||||
/**
|
||||
* The parent tool use ID.
|
||||
*/
|
||||
@JSONField(name = "parent_tool_use_id")
|
||||
private String parentToolUseId;
|
||||
|
||||
/**
|
||||
* Creates a new SDKPartialAssistantMessage instance and sets the type to "stream_event".
|
||||
*/
|
||||
public SDKPartialAssistantMessage() {
|
||||
super();
|
||||
this.type = "stream_event";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UUID of the message.
|
||||
*
|
||||
* @return The UUID of the message
|
||||
*/
|
||||
public String getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the UUID of the message.
|
||||
*
|
||||
* @param uuid The UUID of the message
|
||||
*/
|
||||
public void setUuid(String uuid) {
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the session ID.
|
||||
*
|
||||
* @return The session ID
|
||||
*/
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the session ID.
|
||||
*
|
||||
* @param sessionId The session ID
|
||||
*/
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the stream event.
|
||||
*
|
||||
* @return The stream event
|
||||
*/
|
||||
public StreamEvent getEvent() {
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the stream event.
|
||||
*
|
||||
* @param event The stream event
|
||||
*/
|
||||
public void setEvent(StreamEvent event) {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent tool use ID.
|
||||
*
|
||||
* @return The parent tool use ID
|
||||
*/
|
||||
public String getParentToolUseId() {
|
||||
return parentToolUseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the parent tool use ID.
|
||||
*
|
||||
* @param parentToolUseId The parent tool use ID
|
||||
*/
|
||||
public void setParentToolUseId(String parentToolUseId) {
|
||||
this.parentToolUseId = parentToolUseId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.block;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
|
||||
/**
|
||||
* Represents an annotation for a content block.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
public class Annotation {
|
||||
/**
|
||||
* The annotation type.
|
||||
*/
|
||||
@JSONField(name = "type")
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* The annotation value.
|
||||
*/
|
||||
@JSONField(name = "value")
|
||||
private String value;
|
||||
|
||||
/**
|
||||
* Gets the annotation type.
|
||||
*
|
||||
* @return The annotation type
|
||||
*/
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the annotation type.
|
||||
*
|
||||
* @param type The annotation type
|
||||
*/
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the annotation value.
|
||||
*
|
||||
* @return The annotation value
|
||||
*/
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the annotation value.
|
||||
*
|
||||
* @param value The annotation value
|
||||
*/
|
||||
public void setValue(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.block;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent;
|
||||
|
||||
/**
|
||||
* Abstract base class for content blocks in assistant messages.
|
||||
*
|
||||
* @param <C> The type of content
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "ContentBlock", seeAlso = { TextBlock.class, ToolResultBlock.class, ThinkingBlock.class, ToolUseBlock.class })
|
||||
public abstract class ContentBlock<C> implements AssistantContent<C> {
|
||||
/**
|
||||
* The type of the content block.
|
||||
*/
|
||||
protected String type;
|
||||
/**
|
||||
* List of annotations.
|
||||
*/
|
||||
protected List<Annotation> annotations;
|
||||
/**
|
||||
* The message ID.
|
||||
*/
|
||||
protected String messageId;
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the type of the content block.
|
||||
*
|
||||
* @param type The type of the content block
|
||||
*/
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of annotations.
|
||||
*
|
||||
* @return The list of annotations
|
||||
*/
|
||||
public List<Annotation> getAnnotations() {
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the list of annotations.
|
||||
*
|
||||
* @param annotations The list of annotations
|
||||
*/
|
||||
public void setAnnotations(List<Annotation> annotations) {
|
||||
this.annotations = annotations;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getMessageId() {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message ID.
|
||||
*
|
||||
* @param messageId The message ID
|
||||
*/
|
||||
public void setMessageId(String messageId) {
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>toString.</p>
|
||||
*
|
||||
* @return a {@link java.lang.String} object.
|
||||
*/
|
||||
public String toString() {
|
||||
return JSON.toJSONString(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.block;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent;
|
||||
|
||||
/**
|
||||
* Represents a text content block.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "text")
|
||||
public class TextBlock extends ContentBlock<String> implements TextAssistantContent {
|
||||
/**
|
||||
* The text content.
|
||||
*/
|
||||
private String text;
|
||||
|
||||
/**
|
||||
* Gets the text content.
|
||||
*
|
||||
* @return The text content
|
||||
*/
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the text content.
|
||||
*
|
||||
* @param text The text content
|
||||
*/
|
||||
public void setText(String text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getContentOfAssistant() {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.block;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent;
|
||||
|
||||
/**
|
||||
* Represents a thinking content block.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "thinking")
|
||||
public class ThinkingBlock extends ContentBlock<String> implements ThingkingAssistantContent {
|
||||
/**
|
||||
* The thinking content.
|
||||
*/
|
||||
private String thinking;
|
||||
/**
|
||||
* The signature.
|
||||
*/
|
||||
private String signature;
|
||||
|
||||
/**
|
||||
* Gets the thinking content.
|
||||
*
|
||||
* @return The thinking content
|
||||
*/
|
||||
public String getThinking() {
|
||||
return thinking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the thinking content.
|
||||
*
|
||||
* @param thinking The thinking content
|
||||
*/
|
||||
public void setThinking(String thinking) {
|
||||
this.thinking = thinking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the signature.
|
||||
*
|
||||
* @return The signature
|
||||
*/
|
||||
public String getSignature() {
|
||||
return signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the signature.
|
||||
*
|
||||
* @param signature The signature
|
||||
*/
|
||||
public void setSignature(String signature) {
|
||||
this.signature = signature;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getContentOfAssistant() {
|
||||
return thinking;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.block;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent;
|
||||
|
||||
/**
|
||||
* Represents a tool result content block.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "tool_result")
|
||||
public class ToolResultBlock extends ContentBlock<String> implements ToolResultAssistantContent {
|
||||
/**
|
||||
* The tool use ID.
|
||||
*/
|
||||
@JSONField(name = "tool_use_id")
|
||||
private String toolUseId;
|
||||
|
||||
/**
|
||||
* The result content.
|
||||
*/
|
||||
@JSONField(name = "content")
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* Whether the result is an error.
|
||||
*/
|
||||
@JSONField(name = "is_error")
|
||||
private Boolean isError;
|
||||
|
||||
/**
|
||||
* Gets the tool use ID.
|
||||
*
|
||||
* @return The tool use ID
|
||||
*/
|
||||
public String getToolUseId() {
|
||||
return toolUseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the tool use ID.
|
||||
*
|
||||
* @param toolUseId The tool use ID
|
||||
*/
|
||||
public void setToolUseId(String toolUseId) {
|
||||
this.toolUseId = toolUseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the result content.
|
||||
*
|
||||
* @return The result content
|
||||
*/
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the result content.
|
||||
*
|
||||
* @param content The result content
|
||||
*/
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets whether the result is an error.
|
||||
*
|
||||
* @return Whether the result is an error
|
||||
*/
|
||||
public Boolean getIsError() {
|
||||
return isError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the result is an error.
|
||||
*
|
||||
* @param isError Whether the result is an error
|
||||
*/
|
||||
public void setIsError(Boolean isError) {
|
||||
this.isError = isError;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getContentOfAssistant() {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.block;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent;
|
||||
|
||||
/**
|
||||
* Represents a tool use content block.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "tool_use")
|
||||
public class ToolUseBlock extends ContentBlock<Map<String, Object>> implements ToolUseAssistantContent {
|
||||
/**
|
||||
* The tool use ID.
|
||||
*/
|
||||
private String id;
|
||||
/**
|
||||
* The tool name.
|
||||
*/
|
||||
private String name;
|
||||
/**
|
||||
* The tool input.
|
||||
*/
|
||||
private Map<String, Object> input;
|
||||
/**
|
||||
* List of annotations.
|
||||
*/
|
||||
private List<Annotation> annotations;
|
||||
|
||||
/**
|
||||
* Creates a new ToolUseBlock instance.
|
||||
*/
|
||||
public ToolUseBlock() {}
|
||||
|
||||
/**
|
||||
* Gets the tool use ID.
|
||||
*
|
||||
* @return The tool use ID
|
||||
*/
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the tool use ID.
|
||||
*
|
||||
* @param id The tool use ID
|
||||
*/
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tool name.
|
||||
*
|
||||
* @return The tool name
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the tool name.
|
||||
*
|
||||
* @param name The tool name
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tool input.
|
||||
*
|
||||
* @return The tool input
|
||||
*/
|
||||
public Map<String, Object> getInput() {
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the tool input.
|
||||
*
|
||||
* @param input The tool input
|
||||
*/
|
||||
public void setInput(Map<String, Object> input) {
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of annotations.
|
||||
*
|
||||
* @return The list of annotations
|
||||
*/
|
||||
public List<Annotation> getAnnotations() {
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Sets the list of annotations.
|
||||
*/
|
||||
@Override
|
||||
public void setAnnotations(List<Annotation> annotations) {
|
||||
this.annotations = annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Gets the content of the assistant.
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> getContentOfAssistant() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.event;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.TypeReference;
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent;
|
||||
|
||||
/**
|
||||
* Represents a content block delta event during streaming.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "content_block_delta")
|
||||
public class ContentBlockDeltaEvent extends StreamEvent {
|
||||
/**
|
||||
* The index of the content block.
|
||||
*/
|
||||
private int index;
|
||||
/**
|
||||
* The content block delta.
|
||||
*/
|
||||
private ContentBlockDelta<?> delta;
|
||||
|
||||
/**
|
||||
* Gets the index of the content block.
|
||||
*
|
||||
* @return The index of the content block
|
||||
*/
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the index of the content block.
|
||||
*
|
||||
* @param index The index of the content block
|
||||
*/
|
||||
public void setIndex(int index) {
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the content block delta.
|
||||
*
|
||||
* @return The content block delta
|
||||
*/
|
||||
public ContentBlockDelta<?> getDelta() {
|
||||
return delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the content block delta.
|
||||
*
|
||||
* @param delta The content block delta
|
||||
*/
|
||||
public void setDelta(ContentBlockDelta<?> delta) {
|
||||
this.delta = delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for content block deltas.
|
||||
*
|
||||
* @param <C> The type of content
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "ContentBlockDelta",
|
||||
seeAlso = {ContentBlockDeltaText.class, ContentBlockDeltaThinking.class, ContentBlockDeltaInputJson.class})
|
||||
public abstract static class ContentBlockDelta<C> implements AssistantContent<C> {
|
||||
/**
|
||||
* The type of the content block delta.
|
||||
*/
|
||||
protected String type;
|
||||
/**
|
||||
* The message ID.
|
||||
*/
|
||||
protected String messageId;
|
||||
|
||||
@Override
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the type of the content block delta.
|
||||
*
|
||||
* @param type The type of the content block delta
|
||||
*/
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessageId() {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message ID.
|
||||
*
|
||||
* @param messageId The message ID
|
||||
*/
|
||||
public void setMessageId(String messageId) {
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return JSON.toJSONString(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a text delta.
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "text_delta")
|
||||
public static class ContentBlockDeltaText extends ContentBlockDelta<String> implements TextAssistantContent {
|
||||
/**
|
||||
* The text content.
|
||||
*/
|
||||
private String text;
|
||||
|
||||
/**
|
||||
* Gets the text content.
|
||||
*
|
||||
* @return The text content
|
||||
*/
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the text content.
|
||||
*
|
||||
* @param text The text content
|
||||
*/
|
||||
public void setText(String text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentOfAssistant() {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a thinking delta.
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "thinking_delta")
|
||||
public static class ContentBlockDeltaThinking extends ContentBlockDelta<String> implements ThingkingAssistantContent {
|
||||
/**
|
||||
* The thinking content.
|
||||
*/
|
||||
private String thinking;
|
||||
|
||||
/**
|
||||
* Gets the thinking content.
|
||||
*
|
||||
* @return The thinking content
|
||||
*/
|
||||
public String getThinking() {
|
||||
return thinking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the thinking content.
|
||||
*
|
||||
* @param thinking The thinking content
|
||||
*/
|
||||
public void setThinking(String thinking) {
|
||||
this.thinking = thinking;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentOfAssistant() {
|
||||
return thinking;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an input JSON delta.
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "input_json_delta")
|
||||
public static class ContentBlockDeltaInputJson extends ContentBlockDelta<Map<String, Object>> implements ToolUseAssistantContent {
|
||||
/**
|
||||
* The partial JSON content.
|
||||
*/
|
||||
@JSONField(name = "partial_json")
|
||||
private String partialJson;
|
||||
|
||||
/**
|
||||
* Gets the partial JSON content.
|
||||
*
|
||||
* @return The partial JSON content
|
||||
*/
|
||||
public String getPartialJson() {
|
||||
return partialJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the partial JSON content.
|
||||
*
|
||||
* @param partialJson The partial JSON content
|
||||
*/
|
||||
public void setPartialJson(String partialJson) {
|
||||
this.partialJson = partialJson;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getContentOfAssistant() {
|
||||
return getInput();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getInput() {
|
||||
return JSON.parseObject(partialJson, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.event;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock;
|
||||
|
||||
/**
|
||||
* Represents a content block start event during message streaming.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "content_block_start")
|
||||
public class ContentBlockStartEvent extends StreamEvent{
|
||||
/**
|
||||
* The index of the content block.
|
||||
*/
|
||||
private int index;
|
||||
|
||||
/**
|
||||
* The content block that is starting.
|
||||
*/
|
||||
@JSONField(name = "content_block")
|
||||
private ContentBlock contentBlock;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.event;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
|
||||
/**
|
||||
* Represents a content block stop event during message streaming.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeKey = "type", typeName = "content_block_stop")
|
||||
public class ContentBlockStopEvent extends StreamEvent{
|
||||
/**
|
||||
* The index of the content block.
|
||||
*/
|
||||
Long index;
|
||||
|
||||
/**
|
||||
* Gets the index of the content block.
|
||||
*
|
||||
* @return The index of the content block
|
||||
*/
|
||||
public Long getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the index of the content block.
|
||||
*
|
||||
* @param index The index of the content block
|
||||
*/
|
||||
public void setIndex(Long index) {
|
||||
this.index = index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.event;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
|
||||
/**
|
||||
* Represents a message start event during message streaming.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeName = "message_start")
|
||||
public class MessageStartStreamEvent extends StreamEvent{
|
||||
/**
|
||||
* The message that is starting.
|
||||
*/
|
||||
private Message message;
|
||||
|
||||
/**
|
||||
* Represents the message information.
|
||||
*/
|
||||
public static class Message {
|
||||
/**
|
||||
* Message ID.
|
||||
*/
|
||||
private String id;
|
||||
/**
|
||||
* Message role.
|
||||
*/
|
||||
private String role;
|
||||
/**
|
||||
* Message model.
|
||||
*/
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* Gets the message ID.
|
||||
*
|
||||
* @return The message ID
|
||||
*/
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message ID.
|
||||
*
|
||||
* @param id The message ID
|
||||
*/
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message role.
|
||||
*
|
||||
* @return The message role
|
||||
*/
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message role.
|
||||
*
|
||||
* @param role The message role
|
||||
*/
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message model.
|
||||
*
|
||||
* @return The message model
|
||||
*/
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message model.
|
||||
*
|
||||
* @param model The message model
|
||||
*/
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message that is starting.
|
||||
*
|
||||
* @return The message that is starting
|
||||
*/
|
||||
public Message getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message that is starting.
|
||||
*
|
||||
* @param message The message that is starting
|
||||
*/
|
||||
public void setMessage(Message message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.alibaba.qwen.code.cli.protocol.message.assistant.event;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONType;
|
||||
|
||||
/**
|
||||
* Represents a message stop event during message streaming.
|
||||
*
|
||||
* @author skyfire
|
||||
* @version $Id: 0.0.1
|
||||
*/
|
||||
@JSONType(typeName = "message_stop")
|
||||
public class MessageStopStreamEvent extends StreamEvent{
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user