mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Merge branch 'main' into feat/extension
This commit is contained in:
commit
4c7605d900
246 changed files with 20903 additions and 2406 deletions
|
|
@ -6,7 +6,11 @@
|
|||
|
||||
import { vi, type Mock, type MockInstance } from 'vitest';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
OutputFormat,
|
||||
FatalInputError,
|
||||
ToolErrorType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
getErrorMessage,
|
||||
handleError,
|
||||
|
|
@ -65,6 +69,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||
describe('errors', () => {
|
||||
let mockConfig: Config;
|
||||
let processExitSpy: MockInstance;
|
||||
let processStderrWriteSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -74,6 +79,11 @@ describe('errors', () => {
|
|||
// Mock console.error
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock process.stderr.write
|
||||
processStderrWriteSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
|
||||
// Mock process.exit to throw instead of actually exiting
|
||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit called with code: ${code}`);
|
||||
|
|
@ -84,11 +94,13 @@ describe('errors', () => {
|
|||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
||||
getDebugMode: vi.fn().mockReturnValue(true),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
processStderrWriteSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
|
@ -105,8 +117,33 @@ describe('errors', () => {
|
|||
expect(getErrorMessage(undefined)).toBe('undefined');
|
||||
});
|
||||
|
||||
it('should handle objects', () => {
|
||||
const obj = { message: 'test' };
|
||||
it('should extract message from error-like objects', () => {
|
||||
const obj = { message: 'test error message' };
|
||||
expect(getErrorMessage(obj)).toBe('test error message');
|
||||
});
|
||||
|
||||
it('should stringify plain objects without message property', () => {
|
||||
const obj = { code: 500, details: 'internal error' };
|
||||
expect(getErrorMessage(obj)).toBe(
|
||||
'{"code":500,"details":"internal error"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty objects', () => {
|
||||
expect(getErrorMessage({})).toBe('{}');
|
||||
});
|
||||
|
||||
it('should handle objects with non-string message property', () => {
|
||||
const obj = { message: 123 };
|
||||
expect(getErrorMessage(obj)).toBe('{"message":123}');
|
||||
});
|
||||
|
||||
it('should fallback to String() when toJSON returns undefined', () => {
|
||||
const obj = {
|
||||
toJSON() {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
expect(getErrorMessage(obj)).toBe('[object Object]');
|
||||
});
|
||||
});
|
||||
|
|
@ -432,6 +469,87 @@ describe('errors', () => {
|
|||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission denied warnings', () => {
|
||||
it('should show warning when EXECUTION_DENIED in non-interactive text mode', () => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
mockConfig,
|
||||
ToolErrorType.EXECUTION_DENIED,
|
||||
);
|
||||
|
||||
expect(processStderrWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Warning: Tool "test-tool" requires user approval',
|
||||
),
|
||||
);
|
||||
expect(processStderrWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('use the -y flag (YOLO mode)'),
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show warning when EXECUTION_DENIED in interactive mode', () => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||
(mockConfig.isInteractive as Mock).mockReturnValue(true);
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
mockConfig,
|
||||
ToolErrorType.EXECUTION_DENIED,
|
||||
);
|
||||
|
||||
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show warning when EXECUTION_DENIED in JSON mode', () => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
mockConfig,
|
||||
ToolErrorType.EXECUTION_DENIED,
|
||||
);
|
||||
|
||||
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show warning for non-EXECUTION_DENIED errors', () => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
mockConfig,
|
||||
ToolErrorType.FILE_NOT_FOUND,
|
||||
);
|
||||
|
||||
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCancellationError', () => {
|
||||
|
|
|
|||
|
|
@ -11,12 +11,36 @@ import {
|
|||
parseAndFormatApiError,
|
||||
FatalTurnLimitedError,
|
||||
FatalCancellationError,
|
||||
ToolErrorType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// Handle objects with message property (error-like objects)
|
||||
if (
|
||||
error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'message' in error &&
|
||||
typeof (error as { message: unknown }).message === 'string'
|
||||
) {
|
||||
return (error as { message: string }).message;
|
||||
}
|
||||
|
||||
// Handle plain objects by stringifying them
|
||||
if (error !== null && typeof error === 'object') {
|
||||
try {
|
||||
const stringified = JSON.stringify(error);
|
||||
// JSON.stringify can return undefined for objects with toJSON() returning undefined
|
||||
return stringified ?? String(error);
|
||||
} catch {
|
||||
// If JSON.stringify fails (circular reference, etc.), fall back to String
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
|
|
@ -102,10 +126,24 @@ export function handleToolError(
|
|||
toolName: string,
|
||||
toolError: Error,
|
||||
config: Config,
|
||||
_errorCode?: string | number,
|
||||
errorCode?: string | number,
|
||||
resultDisplay?: string,
|
||||
): void {
|
||||
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
|
||||
// Check if this is a permission denied error in non-interactive mode
|
||||
const isExecutionDenied = errorCode === ToolErrorType.EXECUTION_DENIED;
|
||||
const isNonInteractive = !config.isInteractive();
|
||||
const isTextMode = config.getOutputFormat() === OutputFormat.TEXT;
|
||||
|
||||
// Show warning for permission denied errors in non-interactive text mode
|
||||
if (isExecutionDenied && isNonInteractive && isTextMode) {
|
||||
const warningMessage =
|
||||
`Warning: Tool "${toolName}" requires user approval but cannot execute in non-interactive mode.\n` +
|
||||
`To enable automatic tool execution, use the -y flag (YOLO mode):\n` +
|
||||
`Example: qwen -p 'your prompt' -y\n\n`;
|
||||
process.stderr.write(warningMessage);
|
||||
}
|
||||
|
||||
// Always log detailed error in debug mode
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
||||
|
|
|
|||
150
packages/cli/src/utils/modelConfigUtils.ts
Normal file
150
packages/cli/src/utils/modelConfigUtils.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
type ContentGeneratorConfig,
|
||||
type ContentGeneratorConfigSources,
|
||||
resolveModelConfig,
|
||||
type ModelConfigSourcesInput,
|
||||
type ProviderModelConfig,
|
||||
} 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;
|
||||
}
|
||||
|
||||
export function getAuthTypeFromEnv(): AuthType | undefined {
|
||||
if (process.env['OPENAI_API_KEY']) {
|
||||
return AuthType.USE_OPENAI;
|
||||
}
|
||||
if (process.env['QWEN_OAUTH']) {
|
||||
return AuthType.QWEN_OAUTH;
|
||||
}
|
||||
|
||||
if (process.env['GEMINI_API_KEY']) {
|
||||
return AuthType.USE_GEMINI;
|
||||
}
|
||||
if (process.env['GOOGLE_API_KEY']) {
|
||||
return AuthType.USE_VERTEX_AI;
|
||||
}
|
||||
if (process.env['ANTHROPIC_API_KEY']) {
|
||||
return AuthType.USE_ANTHROPIC;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Find modelProvider from settings.modelProviders based on authType and model
|
||||
let modelProvider: ProviderModelConfig | undefined;
|
||||
if (authType && settings.modelProviders) {
|
||||
const providers = settings.modelProviders[authType];
|
||||
if (providers && Array.isArray(providers)) {
|
||||
// Try to find by requested model (from CLI or settings)
|
||||
const requestedModel = argv.model || settings.model?.name;
|
||||
if (requestedModel) {
|
||||
modelProvider = providers.find((p) => p.id === requestedModel) as
|
||||
| ProviderModelConfig
|
||||
| undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
modelProvider,
|
||||
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,7 +8,6 @@ import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { quote, parse } from 'shell-quote';
|
||||
import {
|
||||
|
|
@ -50,16 +49,16 @@ const BUILTIN_SEATBELT_PROFILES = [
|
|||
|
||||
/**
|
||||
* Determines whether the sandbox container should be run with the current user's UID and GID.
|
||||
* This is often necessary on Linux systems (especially Debian/Ubuntu based) when using
|
||||
* rootful Docker without userns-remap configured, to avoid permission issues with
|
||||
* This is often necessary on Linux systems when using rootful Docker without userns-remap
|
||||
* configured, to avoid permission issues with
|
||||
* mounted volumes.
|
||||
*
|
||||
* The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable:
|
||||
* - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`.
|
||||
* - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`.
|
||||
* - If `SANDBOX_SET_UID_GID` is not set:
|
||||
* - On Debian/Ubuntu Linux, it defaults to `true`.
|
||||
* - On other OSes, or if OS detection fails, it defaults to `false`.
|
||||
* - On Linux, it defaults to `true`.
|
||||
* - On other OSes, it defaults to `false`.
|
||||
*
|
||||
* For more context on running Docker containers as non-root, see:
|
||||
* https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15
|
||||
|
|
@ -76,31 +75,20 @@ async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
|
|||
return false;
|
||||
}
|
||||
|
||||
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
|
||||
if (os.platform() === 'linux') {
|
||||
try {
|
||||
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
|
||||
if (
|
||||
osReleaseContent.includes('ID=debian') ||
|
||||
osReleaseContent.includes('ID=ubuntu') ||
|
||||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
|
||||
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
|
||||
) {
|
||||
// note here and below we use console.error for informational messages on stderr
|
||||
console.error(
|
||||
'INFO: Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silently ignore if /etc/os-release is not found or unreadable.
|
||||
// The default (false) will be applied in this case.
|
||||
console.warn(
|
||||
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
|
||||
const debugEnv = [process.env['DEBUG'], process.env['DEBUG_MODE']].some(
|
||||
(v) => v === 'true' || v === '1',
|
||||
);
|
||||
if (debugEnv) {
|
||||
// Use stderr so it doesn't clutter normal STDOUT output (e.g. in `--prompt` runs).
|
||||
console.error(
|
||||
'INFO: Using current user UID/GID in Linux sandbox. Set SANDBOX_SET_UID_GID=false to disable.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false; // Default to false if no other condition is met
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// docker does not allow container names to contain ':' or '/', so we
|
||||
|
|
@ -372,10 +360,10 @@ export async function start_sandbox(
|
|||
//
|
||||
// note this can only be done with binary linked from gemini-cli repo
|
||||
if (process.env['BUILD_SANDBOX']) {
|
||||
if (!gcPath.includes('gemini-cli/packages/')) {
|
||||
if (!gcPath.includes('qwen-code/packages/')) {
|
||||
throw new FatalSandboxError(
|
||||
'Cannot build sandbox using installed gemini binary; ' +
|
||||
'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.',
|
||||
'Cannot build sandbox using installed Qwen Code binary; ' +
|
||||
'run `npm link ./packages/cli` under QwenCode-cli repo to switch to linked binary.',
|
||||
);
|
||||
} else {
|
||||
console.error('building sandbox ...');
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ describe('systemInfo', () => {
|
|||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getAuthType: vi.fn().mockReturnValue('test-auth'),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
baseUrl: 'https://api.openai.com',
|
||||
}),
|
||||
|
|
@ -273,6 +274,9 @@ describe('systemInfo', () => {
|
|||
// Update the mock context to use OpenAI auth
|
||||
mockContext.services.settings.merged.security!.auth!.selectedType =
|
||||
AuthType.USE_OPENAI;
|
||||
vi.mocked(mockContext.services.config!.getAuthType).mockReturnValue(
|
||||
AuthType.USE_OPENAI,
|
||||
);
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
|
|
|
|||
|
|
@ -115,8 +115,7 @@ export async function getSystemInfo(
|
|||
const sandboxEnv = getSandboxEnv();
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const selectedAuthType = context.services.config?.getAuthType() || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const sessionId = context.services.config?.getSessionId() || 'unknown';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue