mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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/"
|
||||
}
|
||||
|
||||
95
packages/sdk-typescript/scripts/build.js
Executable file
95
packages/sdk-typescript/scripts/build.js
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
147
packages/sdk-typescript/src/utils/logger.ts
Normal file
147
packages/sdk-typescript/src/utils/logger.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
14
packages/sdk-typescript/tsconfig.build.json
Normal file
14
packages/sdk-typescript/tsconfig.build.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user