🐛 Bug Fixes Release v0.1.1 (#898)

This commit is contained in:
tanzhenxin
2025-10-29 12:25:50 +08:00
committed by GitHub
parent 32a71986d5
commit f8be8a61c8
26 changed files with 343 additions and 438 deletions

View File

@@ -7,7 +7,7 @@
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { writeFileSync } from 'node:fs'; import { writeFileSync, rmSync } from 'node:fs';
let esbuild; let esbuild;
try { try {
@@ -22,6 +22,9 @@ const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const pkg = require(path.resolve(__dirname, 'package.json')); const pkg = require(path.resolve(__dirname, 'package.json'));
// Clean dist directory (cross-platform)
rmSync(path.resolve(__dirname, 'dist'), { recursive: true, force: true });
const external = [ const external = [
'@lydell/node-pty', '@lydell/node-pty',
'node-pty', 'node-pty',

View File

@@ -28,7 +28,7 @@
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces", "build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js", "build:sandbox": "node scripts/build_sandbox.js",
"bundle": "rm -rf dist && npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js", "bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces --if-present --parallel", "test": "npm run test --workspaces --if-present --parallel",
"test:ci": "npm run test:ci --workspaces --if-present --parallel && npm run test:scripts", "test:ci": "npm run test:ci --workspaces --if-present --parallel && npm run test:scripts",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",

View File

@@ -18,60 +18,26 @@ vi.mock('./settings.js', () => ({
describe('validateAuthMethod', () => { describe('validateAuthMethod', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.stubEnv('GEMINI_API_KEY', undefined);
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
vi.stubEnv('GOOGLE_API_KEY', undefined);
}); });
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
it('should return null for LOGIN_WITH_GOOGLE', () => { it('should return null for USE_OPENAI', () => {
expect(validateAuthMethod(AuthType.LOGIN_WITH_GOOGLE)).toBeNull(); process.env['OPENAI_API_KEY'] = 'fake-key';
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
}); });
it('should return null for CLOUD_SHELL', () => { it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
expect(validateAuthMethod(AuthType.CLOUD_SHELL)).toBeNull(); delete process.env['OPENAI_API_KEY'];
}); 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.',
describe('USE_GEMINI', () => {
it('should return null if GEMINI_API_KEY is set', () => {
vi.stubEnv('GEMINI_API_KEY', 'test-key');
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
});
it('should return an error message if GEMINI_API_KEY is not set', () => {
vi.stubEnv('GEMINI_API_KEY', undefined);
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBe(
'GEMINI_API_KEY environment variable not found. Add that to your environment and try again (no reload needed if using .env)!',
); );
}); });
});
describe('USE_VERTEX_AI', () => { it('should return null for QWEN_OAUTH', () => {
it('should return null if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set', () => { expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'test-location');
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return null if GOOGLE_API_KEY is set', () => {
vi.stubEnv('GOOGLE_API_KEY', 'test-api-key');
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return an error message if no required environment variables are set', () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBe(
'When using Vertex AI, you must specify either:\n' +
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!',
);
});
}); });
it('should return an error message for an invalid auth method', () => { it('should return an error message for an invalid auth method', () => {

View File

@@ -8,39 +8,13 @@ import { AuthType } from '@qwen-code/qwen-code-core';
import { loadEnvironment, loadSettings } from './settings.js'; import { loadEnvironment, loadSettings } from './settings.js';
export function validateAuthMethod(authMethod: string): string | null { export function validateAuthMethod(authMethod: string): string | null {
loadEnvironment(loadSettings().merged); const settings = loadSettings();
if ( loadEnvironment(settings.merged);
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
authMethod === AuthType.CLOUD_SHELL
) {
return null;
}
if (authMethod === AuthType.USE_GEMINI) {
if (!process.env['GEMINI_API_KEY']) {
return 'GEMINI_API_KEY environment variable not found. Add that to your environment and try again (no reload needed if using .env)!';
}
return null;
}
if (authMethod === AuthType.USE_VERTEX_AI) {
const hasVertexProjectLocationConfig =
!!process.env['GOOGLE_CLOUD_PROJECT'] &&
!!process.env['GOOGLE_CLOUD_LOCATION'];
const hasGoogleApiKey = !!process.env['GOOGLE_API_KEY'];
if (!hasVertexProjectLocationConfig && !hasGoogleApiKey) {
return (
'When using Vertex AI, you must specify either:\n' +
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!'
);
}
return null;
}
if (authMethod === AuthType.USE_OPENAI) { if (authMethod === AuthType.USE_OPENAI) {
if (!process.env['OPENAI_API_KEY']) { 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.'; return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
} }
return null; return null;
@@ -54,15 +28,3 @@ export function validateAuthMethod(authMethod: string): string | null {
return 'Invalid auth method selected.'; return 'Invalid auth method selected.';
} }
export const setOpenAIApiKey = (apiKey: string): void => {
process.env['OPENAI_API_KEY'] = apiKey;
};
export const setOpenAIBaseUrl = (baseUrl: string): void => {
process.env['OPENAI_BASE_URL'] = baseUrl;
};
export const setOpenAIModel = (model: string): void => {
process.env['OPENAI_MODEL'] = model;
};

View File

@@ -13,7 +13,6 @@ import { extensionsCommand } from '../commands/extensions.js';
import { import {
ApprovalMode, ApprovalMode,
Config, Config,
DEFAULT_QWEN_MODEL,
DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_EMBEDDING_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
EditTool, EditTool,
@@ -669,13 +668,11 @@ export async function loadCliConfig(
); );
} }
const defaultModel = DEFAULT_QWEN_MODEL; const resolvedModel =
const resolvedModel: string =
argv.model || argv.model ||
process.env['OPENAI_MODEL'] || process.env['OPENAI_MODEL'] ||
process.env['QWEN_MODEL'] || process.env['QWEN_MODEL'] ||
settings.model?.name || settings.model?.name;
defaultModel;
const sandboxConfig = await loadSandboxConfig(settings, argv); const sandboxConfig = await loadSandboxConfig(settings, argv);
const screenReader = const screenReader =
@@ -739,8 +736,14 @@ export async function loadCliConfig(
generationConfig: { generationConfig: {
...(settings.model?.generationConfig || {}), ...(settings.model?.generationConfig || {}),
model: resolvedModel, model: resolvedModel,
apiKey: argv.openaiApiKey || process.env['OPENAI_API_KEY'], apiKey:
baseUrl: argv.openaiBaseUrl || process.env['OPENAI_BASE_URL'], argv.openaiApiKey ||
process.env['OPENAI_API_KEY'] ||
settings.security?.auth?.apiKey,
baseUrl:
argv.openaiBaseUrl ||
process.env['OPENAI_BASE_URL'] ||
settings.security?.auth?.baseUrl,
enableOpenAILogging: enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined' (typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging ? settings.model?.enableOpenAILogging

View File

@@ -991,6 +991,24 @@ const SETTINGS_SCHEMA = {
description: 'Whether to use an external authentication flow.', description: 'Whether to use an external authentication flow.',
showInDialog: false, showInDialog: false,
}, },
apiKey: {
type: 'string',
label: 'API Key',
category: 'Security',
requiresRestart: true,
default: undefined as string | undefined,
description: 'API key for OpenAI compatible authentication.',
showInDialog: false,
},
baseUrl: {
type: 'string',
label: 'Base URL',
category: 'Security',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Base URL for OpenAI compatible API.',
showInDialog: false,
},
}, },
}, },
}, },

View File

@@ -17,11 +17,7 @@ import dns from 'node:dns';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { start_sandbox } from './utils/sandbox.js'; import { start_sandbox } from './utils/sandbox.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import { import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
loadSettings,
migrateDeprecatedSettings,
SettingScope,
} from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js'; import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js'; import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
@@ -233,17 +229,6 @@ export async function main() {
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
); );
// Set a default auth type if one isn't set.
if (!settings.merged.security?.auth?.selectedType) {
if (process.env['CLOUD_SHELL'] === 'true') {
settings.setValue(
SettingScope.User,
'selectedAuthType',
AuthType.CLOUD_SHELL,
);
}
}
// Load custom themes from settings // Load custom themes from settings
themeManager.loadCustomThemes(settings.merged.ui?.customThemes); themeManager.loadCustomThemes(settings.merged.ui?.customThemes);

View File

@@ -8,12 +8,7 @@ import type React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { import { validateAuthMethod } from '../../config/auth.js';
setOpenAIApiKey,
setOpenAIBaseUrl,
setOpenAIModel,
validateAuthMethod,
} from '../../config/auth.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js'; import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
@@ -21,7 +16,15 @@ import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
interface AuthDialogProps { interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void; onSelect: (
authMethod: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => void;
settings: LoadedSettings; settings: LoadedSettings;
initialErrorMessage?: string | null; initialErrorMessage?: string | null;
} }
@@ -70,11 +73,7 @@ export function AuthDialog({
return item.value === defaultAuthType; return item.value === defaultAuthType;
} }
if (process.env['GEMINI_API_KEY']) { return item.value === AuthType.QWEN_OAUTH;
return item.value === AuthType.USE_GEMINI;
}
return item.value === AuthType.LOGIN_WITH_GOOGLE;
}), }),
); );
@@ -101,11 +100,12 @@ export function AuthDialog({
baseUrl: string, baseUrl: string,
model: string, model: string,
) => { ) => {
setOpenAIApiKey(apiKey);
setOpenAIBaseUrl(baseUrl);
setOpenAIModel(model);
setShowOpenAIKeyPrompt(false); setShowOpenAIKeyPrompt(false);
onSelect(AuthType.USE_OPENAI, SettingScope.User); onSelect(AuthType.USE_OPENAI, SettingScope.User, {
apiKey,
baseUrl,
model,
});
}; };
const handleOpenAIKeyCancel = () => { const handleOpenAIKeyCancel = () => {

View File

@@ -6,12 +6,11 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js'; import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType, type Config } from '@qwen-code/qwen-code-core'; import type { AuthType, Config } from '@qwen-code/qwen-code-core';
import { import {
clearCachedCredentialFile, clearCachedCredentialFile,
getErrorMessage, getErrorMessage,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { runExitCleanup } from '../../utils/cleanup.js';
import { AuthState } from '../types.js'; import { AuthState } from '../types.js';
import { validateAuthMethod } from '../../config/auth.js'; import { validateAuthMethod } from '../../config/auth.js';
@@ -47,6 +46,7 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
setAuthError(error); setAuthError(error);
if (error) { if (error) {
setAuthState(AuthState.Updating); setAuthState(AuthState.Updating);
setIsAuthDialogOpen(true);
} }
}, },
[setAuthError, setAuthState], [setAuthError, setAuthState],
@@ -87,24 +87,49 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
// Handle auth selection from dialog // Handle auth selection from dialog
const handleAuthSelect = useCallback( const handleAuthSelect = useCallback(
async (authType: AuthType | undefined, scope: SettingScope) => { async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => {
if (authType) { if (authType) {
await clearCachedCredentialFile(); await clearCachedCredentialFile();
settings.setValue(scope, 'security.auth.selectedType', authType); // Save OpenAI credentials if provided
if (credentials) {
// Update Config's internal generationConfig before calling refreshAuth
// This ensures refreshAuth has access to the new credentials
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
if ( // Also set environment variables for compatibility with other parts of the code
authType === AuthType.LOGIN_WITH_GOOGLE && if (credentials.apiKey) {
config.isBrowserLaunchSuppressed() settings.setValue(
) { scope,
await runExitCleanup(); 'security.auth.apiKey',
console.log(` credentials.apiKey,
---------------------------------------------------------------- );
Logging in with Google... Please restart Gemini CLI to continue.
----------------------------------------------------------------
`);
process.exit(0);
} }
if (credentials.baseUrl) {
settings.setValue(
scope,
'security.auth.baseUrl',
credentials.baseUrl,
);
}
if (credentials.model) {
settings.setValue(scope, 'model.name', credentials.model);
}
}
settings.setValue(scope, 'security.auth.selectedType', authType);
} }
setIsAuthDialogOpen(false); setIsAuthDialogOpen(false);

View File

@@ -11,6 +11,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import { getCliVersion } from '../../utils/version.js'; import { getCliVersion } from '../../utils/version.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js'; import { formatMemoryUsage } from '../utils/formatters.js';
import { AuthType } from '@qwen-code/qwen-code-core';
// Mock dependencies // Mock dependencies
vi.mock('open'); vi.mock('open');
@@ -59,6 +60,15 @@ describe('bugCommand', () => {
getBugCommand: () => undefined, getBugCommand: () => undefined,
getIdeMode: () => true, getIdeMode: () => true,
}, },
settings: {
merged: {
security: {
auth: {
selectedType: undefined,
},
},
},
},
}, },
}); });
@@ -71,6 +81,7 @@ describe('bugCommand', () => {
* **Session ID:** test-session-id * **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0 * **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test * **Sandbox Environment:** test
* **Auth Type:**
* **Model Version:** qwen3-coder-plus * **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB * **Memory Usage:** 100 MB
* **IDE Client:** VSCode * **IDE Client:** VSCode
@@ -92,6 +103,15 @@ describe('bugCommand', () => {
getBugCommand: () => ({ urlTemplate: customTemplate }), getBugCommand: () => ({ urlTemplate: customTemplate }),
getIdeMode: () => true, getIdeMode: () => true,
}, },
settings: {
merged: {
security: {
auth: {
selectedType: undefined,
},
},
},
},
}, },
}); });
@@ -104,6 +124,7 @@ describe('bugCommand', () => {
* **Session ID:** test-session-id * **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0 * **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test * **Sandbox Environment:** test
* **Auth Type:**
* **Model Version:** qwen3-coder-plus * **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB * **Memory Usage:** 100 MB
* **IDE Client:** VSCode * **IDE Client:** VSCode
@@ -114,4 +135,49 @@ describe('bugCommand', () => {
expect(open).toHaveBeenCalledWith(expectedUrl); expect(open).toHaveBeenCalledWith(expectedUrl);
}); });
it('should include Base URL when auth type is OpenAI', async () => {
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => undefined,
getIdeMode: () => true,
getContentGeneratorConfig: () => ({
baseUrl: 'https://api.openai.com/v1',
}),
},
settings: {
merged: {
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
},
},
},
});
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'OpenAI bug');
const expectedInfo = `
* **CLI Version:** 0.1.0
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Auth Type:** ${AuthType.USE_OPENAI}
* **Base URL:** https://api.openai.com/v1
* **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;
const expectedUrl =
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title=OpenAI%20bug&info=' +
encodeURIComponent(expectedInfo);
expect(open).toHaveBeenCalledWith(expectedUrl);
});
}); });

View File

@@ -15,7 +15,7 @@ import { MessageType } from '../types.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js'; import { formatMemoryUsage } from '../utils/formatters.js';
import { getCliVersion } from '../../utils/version.js'; import { getCliVersion } from '../../utils/version.js';
import { IdeClient, sessionId } from '@qwen-code/qwen-code-core'; import { IdeClient, sessionId, AuthType } from '@qwen-code/qwen-code-core';
export const bugCommand: SlashCommand = { export const bugCommand: SlashCommand = {
name: 'bug', name: 'bug',
@@ -38,6 +38,12 @@ export const bugCommand: SlashCommand = {
const cliVersion = await getCliVersion(); const cliVersion = await getCliVersion();
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
const ideClient = await getIdeClientName(context); const ideClient = await getIdeClientName(context);
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const baseUrl =
selectedAuthType === AuthType.USE_OPENAI
? config?.getContentGeneratorConfig()?.baseUrl
: undefined;
let info = ` let info = `
* **CLI Version:** ${cliVersion} * **CLI Version:** ${cliVersion}
@@ -45,6 +51,11 @@ export const bugCommand: SlashCommand = {
* **Session ID:** ${sessionId} * **Session ID:** ${sessionId}
* **Operating System:** ${osVersion} * **Operating System:** ${osVersion}
* **Sandbox Environment:** ${sandboxEnv} * **Sandbox Environment:** ${sandboxEnv}
* **Auth Type:** ${selectedAuthType}`;
if (baseUrl) {
info += `\n* **Base URL:** ${baseUrl}`;
}
info += `
* **Model Version:** ${modelVersion} * **Model Version:** ${modelVersion}
* **Memory Usage:** ${memoryUsage} * **Memory Usage:** ${memoryUsage}
`; `;

View File

@@ -12,6 +12,7 @@ import type {
Config, Config,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../../test-utils/render.js'; import { renderWithProviders } from '../../../test-utils/render.js';
import type { LoadedSettings } from '../../../config/settings.js';
describe('ToolConfirmationMessage', () => { describe('ToolConfirmationMessage', () => {
const mockConfig = { const mockConfig = {
@@ -187,4 +188,63 @@ describe('ToolConfirmationMessage', () => {
}); });
}); });
}); });
describe('external editor option', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
filePath: '/test.txt',
fileDiff: '...diff...',
originalContent: 'a',
newContent: 'b',
onConfirm: vi.fn(),
};
it('should show "Modify with external editor" when preferredEditor is set', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
{
settings: {
merged: { general: { preferredEditor: 'vscode' } },
} as unknown as LoadedSettings,
},
);
expect(lastFrame()).toContain('Modify with external editor');
});
it('should NOT show "Modify with external editor" when preferredEditor is not set', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
{
settings: {
merged: { general: {} },
} as unknown as LoadedSettings,
},
);
expect(lastFrame()).not.toContain('Modify with external editor');
});
});
}); });

View File

@@ -15,12 +15,14 @@ import type {
ToolExecuteConfirmationDetails, ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails, ToolMcpConfirmationDetails,
Config, Config,
EditorType,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { IdeClient, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core'; import { IdeClient, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js'; import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js'; import { useKeypress } from '../../hooks/useKeypress.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
export interface ToolConfirmationMessageProps { export interface ToolConfirmationMessageProps {
@@ -45,6 +47,11 @@ export const ToolConfirmationMessage: React.FC<
const { onConfirm } = confirmationDetails; const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding const childWidth = terminalWidth - 2; // 2 for padding
const settings = useSettings();
const preferredEditor = settings.merged.general?.preferredEditor as
| EditorType
| undefined;
const [ideClient, setIdeClient] = useState<IdeClient | null>(null); const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false); const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
@@ -199,7 +206,7 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow always', key: 'Yes, allow always',
}); });
} }
if (!config.getIdeMode() || !isDiffingEnabled) { if ((!config.getIdeMode() || !isDiffingEnabled) && preferredEditor) {
options.push({ options.push({
label: 'Modify with external editor', label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor, value: ToolConfirmationOutcome.ModifyWithEditor,

View File

@@ -109,7 +109,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope, scope,
'preferredEditor', 'general.preferredEditor',
editorType, editorType,
); );
@@ -139,7 +139,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope, scope,
'preferredEditor', 'general.preferredEditor',
undefined, undefined,
); );
@@ -170,7 +170,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope, scope,
'preferredEditor', 'general.preferredEditor',
editorType, editorType,
); );
@@ -199,7 +199,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope, scope,
'preferredEditor', 'general.preferredEditor',
editorType, editorType,
); );

View File

@@ -45,7 +45,7 @@ export const useEditorSettings = (
} }
try { try {
loadedSettings.setValue(scope, 'preferredEditor', editorType); loadedSettings.setValue(scope, 'general.preferredEditor', editorType);
addItem( addItem(
{ {
type: MessageType.INFO, type: MessageType.INFO,

View File

@@ -105,34 +105,6 @@ describe('validateNonInterActiveAuth', () => {
expect(processExitSpy).toHaveBeenCalledWith(1); expect(processExitSpy).toHaveBeenCalledWith(1);
}); });
it('uses LOGIN_WITH_GOOGLE if GOOGLE_GENAI_USE_GCA is set', async () => {
process.env['GOOGLE_GENAI_USE_GCA'] = 'true';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.LOGIN_WITH_GOOGLE);
});
it('uses USE_GEMINI if GEMINI_API_KEY is set', async () => {
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI);
});
it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => { it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => {
process.env['OPENAI_API_KEY'] = 'fake-openai-key'; process.env['OPENAI_API_KEY'] = 'fake-openai-key';
const nonInteractiveConfig = { const nonInteractiveConfig = {
@@ -168,104 +140,6 @@ describe('validateNonInterActiveAuth', () => {
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH);
}); });
it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true (with GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION)', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI);
});
it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true and GOOGLE_API_KEY is set', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_API_KEY'] = 'vertex-api-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI);
});
it('uses LOGIN_WITH_GOOGLE if GOOGLE_GENAI_USE_GCA is set, even with other env vars', async () => {
process.env['GOOGLE_GENAI_USE_GCA'] = 'true';
process.env['GEMINI_API_KEY'] = 'fake-key';
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.LOGIN_WITH_GOOGLE);
});
it('uses USE_VERTEX_AI if both GEMINI_API_KEY and GOOGLE_GENAI_USE_VERTEXAI are set', async () => {
process.env['GEMINI_API_KEY'] = 'fake-key';
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI);
});
it('uses USE_GEMINI if GOOGLE_GENAI_USE_VERTEXAI is false, GEMINI_API_KEY is set, and project/location are available', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'false';
process.env['GEMINI_API_KEY'] = 'fake-key';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI);
});
it('uses configuredAuthType if provided', async () => {
// Set required env var for USE_GEMINI
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
AuthType.USE_GEMINI,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI);
});
it('exits if validateAuthMethod returns error', async () => { it('exits if validateAuthMethod returns error', async () => {
// Mock validateAuthMethod to return error // Mock validateAuthMethod to return error
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
@@ -317,26 +191,25 @@ describe('validateNonInterActiveAuth', () => {
}); });
it('uses enforcedAuthType if provided', async () => { it('uses enforcedAuthType if provided', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_OPENAI;
mockSettings.merged.security!.auth!.selectedType = AuthType.USE_GEMINI; mockSettings.merged.security!.auth!.selectedType = AuthType.USE_OPENAI;
// Set required env var for USE_GEMINI to ensure enforcedAuthType takes precedence // Set required env var for USE_OPENAI to ensure enforcedAuthType takes precedence
process.env['GEMINI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = { const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
} as unknown as Config; } as unknown as Config;
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_GEMINI, AuthType.USE_OPENAI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
); );
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_OPENAI);
}); });
it('exits if currentAuthType does not match enforcedAuthType', async () => { it('exits if currentAuthType does not match enforcedAuthType', async () => {
mockSettings.merged.security!.auth!.enforcedType = mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
AuthType.LOGIN_WITH_GOOGLE; process.env['OPENAI_API_KEY'] = 'fake-key';
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
const nonInteractiveConfig = { const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
@@ -346,7 +219,7 @@ describe('validateNonInterActiveAuth', () => {
} as unknown as Config; } as unknown as Config;
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_GEMINI, AuthType.USE_OPENAI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -356,7 +229,7 @@ describe('validateNonInterActiveAuth', () => {
expect((e as Error).message).toContain('process.exit(1) called'); expect((e as Error).message).toContain('process.exit(1) called');
} }
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
'The configured auth type is oauth-personal, but the current auth type is vertex-ai. Please re-authenticate with the correct type.', 'The configured auth type is qwen-oauth, but the current auth type is openai. Please re-authenticate with the correct type.',
); );
expect(processExitSpy).toHaveBeenCalledWith(1); expect(processExitSpy).toHaveBeenCalledWith(1);
}); });
@@ -394,8 +267,8 @@ describe('validateNonInterActiveAuth', () => {
}); });
it('prints JSON error when enforced auth mismatches current auth and exits with code 1', async () => { it('prints JSON error when enforced auth mismatches current auth and exits with code 1', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['GOOGLE_GENAI_USE_GCA'] = 'true'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = { const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
@@ -424,14 +297,14 @@ describe('validateNonInterActiveAuth', () => {
expect(payload.error.type).toBe('Error'); expect(payload.error.type).toBe('Error');
expect(payload.error.code).toBe(1); expect(payload.error.code).toBe(1);
expect(payload.error.message).toContain( expect(payload.error.message).toContain(
'The configured auth type is gemini-api-key, but the current auth type is oauth-personal.', 'The configured auth type is qwen-oauth, but the current auth type is openai.',
); );
} }
}); });
it('prints JSON error when validateAuthMethod fails and exits with code 1', async () => { it('prints JSON error when validateAuthMethod fails and exits with code 1', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['GEMINI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = { const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
@@ -444,7 +317,7 @@ describe('validateNonInterActiveAuth', () => {
let thrown: Error | undefined; let thrown: Error | undefined;
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_GEMINI, AuthType.USE_OPENAI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,

View File

@@ -12,21 +12,13 @@ import { type LoadedSettings } from './config/settings.js';
import { handleError } from './utils/errors.js'; import { handleError } from './utils/errors.js';
function getAuthTypeFromEnv(): AuthType | undefined { function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env['GOOGLE_GENAI_USE_GCA'] === 'true') {
return AuthType.LOGIN_WITH_GOOGLE;
}
if (process.env['GOOGLE_GENAI_USE_VERTEXAI'] === 'true') {
return AuthType.USE_VERTEX_AI;
}
if (process.env['GEMINI_API_KEY']) {
return AuthType.USE_GEMINI;
}
if (process.env['OPENAI_API_KEY']) { if (process.env['OPENAI_API_KEY']) {
return AuthType.USE_OPENAI; return AuthType.USE_OPENAI;
} }
if (process.env['QWEN_OAUTH']) { if (process.env['QWEN_OAUTH']) {
return AuthType.QWEN_OAUTH; return AuthType.QWEN_OAUTH;
} }
return undefined; return undefined;
} }

View File

@@ -16,6 +16,7 @@ import {
QwenLogger, QwenLogger,
} from '../telemetry/index.js'; } from '../telemetry/index.js';
import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
import { import {
AuthType, AuthType,
createContentGeneratorConfig, createContentGeneratorConfig,
@@ -250,6 +251,7 @@ describe('Server Config (config.ts)', () => {
authType, authType,
{ {
model: MODEL, model: MODEL,
baseUrl: DEFAULT_DASHSCOPE_BASE_URL,
}, },
); );
// Verify that contentGeneratorConfig is updated // Verify that contentGeneratorConfig is updated

View File

@@ -88,8 +88,9 @@ import {
DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
} from './constants.js'; } from './constants.js';
import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js'; import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js';
import { Storage } from './storage.js'; import { Storage } from './storage.js';
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
// Re-export types // Re-export types
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig }; export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
@@ -243,7 +244,7 @@ export interface ConfigParameters {
fileDiscoveryService?: FileDiscoveryService; fileDiscoveryService?: FileDiscoveryService;
includeDirectories?: string[]; includeDirectories?: string[];
bugCommand?: BugCommandSettings; bugCommand?: BugCommandSettings;
model: string; model?: string;
extensionContextFilePaths?: string[]; extensionContextFilePaths?: string[];
maxSessionTurns?: number; maxSessionTurns?: number;
sessionTokenLimit?: number; sessionTokenLimit?: number;
@@ -289,7 +290,7 @@ export class Config {
private fileSystemService: FileSystemService; private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig; private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator; private contentGenerator!: ContentGenerator;
private readonly _generationConfig: ContentGeneratorConfig; private _generationConfig: Partial<ContentGeneratorConfig>;
private readonly embeddingModel: string; private readonly embeddingModel: string;
private readonly sandbox: SandboxConfig | undefined; private readonly sandbox: SandboxConfig | undefined;
private readonly targetDir: string; private readonly targetDir: string;
@@ -440,8 +441,10 @@ export class Config {
this._generationConfig = { this._generationConfig = {
model: params.model, model: params.model,
...(params.generationConfig || {}), ...(params.generationConfig || {}),
baseUrl: params.generationConfig?.baseUrl || DEFAULT_DASHSCOPE_BASE_URL,
}; };
this.contentGeneratorConfig = this._generationConfig; this.contentGeneratorConfig = this
._generationConfig as ContentGeneratorConfig;
this.cliVersion = params.cliVersion; this.cliVersion = params.cliVersion;
this.loadMemoryFromIncludeDirectories = this.loadMemoryFromIncludeDirectories =
@@ -520,6 +523,26 @@ export class Config {
return this.contentGenerator; return this.contentGenerator;
} }
/**
* Updates the credentials in the generation config.
* This is needed when credentials are set after Config construction.
*/
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;
}
}
async refreshAuth(authMethod: AuthType) { async refreshAuth(authMethod: AuthType) {
// Vertex and Genai have incompatible encryption and sending history with // Vertex and Genai have incompatible encryption and sending history with
// throughtSignature from Genai to Vertex will fail, we need to strip them // throughtSignature from Genai to Vertex will fail, we need to strip them
@@ -587,7 +610,7 @@ export class Config {
} }
getModel(): string { getModel(): string {
return this.contentGeneratorConfig.model; return this.contentGeneratorConfig?.model || DEFAULT_QWEN_MODEL;
} }
async setModel( async setModel(

View File

@@ -4,13 +4,9 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import type { ContentGenerator } from './contentGenerator.js'; import type { ContentGenerator } from './contentGenerator.js';
import { import { createContentGenerator, AuthType } from './contentGenerator.js';
createContentGenerator,
AuthType,
createContentGeneratorConfig,
} from './contentGenerator.js';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { GoogleGenAI } from '@google/genai'; import { GoogleGenAI } from '@google/genai';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
@@ -110,83 +106,3 @@ describe('createContentGenerator', () => {
); );
}); });
}); });
describe('createContentGeneratorConfig', () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
setModel: vi.fn(),
flashFallbackHandler: vi.fn(),
getProxy: vi.fn(),
getEnableOpenAILogging: vi.fn().mockReturnValue(false),
getSamplingParams: vi.fn().mockReturnValue(undefined),
getContentGeneratorTimeout: vi.fn().mockReturnValue(undefined),
getContentGeneratorMaxRetries: vi.fn().mockReturnValue(undefined),
getContentGeneratorDisableCacheControl: vi.fn().mockReturnValue(undefined),
getContentGeneratorSamplingParams: vi.fn().mockReturnValue(undefined),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
beforeEach(() => {
// Reset modules to re-evaluate imports and environment variables
vi.resetModules();
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should configure for Gemini using GEMINI_API_KEY when set', async () => {
vi.stubEnv('GEMINI_API_KEY', 'env-gemini-key');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_GEMINI,
);
expect(config.apiKey).toBe('env-gemini-key');
expect(config.vertexai).toBe(false);
});
it('should not configure for Gemini if GEMINI_API_KEY is empty', async () => {
vi.stubEnv('GEMINI_API_KEY', '');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_GEMINI,
);
expect(config.apiKey).toBeUndefined();
expect(config.vertexai).toBeUndefined();
});
it('should configure for Vertex AI using GOOGLE_API_KEY when set', async () => {
vi.stubEnv('GOOGLE_API_KEY', 'env-google-key');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_VERTEX_AI,
);
expect(config.apiKey).toBe('env-google-key');
expect(config.vertexai).toBe(true);
});
it('should configure for Vertex AI using GCP project and location when set', async () => {
vi.stubEnv('GOOGLE_API_KEY', undefined);
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-gcp-project');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'env-gcp-location');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_VERTEX_AI,
);
expect(config.vertexai).toBe(true);
expect(config.apiKey).toBeUndefined();
});
it('should not configure for Vertex AI if required env vars are empty', async () => {
vi.stubEnv('GOOGLE_API_KEY', '');
vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', '');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_VERTEX_AI,
);
expect(config.apiKey).toBeUndefined();
expect(config.vertexai).toBeUndefined();
});
});

View File

@@ -14,8 +14,8 @@ import type {
} from '@google/genai'; } from '@google/genai';
import { GoogleGenAI } from '@google/genai'; import { GoogleGenAI } from '@google/genai';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import type { Config } from '../config/config.js';
import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import type { Config } from '../config/config.js';
import type { UserTierId } from '../code_assist/types.js'; import type { UserTierId } from '../code_assist/types.js';
import { InstallationManager } from '../utils/installationManager.js'; import { InstallationManager } from '../utils/installationManager.js';
@@ -82,53 +82,37 @@ export function createContentGeneratorConfig(
authType: AuthType | undefined, authType: AuthType | undefined,
generationConfig?: Partial<ContentGeneratorConfig>, generationConfig?: Partial<ContentGeneratorConfig>,
): ContentGeneratorConfig { ): ContentGeneratorConfig {
const geminiApiKey = process.env['GEMINI_API_KEY'] || undefined; const newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
const googleCloudProject = process.env['GOOGLE_CLOUD_PROJECT'] || undefined;
const googleCloudLocation = process.env['GOOGLE_CLOUD_LOCATION'] || undefined;
const newContentGeneratorConfig: ContentGeneratorConfig = {
...(generationConfig || {}), ...(generationConfig || {}),
model: generationConfig?.model || DEFAULT_QWEN_MODEL,
authType, authType,
proxy: config?.getProxy(), proxy: config?.getProxy(),
}; };
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now
if (
authType === AuthType.LOGIN_WITH_GOOGLE ||
authType === AuthType.CLOUD_SHELL
) {
return newContentGeneratorConfig;
}
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
newContentGeneratorConfig.apiKey = geminiApiKey;
newContentGeneratorConfig.vertexai = false;
return newContentGeneratorConfig;
}
if (
authType === AuthType.USE_VERTEX_AI &&
(googleApiKey || (googleCloudProject && googleCloudLocation))
) {
newContentGeneratorConfig.apiKey = googleApiKey;
newContentGeneratorConfig.vertexai = true;
return newContentGeneratorConfig;
}
if (authType === AuthType.QWEN_OAUTH) { if (authType === AuthType.QWEN_OAUTH) {
// For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator // For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator
// Set a special marker to indicate this is Qwen OAuth // Set a special marker to indicate this is Qwen OAuth
newContentGeneratorConfig.apiKey = 'QWEN_OAUTH_DYNAMIC_TOKEN'; return {
newContentGeneratorConfig.model = DEFAULT_QWEN_MODEL; ...newContentGeneratorConfig,
model: DEFAULT_QWEN_MODEL,
return newContentGeneratorConfig; apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
} as ContentGeneratorConfig;
} }
return newContentGeneratorConfig; if (authType === AuthType.USE_OPENAI) {
if (!newContentGeneratorConfig.apiKey) {
throw new Error('OpenAI API key is required');
}
return {
...newContentGeneratorConfig,
model: newContentGeneratorConfig?.model || 'qwen3-coder-plus',
} as ContentGeneratorConfig;
}
return {
...newContentGeneratorConfig,
model: newContentGeneratorConfig?.model || DEFAULT_QWEN_MODEL,
} as ContentGeneratorConfig;
} }
export async function createContentGenerator( export async function createContentGenerator(

View File

@@ -1,2 +1,8 @@
export const DEFAULT_TIMEOUT = 120000; export const DEFAULT_TIMEOUT = 120000;
export const DEFAULT_MAX_RETRIES = 3; export const DEFAULT_MAX_RETRIES = 3;
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
export const DEFAULT_DASHSCOPE_BASE_URL =
'https://dashscope.aliyuncs.com/compatible-mode/v1';
export const DEFAULT_DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1';
export const DEFAULT_OPEN_ROUTER_BASE_URL = 'https://openrouter.ai/api/v1';

View File

@@ -2,7 +2,11 @@ import OpenAI from 'openai';
import type { Config } from '../../../config/config.js'; import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { AuthType } from '../../contentGenerator.js'; import { AuthType } from '../../contentGenerator.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js'; import {
DEFAULT_TIMEOUT,
DEFAULT_MAX_RETRIES,
DEFAULT_DASHSCOPE_BASE_URL,
} from '../constants.js';
import { tokenLimit } from '../../tokenLimits.js'; import { tokenLimit } from '../../tokenLimits.js';
import type { import type {
OpenAICompatibleProvider, OpenAICompatibleProvider,
@@ -53,7 +57,7 @@ export class DashScopeOpenAICompatibleProvider
buildClient(): OpenAI { buildClient(): OpenAI {
const { const {
apiKey, apiKey,
baseUrl, baseUrl = DEFAULT_DASHSCOPE_BASE_URL,
timeout = DEFAULT_TIMEOUT, timeout = DEFAULT_TIMEOUT,
maxRetries = DEFAULT_MAX_RETRIES, maxRetries = DEFAULT_MAX_RETRIES,
} = this.contentGeneratorConfig; } = this.contentGeneratorConfig;

View File

@@ -8,7 +8,7 @@ import { OpenAIContentGenerator } from '../core/openaiContentGenerator/index.js'
import { DashScopeOpenAICompatibleProvider } from '../core/openaiContentGenerator/provider/dashscope.js'; import { DashScopeOpenAICompatibleProvider } from '../core/openaiContentGenerator/provider/dashscope.js';
import type { IQwenOAuth2Client } from './qwenOAuth2.js'; import type { IQwenOAuth2Client } from './qwenOAuth2.js';
import { SharedTokenManager } from './sharedTokenManager.js'; import { SharedTokenManager } from './sharedTokenManager.js';
import type { Config } from '../config/config.js'; import { type Config } from '../config/config.js';
import type { import type {
GenerateContentParameters, GenerateContentParameters,
GenerateContentResponse, GenerateContentResponse,
@@ -18,10 +18,7 @@ import type {
EmbedContentResponse, EmbedContentResponse,
} from '@google/genai'; } from '@google/genai';
import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
// Default fallback base URL if no endpoint is provided
const DEFAULT_QWEN_BASE_URL =
'https://dashscope.aliyuncs.com/compatible-mode/v1';
/** /**
* Qwen Content Generator that uses Qwen OAuth tokens with automatic refresh * Qwen Content Generator that uses Qwen OAuth tokens with automatic refresh
@@ -58,7 +55,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
* Get the current endpoint URL with proper protocol and /v1 suffix * Get the current endpoint URL with proper protocol and /v1 suffix
*/ */
private getCurrentEndpoint(resourceUrl?: string): string { private getCurrentEndpoint(resourceUrl?: string): string {
const baseEndpoint = resourceUrl || DEFAULT_QWEN_BASE_URL; const baseEndpoint = resourceUrl || DEFAULT_DASHSCOPE_BASE_URL;
const suffix = '/v1'; const suffix = '/v1';
// Normalize the URL: add protocol if missing, ensure /v1 suffix // Normalize the URL: add protocol if missing, ensure /v1 suffix

View File

@@ -339,6 +339,7 @@ describe('editor utils', () => {
diffCommand.args, diffCommand.args,
{ {
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32',
}, },
); );
expect(mockSpawnOn).toHaveBeenCalledWith('close', expect.any(Function)); expect(mockSpawnOn).toHaveBeenCalledWith('close', expect.any(Function));

View File

@@ -195,6 +195,7 @@ export async function openDiff(
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const childProcess = spawn(diffCommand.command, diffCommand.args, { const childProcess = spawn(diffCommand.command, diffCommand.args, {
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32',
}); });
childProcess.on('close', (code) => { childProcess.on('close', (code) => {