feat: enhance logging capabilities and update query options in sdk-typescript

- Introduced a new logging system with adjustable log levels (debug, info, warn, error).
- Updated query options to include a logLevel parameter for controlling verbosity.
- Refactored existing code to utilize the new logging system for better error handling and debugging.
- Cleaned up unused code and improved the structure of the SDK.
This commit is contained in:
mingholy.lmh
2025-11-26 21:37:40 +08:00
parent 49dc84ac0e
commit 769a438fa4
16 changed files with 552 additions and 143 deletions

View File

@@ -170,7 +170,7 @@ export default tseslint.config(
},
// extra settings for scripts that we run directly with node
{
files: ['./scripts/**/*.js', 'esbuild.config.js'],
files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'],
languageOptions: {
globals: {
...globals.node,

View File

@@ -449,7 +449,8 @@ export async function main() {
}
const nonInteractiveConfig = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.selectedType ||
(argv.authType as AuthType),
settings.merged.security?.auth?.useExternal,
config,
settings,

View File

@@ -41,7 +41,7 @@ export async function validateNonInteractiveAuth(
}
const effectiveAuthType =
enforcedType || getAuthTypeFromEnv() || configuredAuthType;
enforcedType || configuredAuthType || getAuthTypeFromEnv();
if (!effectiveAuthType) {
const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`;

View File

@@ -2,14 +2,15 @@
"name": "@qwen-code/sdk-typescript",
"version": "0.1.0",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
@@ -19,14 +20,16 @@
"LICENSE"
],
"scripts": {
"build": "tsc",
"build": "node scripts/build.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src test",
"lint:fix": "eslint src test --fix",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"prepublishOnly": "npm run clean && npm run build"
"prepublishOnly": "npm run clean && npm run build",
"prepack": "npm run build"
},
"keywords": [
"qwen",
@@ -49,20 +52,23 @@
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"@vitest/coverage-v8": "^1.6.0",
"dts-bundle-generator": "^9.5.1",
"esbuild": "^0.25.12",
"eslint": "^8.57.0",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
"vitest": "^1.6.0",
"zod": "^3.23.8"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/qwen-ai/qwen-code.git",
"directory": "packages/sdk/typescript"
"url": "https://github.com/QwenLM/qwen-code.git",
"directory": "packages/sdk-typescript"
},
"bugs": {
"url": "https://github.com/qwen-ai/qwen-code/issues"
"url": "https://github.com/QwenLM/qwen-code/issues"
},
"homepage": "https://github.com/qwen-ai/qwen-code#readme"
"homepage": "https://qwenlm.github.io/qwen-code-docs/"
}

View File

@@ -0,0 +1,95 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import { rmSync, mkdirSync, existsSync, cpSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import esbuild from 'esbuild';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = join(__dirname, '..');
rmSync(join(rootDir, 'dist'), { recursive: true, force: true });
mkdirSync(join(rootDir, 'dist'), { recursive: true });
execSync('tsc --project tsconfig.build.json', {
stdio: 'inherit',
cwd: rootDir,
});
try {
execSync(
'npx dts-bundle-generator -o dist/index.d.ts src/index.ts --no-check',
{
stdio: 'inherit',
cwd: rootDir,
},
);
const dirsToRemove = ['mcp', 'query', 'transport', 'types', 'utils'];
for (const dir of dirsToRemove) {
const dirPath = join(rootDir, 'dist', dir);
if (existsSync(dirPath)) {
rmSync(dirPath, { recursive: true, force: true });
}
}
} catch (error) {
console.warn(
'Could not bundle type definitions, keeping separate .d.ts files',
error.message,
);
}
await esbuild.build({
entryPoints: [join(rootDir, 'src', 'index.ts')],
bundle: true,
format: 'esm',
platform: 'node',
target: 'node18',
outfile: join(rootDir, 'dist', 'index.mjs'),
external: ['@modelcontextprotocol/sdk'],
sourcemap: false,
minify: true,
minifyWhitespace: true,
minifyIdentifiers: true,
minifySyntax: true,
legalComments: 'none',
keepNames: false,
treeShaking: true,
});
await esbuild.build({
entryPoints: [join(rootDir, 'src', 'index.ts')],
bundle: true,
format: 'cjs',
platform: 'node',
target: 'node18',
outfile: join(rootDir, 'dist', 'index.cjs'),
external: ['@modelcontextprotocol/sdk'],
sourcemap: false,
minify: true,
minifyWhitespace: true,
minifyIdentifiers: true,
minifySyntax: true,
legalComments: 'none',
keepNames: false,
treeShaking: true,
});
const filesToCopy = ['README.md', 'LICENSE'];
for (const file of filesToCopy) {
const sourcePath = join(rootDir, '..', '..', file);
const targetPath = join(rootDir, 'dist', file);
if (existsSync(sourcePath)) {
try {
cpSync(sourcePath, targetPath);
} catch (error) {
console.warn(`Could not copy ${file}:`, error.message);
}
}
}

View File

@@ -1,10 +1,10 @@
export { query } from './query/createQuery.js';
export { AbortError, isAbortError } from './types/errors.js';
export { Query } from './query/Query.js';
export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js';
export { SdkLogger } from './utils/logger.js';
export type { QueryOptions } from './query/createQuery.js';
export type { LogLevel, LoggerConfig, ScopedLogger } from './utils/logger.js';
export type {
ContentBlock,
@@ -29,8 +29,9 @@ export {
} from './types/protocol.js';
export type {
JSONSchema,
PermissionMode,
CanUseTool,
PermissionResult,
ExternalMcpServerConfig,
SdkMcpServerConfig,
} from './types/types.js';

View File

@@ -9,6 +9,7 @@
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import { SdkLogger } from '../utils/logger.js';
export type SendToQueryCallback = (message: JSONRPCMessage) => Promise<void>;
@@ -21,6 +22,7 @@ export class SdkControlServerTransport {
sendToQuery: SendToQueryCallback;
private serverName: string;
private started = false;
private logger;
onmessage?: (message: JSONRPCMessage) => void;
onerror?: (error: Error) => void;
@@ -29,10 +31,14 @@ export class SdkControlServerTransport {
constructor(options: SdkControlServerTransportOptions) {
this.sendToQuery = options.sendToQuery;
this.serverName = options.serverName;
this.logger = SdkLogger.createLogger(
`SdkControlServerTransport:${options.serverName}`,
);
}
async start(): Promise<void> {
this.started = true;
this.logger.debug('Transport started');
}
async send(message: JSONRPCMessage): Promise<void> {
@@ -43,10 +49,10 @@ export class SdkControlServerTransport {
}
try {
// Send via Query's control plane
this.logger.debug('Sending message to Query', message);
await this.sendToQuery(message);
} catch (error) {
// Invoke error callback if set
this.logger.error('Error sending message:', error);
if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error)));
}
@@ -60,6 +66,7 @@ export class SdkControlServerTransport {
}
this.started = false;
this.logger.debug('Transport closed');
// Notify MCP Server
if (this.onclose) {
@@ -69,29 +76,22 @@ export class SdkControlServerTransport {
handleMessage(message: JSONRPCMessage): void {
if (!this.started) {
console.warn(
`[SdkControlServerTransport] Received message for closed transport (${this.serverName})`,
);
this.logger.warn('Received message for closed transport');
return;
}
this.logger.debug('Handling message from CLI', message);
if (this.onmessage) {
this.onmessage(message);
} else {
console.warn(
`[SdkControlServerTransport] No onmessage handler set for ${this.serverName}`,
);
this.logger.warn('No onmessage handler set');
}
}
handleError(error: Error): void {
this.logger.error('Transport error:', error);
if (this.onerror) {
this.onerror(error);
} else {
console.error(
`[SdkControlServerTransport] Error for ${this.serverName}:`,
error,
);
}
}

View File

@@ -11,6 +11,7 @@ const CONTROL_REQUEST_TIMEOUT = 30000;
const STREAM_CLOSE_TIMEOUT = 10000;
import { randomUUID } from 'node:crypto';
import { SdkLogger } from '../utils/logger.js';
import type {
CLIMessage,
CLIUserMessage,
@@ -30,7 +31,7 @@ import {
isControlCancel,
} from '../types/protocol.js';
import type { Transport } from '../transport/Transport.js';
import { type QueryOptions } from '../types/queryOptionsSchema.js';
import type { QueryOptions } from '../types/types.js';
import { Stream } from '../utils/Stream.js';
import { serializeJsonLine } from '../utils/jsonLines.js';
import { AbortError } from '../types/errors.js';
@@ -49,6 +50,8 @@ interface TransportWithEndInput extends Transport {
endInput(): void;
}
const logger = SdkLogger.createLogger('Query');
export class Query implements AsyncIterable<CLIMessage> {
private transport: Transport;
private options: QueryOptions;
@@ -101,13 +104,13 @@ export class Query implements AsyncIterable<CLIMessage> {
if (this.abortController.signal.aborted) {
this.inputStream.error(new AbortError('Query aborted by user'));
this.close().catch((err) => {
console.error('[Query] Error during abort cleanup:', err);
logger.error('Error during abort cleanup:', err);
});
} else {
this.abortController.signal.addEventListener('abort', () => {
this.inputStream.error(new AbortError('Query aborted by user'));
this.close().catch((err) => {
console.error('[Query] Error during abort cleanup:', err);
logger.error('Error during abort cleanup:', err);
});
});
}
@@ -120,7 +123,7 @@ export class Query implements AsyncIterable<CLIMessage> {
private async initialize(): Promise<void> {
try {
await this.setupSdkMcpServers();
logger.debug('Initializing Query');
const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys());
@@ -131,52 +134,13 @@ export class Query implements AsyncIterable<CLIMessage> {
mcpServers: this.options.mcpServers,
agents: this.options.agents,
});
logger.info('Query initialized successfully');
} catch (error) {
console.error('[Query] Initialization error:', error);
logger.error('Initialization error:', error);
throw error;
}
}
private async setupSdkMcpServers(): Promise<void> {
if (!this.options.sdkMcpServers) {
return;
}
const externalNames = Object.keys(this.options.mcpServers ?? {});
const sdkNames = Object.keys(this.options.sdkMcpServers);
const conflicts = sdkNames.filter((name) => externalNames.includes(name));
if (conflicts.length > 0) {
throw new Error(
`MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`,
);
}
/**
* Import SdkControlServerTransport dynamically to avoid circular dependencies.
* Create transport for each server that sends MCP messages via control plane.
*/
const { SdkControlServerTransport } = await import(
'../mcp/SdkControlServerTransport.js'
);
for (const [name, server] of Object.entries(this.options.sdkMcpServers)) {
const transport = new SdkControlServerTransport({
serverName: name,
sendToQuery: async (message: JSONRPCMessage) => {
await this.sendControlRequest(ControlRequestType.MCP_MESSAGE, {
server_name: name,
message,
});
},
});
await transport.start();
await server.connect(transport);
this.sdkMcpTransports.set(name, transport);
}
}
private startMessageRouter(): void {
if (this.messageRouterStarted) {
return;
@@ -256,9 +220,7 @@ export class Query implements AsyncIterable<CLIMessage> {
return;
}
if (process.env['DEBUG']) {
console.warn('[Query] Unknown message type:', message);
}
logger.warn('Unknown message type:', message);
this.inputStream.enqueue(message as CLIMessage);
}
@@ -267,6 +229,7 @@ export class Query implements AsyncIterable<CLIMessage> {
): Promise<void> {
const { request_id, request: payload } = request;
logger.debug(`Handling control request: ${payload.subtype}`);
const requestAbortController = new AbortController();
try {
@@ -299,6 +262,7 @@ export class Query implements AsyncIterable<CLIMessage> {
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(`Control request error (${payload.subtype}):`, errorMessage);
await this.sendControlResponse(request_id, false, errorMessage);
}
}
@@ -369,8 +333,8 @@ export class Query implements AsyncIterable<CLIMessage> {
*/
const errorMessage =
error instanceof Error ? error.message : String(error);
console.warn(
'[Query] Permission callback error (denying by default):',
logger.warn(
'Permission callback error (denying by default):',
errorMessage,
);
return {
@@ -448,9 +412,10 @@ export class Query implements AsyncIterable<CLIMessage> {
const pending = this.pendingControlRequests.get(request_id);
if (!pending) {
console.warn(
'[Query] Received response for unknown request:',
logger.warn(
'Received response for unknown request:',
request_id,
JSON.stringify(payload),
);
return;
}
@@ -459,6 +424,9 @@ export class Query implements AsyncIterable<CLIMessage> {
this.pendingControlRequests.delete(request_id);
if (payload.subtype === 'success') {
logger.debug(
`Control response success for request: ${request_id}: ${JSON.stringify(payload.response)}`,
);
pending.resolve(payload.response as Record<string, unknown> | null);
} else {
/**
@@ -469,6 +437,10 @@ export class Query implements AsyncIterable<CLIMessage> {
typeof payload.error === 'string'
? payload.error
: (payload.error?.message ?? 'Unknown error');
logger.error(
`Control response error for request ${request_id}:`,
errorMessage,
);
pending.reject(new Error(errorMessage));
}
}
@@ -477,12 +449,13 @@ export class Query implements AsyncIterable<CLIMessage> {
const { request_id } = request;
if (!request_id) {
console.warn('[Query] Received cancel request without request_id');
logger.warn('Received cancel request without request_id');
return;
}
const pending = this.pendingControlRequests.get(request_id);
if (pending) {
logger.debug(`Cancelling control request: ${request_id}`);
pending.abortController.abort();
clearTimeout(pending.timeout);
this.pendingControlRequests.delete(request_id);
@@ -580,10 +553,11 @@ export class Query implements AsyncIterable<CLIMessage> {
try {
await transport.close();
} catch (error) {
console.error('[Query] Error closing MCP transport:', error);
logger.error('Error closing MCP transport:', error);
}
}
this.sdkMcpTransports.clear();
logger.info('Query closed');
}
private async *readSdkMessages(): AsyncGenerator<CLIMessage> {
@@ -652,7 +626,7 @@ export class Query implements AsyncIterable<CLIMessage> {
this.endInput();
} catch (error) {
if (this.abortController.signal.aborted) {
console.log('[Query] Aborted during input streaming');
logger.info('Aborted during input streaming');
this.inputStream.error(
new AbortError('Query aborted during input streaming'),
);

View File

@@ -7,18 +7,29 @@ import { serializeJsonLine } from '../utils/jsonLines.js';
import { ProcessTransport } from '../transport/ProcessTransport.js';
import { parseExecutableSpec } from '../utils/cliPath.js';
import { Query } from './Query.js';
import {
QueryOptionsSchema,
type QueryOptions,
} from '../types/queryOptionsSchema.js';
import type { QueryOptions } from '../types/types.js';
import { QueryOptionsSchema } from '../types/queryOptionsSchema.js';
import { SdkLogger } from '../utils/logger.js';
export type { QueryOptions };
const logger = SdkLogger.createLogger('createQuery');
export function query({
prompt,
options = {},
}: {
/**
* The prompt to send to the Qwen Code CLI process.
* - `string` for single-turn query,
* - `AsyncIterable<CLIUserMessage>` for multi-turn query.
*
* The transport will remain open until the prompt is done.
*/
prompt: string | AsyncIterable<CLIUserMessage>;
/**
* Configuration options for the query.
*/
options?: QueryOptions;
}): Query {
const parsedExecutable = validateOptions(options);
@@ -39,6 +50,7 @@ export function query({
abortController,
debug: options.debug,
stderr: options.stderr,
logLevel: options.logLevel,
maxSessionTurns: options.maxSessionTurns,
coreTools: options.coreTools,
excludeTools: options.excludeTools,
@@ -70,14 +82,14 @@ export function query({
await queryInstance.initialized;
transport.write(serializeJsonLine(message));
} catch (err) {
console.error('[query] Error sending single-turn prompt:', err);
logger.error('Error sending single-turn prompt:', err);
}
})();
} else {
queryInstance
.streamInput(prompt as AsyncIterable<CLIUserMessage>)
.catch((err) => {
console.error('[query] Error streaming input:', err);
logger.error('Error streaming input:', err);
});
}
@@ -103,17 +115,5 @@ function validateOptions(
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
}
if (options.mcpServers && options.sdkMcpServers) {
const externalNames = Object.keys(options.mcpServers);
const sdkNames = Object.keys(options.sdkMcpServers);
const conflicts = externalNames.filter((name) => sdkNames.includes(name));
if (conflicts.length > 0) {
throw new Error(
`MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`,
);
}
}
return parsedExecutable;
}

View File

@@ -6,6 +6,9 @@ import type { Transport } from './Transport.js';
import { parseJsonLinesStream } from '../utils/jsonLines.js';
import { prepareSpawnInfo } from '../utils/cliPath.js';
import { AbortError } from '../types/errors.js';
import { SdkLogger } from '../utils/logger.js';
const logger = SdkLogger.createLogger('ProcessTransport');
export class ProcessTransport implements Transport {
private childProcess: ChildProcess | null = null;
@@ -23,6 +26,11 @@ export class ProcessTransport implements Transport {
this.options = options;
this.abortController =
this.options.abortController ?? new AbortController();
SdkLogger.configure({
debug: options.debug,
stderr: options.stderr,
logLevel: options.logLevel,
});
this.initialize();
}
@@ -41,7 +49,7 @@ export class ProcessTransport implements Transport {
const stderrMode =
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
this.logForDebugging(
logger.debug(
`Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`,
);
@@ -61,7 +69,7 @@ export class ProcessTransport implements Transport {
if (this.options.debug || this.options.stderr) {
this.childProcess.stderr?.on('data', (data) => {
this.logForDebugging(data.toString());
logger.debug(data.toString());
});
}
@@ -79,8 +87,10 @@ export class ProcessTransport implements Transport {
this.setupEventHandlers();
this.ready = true;
logger.info('CLI process started successfully');
} catch (error) {
this.ready = false;
logger.error('Failed to initialize CLI process:', error);
throw error;
}
}
@@ -94,7 +104,7 @@ export class ProcessTransport implements Transport {
this._exitError = new AbortError('CLI process aborted by user');
} else {
this._exitError = new Error(`CLI process error: ${error.message}`);
this.logForDebugging(this._exitError.message);
logger.error(this._exitError.message);
}
});
@@ -106,7 +116,7 @@ export class ProcessTransport implements Transport {
const error = this.getProcessExitError(code, signal);
if (error) {
this._exitError = error;
this.logForDebugging(error.message);
logger.error(error.message);
}
}
});
@@ -269,28 +279,24 @@ export class ProcessTransport implements Transport {
);
}
if (process.env['DEBUG']) {
this.logForDebugging(
`[ProcessTransport] Writing to stdin (${message.length} bytes): ${message.substring(0, 100)}`,
);
}
logger.debug(
`Writing to stdin (${message.length} bytes): ${message.trim()}`,
);
try {
const written = this.childStdin.write(message);
if (!written) {
this.logForDebugging(
`[ProcessTransport] Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`,
);
} else if (process.env['DEBUG']) {
this.logForDebugging(
`[ProcessTransport] Write successful (${message.length} bytes)`,
logger.warn(
`Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`,
);
} else {
logger.debug(`Write successful (${message.length} bytes)`);
}
} catch (error) {
this.ready = false;
throw new Error(
`Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`,
);
const errorMsg = `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`;
logger.error(errorMsg);
throw new Error(errorMsg);
}
}
@@ -340,13 +346,4 @@ export class ProcessTransport implements Transport {
getOutputStream(): Readable | undefined {
return this.childStdout || undefined;
}
private logForDebugging(message: string): void {
if (this.options.debug || process.env['DEBUG']) {
process.stderr.write(`[ProcessTransport] ${message}\n`);
}
if (this.options.stderr) {
this.options.stderr(message);
}
}
}

View File

@@ -50,7 +50,6 @@ export const QueryOptionsSchema = z
})
.optional(),
mcpServers: z.record(z.string(), ExternalMcpServerConfigSchema).optional(),
sdkMcpServers: z.record(z.string(), SdkMcpServerConfigSchema).optional(),
abortController: z.instanceof(AbortController).optional(),
debug: z.boolean().optional(),
stderr: z
@@ -58,6 +57,7 @@ export const QueryOptionsSchema = z
(message: string) => void
>((val) => typeof val === 'function', { message: 'stderr must be a function' })
.optional(),
logLevel: z.enum(['debug', 'info', 'warn', 'error']).optional(),
maxSessionTurns: z.number().optional(),
coreTools: z.array(z.string()).optional(),
excludeTools: z.array(z.string()).optional(),
@@ -79,8 +79,3 @@ export const QueryOptionsSchema = z
includePartialMessages: z.boolean().optional(),
})
.strict();
export type ExternalMcpServerConfig = z.infer<
typeof ExternalMcpServerConfigSchema
>;
export type QueryOptions = z.infer<typeof QueryOptionsSchema>;

View File

@@ -1,8 +1,12 @@
import type { PermissionMode, PermissionSuggestion } from './protocol.js';
import type {
PermissionMode,
PermissionSuggestion,
SubagentConfig,
} from './protocol.js';
export type { PermissionMode };
export type JSONSchema = {
type JSONSchema = {
type: string;
properties?: Record<string, unknown>;
required?: string[];
@@ -26,6 +30,7 @@ export type TransportOptions = {
abortController?: AbortController;
debug?: boolean;
stderr?: (message: string) => void;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
maxSessionTurns?: number;
coreTools?: string[];
excludeTools?: string[];
@@ -54,3 +59,172 @@ export type PermissionResult =
message: string;
interrupt?: boolean;
};
export interface ExternalMcpServerConfig {
command: string;
args?: string[];
env?: Record<string, string>;
}
export interface SdkMcpServerConfig {
connect: (transport: unknown) => Promise<void>;
}
/**
* Configuration options for creating a query session with the Qwen CLI.
*/
export interface QueryOptions {
/**
* The working directory for the query session.
* This determines the context in which file operations and commands are executed.
* @default process.cwd()
*/
cwd?: string;
/**
* The AI model to use for the query session.
* This takes precedence over the environment variables `OPENAI_MODEL` and `QWEN_MODEL`
* @example 'qwen-max', 'qwen-plus', 'qwen-turbo'
*/
model?: string;
/**
* Path to the Qwen CLI executable or runtime specification.
*
* Supports multiple formats:
* - 'qwen' -> native binary (auto-detected from PATH)
* - '/path/to/qwen' -> native binary (explicit path)
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
* - 'bun:/path/to/cli.js' -> Force Bun runtime
* - 'node:/path/to/cli.js' -> Force Node.js runtime
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
*
* If not provided, the SDK will auto-detect the native binary in this order:
* 1. QWEN_CODE_CLI_PATH environment variable
* 2. ~/.volta/bin/qwen
* 3. ~/.npm-global/bin/qwen
* 4. /usr/local/bin/qwen
* 5. ~/.local/bin/qwen
* 6. ~/node_modules/.bin/qwen
* 7. ~/.yarn/bin/qwen
*
* The .ts files are only supported for debugging purposes.
*
* @example 'qwen'
* @example '/usr/local/bin/qwen'
* @example 'tsx:/path/to/packages/cli/src/index.ts'
*/
pathToQwenExecutable?: string;
/**
* Environment variables to pass to the Qwen CLI process.
* These variables will be merged with the current process environment.
*/
env?: Record<string, string>;
/**
* Alias for `approval-mode` command line argument.
* Behaves slightly differently from the command line argument.
* Permission mode controlling how the CLI handles tool usage and file operations **in non-interactive mode**.
* - 'default': Automatically deny all write-like tools(edit, write_file, etc.) and dangers commands.
* - 'plan': Shows a plan before executing operations
* - 'auto-edit': Automatically applies edits without confirmation
* - 'yolo': Executes all operations without prompting
* @default 'default'
*/
permissionMode?: 'default' | 'plan' | 'auto-edit' | 'yolo';
/**
* Custom permission handler for tool usage.
* This function is called when the SDK needs to determine if a tool should be allowed.
* Use this with `permissionMode` to gain more control over the tool usage.
* TODO: For now we don't support modifying the input.
*/
canUseTool?: CanUseTool;
/**
* External MCP (Model Context Protocol) servers to connect to.
* Each server is identified by a unique name and configured with command, args, and environment.
* @example { 'my-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } } }
*/
mcpServers?: Record<string, ExternalMcpServerConfig>;
/**
* AbortController to cancel the query session.
* Call abortController.abort() to terminate the session and cleanup resources.
* Remember to handle the AbortError when the session is aborted.
*/
abortController?: AbortController;
/**
* Enable debug mode for verbose logging.
* When true, additional diagnostic information will be output.
* Use this with `logLevel` to control the verbosity of the logs.
* @default false
*/
debug?: boolean;
/**
* Custom handler for stderr output from the Qwen CLI process.
* Use this to capture and process error messages or diagnostic output.
*/
stderr?: (message: string) => void;
/**
* Logging level for the SDK.
* Controls the verbosity of log messages output by the SDK.
* @default 'info'
*/
logLevel?: 'debug' | 'info' | 'warn' | 'error';
/**
* Maximum number of conversation turns before the session automatically terminates.
* A turn consists of a user message and an assistant response.
* @default -1 (unlimited)
*/
maxSessionTurns?: number;
/**
* Equivalent to `tool.core` in settings.json.
* List of core tools to enable for the session.
* If specified, only these tools will be available to the AI.
* @example ['read_file', 'write_file', 'run_terminal_cmd']
*/
coreTools?: string[];
/**
* Equivalent to `tool.exclude` in settings.json.
* List of tools to exclude from the session.
* These tools will not be available to the AI, even if they are core tools.
* @example ['run_terminal_cmd', 'delete_file']
*/
excludeTools?: string[];
/**
* Authentication type for the AI service.
* - 'openai': Use OpenAI-compatible authentication
* - 'qwen-oauth': Use Qwen OAuth authentication
*
* Though we support 'qwen-oauth', it's not recommended to use it in the SDK.
* Because the credentials are stored in `~/.qwen` and may need to refresh periodically.
*/
authType?: 'openai' | 'qwen-oauth';
/**
* Configuration for subagents that can be invoked during the session.
* Subagents are specialized AI agents that can handle specific tasks or domains.
* The invocation is marked as a `task` tool use with the name of agent and a tool_use_id.
* The tool use of these agent is marked with the parent_tool_use_id of the `task` tool use.
*/
agents?: SubagentConfig[];
/**
* Include partial messages in the response stream.
* When true, the SDK will emit incomplete messages as they are being generated,
* allowing for real-time streaming of the AI's response.
* @default false
*/
includePartialMessages?: boolean;
}

View File

@@ -1,3 +1,5 @@
import { SdkLogger } from './logger.js';
export function serializeJsonLine(message: unknown): string {
try {
return JSON.stringify(message) + '\n';
@@ -12,11 +14,12 @@ export function parseJsonLineSafe(
line: string,
context = 'JsonLines',
): unknown | null {
const logger = SdkLogger.createLogger(context);
try {
return JSON.parse(line);
} catch (error) {
console.warn(
`[${context}] Failed to parse JSON line, skipping:`,
logger.warn(
'Failed to parse JSON line, skipping:',
line.substring(0, 100),
error instanceof Error ? error.message : String(error),
);
@@ -37,6 +40,7 @@ export async function* parseJsonLinesStream(
lines: AsyncIterable<string>,
context = 'JsonLines',
): AsyncGenerator<unknown, void, unknown> {
const logger = SdkLogger.createLogger(context);
for await (const line of lines) {
if (line.trim().length === 0) {
continue;
@@ -49,8 +53,8 @@ export async function* parseJsonLinesStream(
}
if (!isValidMessage(message)) {
console.warn(
`[${context}] Invalid message structure (missing 'type' field), skipping:`,
logger.warn(
"Invalid message structure (missing 'type' field), skipping:",
line.substring(0, 100),
);
continue;

View File

@@ -0,0 +1,147 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface LoggerConfig {
debug?: boolean;
stderr?: (message: string) => void;
logLevel?: LogLevel;
}
export interface ScopedLogger {
debug(message: string, ...args: unknown[]): void;
info(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
export class SdkLogger {
private static config: LoggerConfig = {};
private static effectiveLevel: LogLevel = 'info';
static configure(config: LoggerConfig): void {
this.config = config;
this.effectiveLevel = this.determineLogLevel();
}
private static determineLogLevel(): LogLevel {
if (this.config.logLevel) {
return this.config.logLevel;
}
if (this.config.debug) {
return 'debug';
}
const envLevel = process.env['DEBUG_QWEN_CODE_SDK_LEVEL'];
if (envLevel && this.isValidLogLevel(envLevel)) {
return envLevel as LogLevel;
}
if (process.env['DEBUG_QWEN_CODE_SDK']) {
return 'debug';
}
return 'info';
}
private static isValidLogLevel(level: string): boolean {
return ['debug', 'info', 'warn', 'error'].includes(level);
}
private static shouldLog(level: LogLevel): boolean {
return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.effectiveLevel];
}
private static formatTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
private static formatMessage(
level: LogLevel,
scope: string,
message: string,
args: unknown[],
): string {
const timestamp = this.formatTimestamp();
const levelStr = `[${level.toUpperCase()}]`.padEnd(7);
let fullMessage = `${timestamp} ${levelStr} [${scope}] ${message}`;
if (args.length > 0) {
const argsStr = args
.map((arg) => {
if (typeof arg === 'string') {
return arg;
}
if (arg instanceof Error) {
return arg.message;
}
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
})
.join(' ');
fullMessage += ` ${argsStr}`;
}
return fullMessage;
}
private static log(
level: LogLevel,
scope: string,
message: string,
args: unknown[],
): void {
if (!this.shouldLog(level)) {
return;
}
const formattedMessage = this.formatMessage(level, scope, message, args);
if (this.config.stderr) {
this.config.stderr(formattedMessage);
} else {
if (level === 'warn' || level === 'error') {
process.stderr.write(formattedMessage + '\n');
} else {
process.stdout.write(formattedMessage + '\n');
}
}
}
static createLogger(scope: string): ScopedLogger {
return {
debug: (message: string, ...args: unknown[]) => {
this.log('debug', scope, message, args);
},
info: (message: string, ...args: unknown[]) => {
this.log('info', scope, message, args);
},
warn: (message: string, ...args: unknown[]) => {
this.log('warn', scope, message, args);
},
error: (message: string, ...args: unknown[]) => {
this.log('error', scope, message, args);
},
};
}
static getEffectiveLevel(): LogLevel {
return this.effectiveLevel;
}
}

View File

@@ -39,7 +39,8 @@ describe('Single-Turn Query (E2E)', () => {
prompt: 'What is 2 + 2? Just give me the number.',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
debug: true,
logLevel: 'debug',
},
});

View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": false,
"sourceMap": false,
"emitDeclarationOnly": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"]
}