From f9f6eb52dd3244edf15e85131a2d2556c0092f15 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Fri, 24 Oct 2025 17:16:14 +0800 Subject: [PATCH 01/25] feat: add multi websearch provider --- packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 19 ++ packages/core/src/config/config.ts | 52 +++- packages/core/src/index.ts | 2 +- .../src/tools/web-search/base-provider.ts | 101 ++++++++ packages/core/src/tools/web-search/index.ts | 235 ++++++++++++++++++ .../src/tools/web-search/provider-factory.ts | 110 ++++++++ .../providers/dashscope-provider.ts | 138 ++++++++++ .../web-search/providers/google-provider.ts | 96 +++++++ .../web-search/providers/tavily-provider.ts | 86 +++++++ packages/core/src/tools/web-search/types.ts | 119 +++++++++ 11 files changed, 955 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/tools/web-search/base-provider.ts create mode 100644 packages/core/src/tools/web-search/index.ts create mode 100644 packages/core/src/tools/web-search/provider-factory.ts create mode 100644 packages/core/src/tools/web-search/providers/dashscope-provider.ts create mode 100644 packages/core/src/tools/web-search/providers/google-provider.ts create mode 100644 packages/core/src/tools/web-search/providers/tavily-provider.ts create mode 100644 packages/core/src/tools/web-search/types.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7296ff43..9655a1ec 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -771,6 +771,7 @@ export async function loadCliConfig( output: { format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, + webSearch: settings.webSearch, }); } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 34ebe4b0..4265cc5c 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1055,6 +1055,25 @@ const SETTINGS_SCHEMA = { }, }, + webSearch: { + type: 'object', + label: 'Web Search', + category: 'Advanced', + requiresRestart: true, + default: undefined as + | { + provider: Array<{ + type: 'tavily' | 'google' | 'dashscope'; + apiKey?: string; + searchEngineId?: string; + }>; + default: string; + } + | undefined, + description: 'Configuration for web search providers.', + showInDialog: false, + }, + experimental: { type: 'object', label: 'Experimental', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4bc2b902..75ec3766 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -56,7 +56,7 @@ import { TaskTool } from '../tools/task.js'; import { TodoWriteTool } from '../tools/todoWrite.js'; import { ToolRegistry } from '../tools/tool-registry.js'; import { WebFetchTool } from '../tools/web-fetch.js'; -import { WebSearchTool } from '../tools/web-search.js'; +import { WebSearchTool } from '../tools/web-search/index.js'; import { WriteFileTool } from '../tools/write-file.js'; // Other modules @@ -261,6 +261,14 @@ export interface ConfigParameters { loadMemoryFromIncludeDirectories?: boolean; // Web search providers tavilyApiKey?: string; + webSearch?: { + provider: Array<{ + type: 'tavily' | 'google' | 'dashscope'; + apiKey?: string; + searchEngineId?: string; + }>; + default: string; + }; chatCompression?: ChatCompressionSettings; interactive?: boolean; trustedFolder?: boolean; @@ -349,6 +357,14 @@ export class Config { private readonly experimentalZedIntegration: boolean = false; private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly tavilyApiKey?: string; + private readonly webSearch?: { + provider: Array<{ + type: 'tavily' | 'google' | 'dashscope'; + apiKey?: string; + searchEngineId?: string; + }>; + default: string; + }; private readonly chatCompression: ChatCompressionSettings | undefined; private readonly interactive: boolean; private readonly trustedFolder: boolean | undefined; @@ -454,6 +470,7 @@ export class Config { // Web search this.tavilyApiKey = params.tavilyApiKey; + this.webSearch = params.webSearch; this.useRipgrep = params.useRipgrep ?? true; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; @@ -891,6 +908,31 @@ export class Config { return this.tavilyApiKey; } + getWebSearchConfig(): + | { + provider: Array<{ + type: 'tavily' | 'google' | 'dashscope'; + config: Record; + }>; + default: string; + } + | undefined { + if (!this.webSearch) { + return undefined; + } + + return { + provider: this.webSearch.provider.map((p) => ({ + type: p.type, + config: { + apiKey: p.apiKey, + searchEngineId: p.searchEngineId, + }, + })), + default: this.webSearch.default, + }; + } + getIdeMode(): boolean { return this.ideMode; } @@ -1118,8 +1160,12 @@ export class Config { registerCoreTool(TodoWriteTool, this); registerCoreTool(ExitPlanModeTool, this); registerCoreTool(WebFetchTool, this); - // Conditionally register web search tool only if Tavily API key is set - if (this.getTavilyApiKey()) { + // Conditionally register web search tool if any web search provider is configured + // or if using qwen-oauth authentication + if ( + this.getWebSearchConfig() || + this.getAuthType() === AuthType.QWEN_OAUTH + ) { registerCoreTool(WebSearchTool, this); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 38787dc1..8a538bfe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,7 +97,7 @@ export * from './tools/write-file.js'; export * from './tools/web-fetch.js'; export * from './tools/memoryTool.js'; export * from './tools/shell.js'; -export * from './tools/web-search.js'; +export * from './tools/web-search/index.js'; export * from './tools/read-many-files.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-tool.js'; diff --git a/packages/core/src/tools/web-search/base-provider.ts b/packages/core/src/tools/web-search/base-provider.ts new file mode 100644 index 00000000..63e8aafc --- /dev/null +++ b/packages/core/src/tools/web-search/base-provider.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + WebSearchProvider, + WebSearchResult, + WebSearchResultItem, +} from './types.js'; +import { getErrorMessage } from '../../utils/errors.js'; + +/** + * Base implementation for web search providers. + */ +export abstract class BaseWebSearchProvider implements WebSearchProvider { + abstract readonly name: string; + + /** + * Check if the provider is available (has required configuration). + */ + abstract isAvailable(): boolean; + + /** + * Perform the actual search implementation. + * @param query The search query + * @param signal Abort signal for cancellation + * @returns Promise resolving to search results + */ + protected abstract performSearch( + query: string, + signal: AbortSignal, + ): Promise; + + /** + * Execute a web search with error handling. + * @param query The search query + * @param signal Abort signal for cancellation + * @returns Promise resolving to search results + */ + async search(query: string, signal: AbortSignal): Promise { + if (!this.isAvailable()) { + throw new Error( + `${this.name} provider is not available. Please check your configuration.`, + ); + } + + try { + return await this.performSearch(query, signal); + } catch (error: unknown) { + const errorMessage = getErrorMessage(error); + throw new Error(`Error during ${this.name} search: ${errorMessage}`); + } + } + + /** + * Format search results into a consistent format. + * @param results Raw results from the provider + * @param query The original search query + * @returns Formatted search results + */ + protected formatResults( + results: WebSearchResultItem[], + query: string, + answer?: string, + ): WebSearchResult { + return { + query, + answer, + results, + }; + } + + /** + * Create a formatted source list for display. + * @param results Search result items + * @returns Formatted source list + */ + protected createSourceList(results: WebSearchResultItem[]): string { + return results + .map((r, i) => `[${i + 1}] ${r.title || 'Untitled'} (${r.url})`) + .join('\n'); + } + + /** + * Build a concise summary from search results. + * @param results Search result items + * @param maxResults Maximum number of results to include + * @returns Concise summary string + */ + protected buildSummary( + results: WebSearchResultItem[], + maxResults: number = 3, + ): string { + return results + .slice(0, maxResults) + .map((r, i) => `${i + 1}. ${r.title} - ${r.url}`) + .join('\n'); + } +} diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts new file mode 100644 index 00000000..3aba841f --- /dev/null +++ b/packages/core/src/tools/web-search/index.ts @@ -0,0 +1,235 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolInvocation, + type ToolCallConfirmationDetails, + type ToolInfoConfirmationDetails, + ToolConfirmationOutcome, +} from '../tools.js'; + +import type { Config } from '../../config/config.js'; +import { ApprovalMode } from '../../config/config.js'; +import { getErrorMessage } from '../../utils/errors.js'; +import { WebSearchProviderFactory } from './provider-factory.js'; +import type { + WebSearchToolParams, + WebSearchToolResult, + WebSearchProvider, + WebSearchResultItem, +} from './types.js'; + +class WebSearchToolInvocation extends BaseToolInvocation< + WebSearchToolParams, + WebSearchToolResult +> { + constructor( + private readonly config: Config, + params: WebSearchToolParams, + ) { + super(params); + } + + override getDescription(): string { + // Try to determine which provider will be used + const webSearchConfig = this.config.getWebSearchConfig(); + const provider = + this.params.provider || webSearchConfig?.default || 'tavily'; + return ` (Searching the web via ${provider})`; + } + + override async shouldConfirmExecute( + _abortSignal: AbortSignal, + ): Promise { + if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { + return false; + } + + const confirmationDetails: ToolInfoConfirmationDetails = { + type: 'info', + title: 'Confirm Web Search', + prompt: `Search the web for: "${this.params.query}"`, + onConfirm: async (outcome: ToolConfirmationOutcome) => { + if (outcome === ToolConfirmationOutcome.ProceedAlways) { + this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); + } + }, + }; + return confirmationDetails; + } + + async execute(signal: AbortSignal): Promise { + const webSearchConfig = this.config.getWebSearchConfig(); + if (!webSearchConfig) { + return { + llmContent: + 'Web search is disabled because no web search configuration is available. Please configure web search providers in your settings.json.', + returnDisplay: + 'Web search disabled. Configure web search providers to enable search.', + }; + } + + const providers = WebSearchProviderFactory.createProviders( + webSearchConfig.provider, + ); + + // Determine which provider to use + let selectedProvider: WebSearchProvider | null = null; + + if (this.params.provider) { + // Use the specified provider if available + const provider = providers.get(this.params.provider); + if (provider && provider.isAvailable()) { + selectedProvider = provider; + } else { + return { + llmContent: `The specified provider "${this.params.provider}" is not available or not configured. Available providers: ${Array.from(providers.keys()).join(', ')}`, + returnDisplay: `The WebSearch Provider "${this.params.provider}" not available.`, + }; + } + } else { + // Use default provider + selectedProvider = WebSearchProviderFactory.getDefaultProvider( + providers, + webSearchConfig.default, + ); + } + + if (!selectedProvider) { + return { + llmContent: + 'Web search is disabled because no web search providers are available. Please check your configuration.', + returnDisplay: 'Web search disabled. No available providers.', + }; + } + + try { + const searchResult = await selectedProvider.search( + this.params.query, + signal, + ); + + const sources = searchResult.results.map((r: WebSearchResultItem) => ({ + title: r.title, + url: r.url, + })); + + const sourceListFormatted = sources.map( + (s: { title: string; url: string }, i: number) => + `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`, + ); + + let content = searchResult.answer?.trim() || ''; + if (!content) { + // Fallback: build a concise summary from top results + content = sources + .slice(0, 3) + .map( + (s: { title: string; url: string }, i: number) => + `${i + 1}. ${s.title} - ${s.url}`, + ) + .join('\n'); + } + + if (sourceListFormatted.length > 0) { + content += `\n\nSources:\n${sourceListFormatted.join('\n')}`; + } + + if (!content.trim()) { + return { + llmContent: `No search results or information found for query: "${this.params.query}" (searched via ${selectedProvider.name})`, + returnDisplay: `No information found for "${this.params.query}".`, + }; + } + + return { + llmContent: `Web search results for "${this.params.query}" (via ${selectedProvider.name}):\n\n${content}`, + returnDisplay: `Search results for "${this.params.query}".`, + sources, + }; + } catch (error: unknown) { + const errorMessage = `Error during web search for query "${this.params.query}": ${getErrorMessage( + error, + )}`; + console.error(errorMessage, error); + return { + llmContent: `Error: ${errorMessage}`, + returnDisplay: `Error performing web search.`, + }; + } + } +} + +/** + * A tool to perform web searches using configurable providers. + */ +export class WebSearchTool extends BaseDeclarativeTool< + WebSearchToolParams, + WebSearchToolResult +> { + static readonly Name: string = 'web_search'; + + constructor(private readonly config: Config) { + super( + WebSearchTool.Name, + 'WebSearch', + 'Performs a web search using configurable providers and returns a concise answer with sources.', + Kind.Search, + { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query to find information on the web.', + }, + provider: { + type: 'string', + description: + 'Optional provider to use for the search (e.g., "tavily", "google", "dashscope"). If not specified, the default provider will be used.', + }, + }, + required: ['query'], + }, + ); + } + + /** + * Validates the parameters for the WebSearchTool. + * @param params The parameters to validate + * @returns An error message string if validation fails, null if valid + */ + protected override validateToolParamValues( + params: WebSearchToolParams, + ): string | null { + if (!params.query || params.query.trim() === '') { + return "The 'query' parameter cannot be empty."; + } + + // Validate provider parameter if provided + if (params.provider !== undefined && params.provider.trim() === '') { + return "The 'provider' parameter cannot be empty if specified."; + } + + return null; + } + + protected createInvocation( + params: WebSearchToolParams, + ): ToolInvocation { + return new WebSearchToolInvocation(this.config, params); + } +} + +// Re-export types for external use +export type { + WebSearchToolParams, + WebSearchToolResult, + WebSearchConfig, + WebSearchProviderConfig, +} from './types.js'; diff --git a/packages/core/src/tools/web-search/provider-factory.ts b/packages/core/src/tools/web-search/provider-factory.ts new file mode 100644 index 00000000..0be0d953 --- /dev/null +++ b/packages/core/src/tools/web-search/provider-factory.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WebSearchProvider, WebSearchProviderConfig } from './types.js'; +import { + TavilyProvider, + type TavilyConfig, +} from './providers/tavily-provider.js'; +import { + GoogleProvider, + type GoogleConfig, +} from './providers/google-provider.js'; +import { + DashScopeProvider, + type DashScopeConfig, +} from './providers/dashscope-provider.js'; + +/** + * Factory for creating web search providers based on configuration. + */ +export class WebSearchProviderFactory { + /** + * Create a web search provider from configuration. + * @param config Provider configuration + * @returns Web search provider instance + */ + static createProvider(config: WebSearchProviderConfig): WebSearchProvider { + switch (config.type) { + case 'tavily': { + const tavilyConfig = config.config as unknown as TavilyConfig; + if (!tavilyConfig?.apiKey) { + throw new Error('Tavily provider requires apiKey in configuration'); + } + return new TavilyProvider(tavilyConfig); + } + + case 'google': { + const googleConfig = config.config as unknown as GoogleConfig; + if (!googleConfig?.apiKey || !googleConfig?.searchEngineId) { + throw new Error( + 'Google provider requires apiKey and searchEngineId in configuration', + ); + } + return new GoogleProvider(googleConfig); + } + + case 'dashscope': { + const dashscopeConfig = config.config as unknown as DashScopeConfig; + return new DashScopeProvider(dashscopeConfig); + } + + default: + throw new Error( + `Unsupported web search provider type: ${(config as WebSearchProviderConfig).type}`, + ); + } + } + + /** + * Create multiple providers from configuration list. + * @param configs List of provider configurations + * @returns Map of provider name to provider instance + */ + static createProviders( + configs: WebSearchProviderConfig[], + ): Map { + const providers = new Map(); + + for (const config of configs) { + try { + const provider = this.createProvider(config); + providers.set(config.type, provider); + } catch (error) { + console.warn(`Failed to create ${config.type} provider:`, error); + } + } + + return providers; + } + + /** + * Get the default provider from a list of providers. + * @param providers Map of available providers + * @param defaultProviderName Name of the default provider + * @returns Default provider or the first available provider + */ + static getDefaultProvider( + providers: Map, + defaultProviderName?: string, + ): WebSearchProvider | null { + if (defaultProviderName && providers.has(defaultProviderName)) { + const provider = providers.get(defaultProviderName)!; + if (provider.isAvailable()) { + return provider; + } + } + + // Fallback to first available provider + for (const provider of providers.values()) { + if (provider.isAvailable()) { + return provider; + } + } + + return null; + } +} diff --git a/packages/core/src/tools/web-search/providers/dashscope-provider.ts b/packages/core/src/tools/web-search/providers/dashscope-provider.ts new file mode 100644 index 00000000..cca0a09f --- /dev/null +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseWebSearchProvider } from '../base-provider.js'; +import type { WebSearchResult, WebSearchResultItem } from '../types.js'; + +interface DashScopeSearchItem { + _id: string; + snippet: string; + title: string; + url: string; + timestamp: number; + timestamp_format: string; + hostname: string; + hostlogo?: string; + web_main_body?: string; + _score?: number; +} + +interface DashScopeSearchResponse { + headers: Record; + rid: string; + status: number; + message: string | null; + data: { + total: number; + totalDistinct: number; + docs: DashScopeSearchItem[]; + keywords?: string[]; + qpInfos?: Array<{ + query: string; + cleanQuery: string; + sensitive: boolean; + spellchecked: string; + spellcheck: boolean; + tokenized: string[]; + stopWords: string[]; + synonymWords: string[]; + recognitions: unknown[]; + rewrite: string; + operator: string; + }>; + aggs?: unknown; + extras?: Record; + }; + debug?: unknown; + success: boolean; +} + +/** + * Configuration for DashScope provider. + */ +export interface DashScopeConfig { + apiKey: string; + uid: string; + appId: string; + maxResults?: number; + scene?: string; + timeout?: number; +} + +/** + * Web search provider using Alibaba Cloud DashScope API. + */ +export class DashScopeProvider extends BaseWebSearchProvider { + readonly name = 'DashScope'; + + constructor(private readonly config: DashScopeConfig) { + super(); + } + + isAvailable(): boolean { + return !!(this.config.apiKey && this.config.uid && this.config.appId); + } + + protected async performSearch( + query: string, + signal: AbortSignal, + ): Promise { + const requestBody = { + rid: '', + uid: this.config.uid, + scene: this.config.scene || 'dolphin_search_inner_turbo', + uq: query, + fields: [], + page: 1, + rows: this.config.maxResults || 10, + customConfigInfo: {}, + headers: { + __d_head_qto: this.config.timeout || 8000, + __d_head_app: this.config.appId, + }, + }; + + const response = await fetch( + 'https://dashscope.aliyuncs.com/api/v1/indices/plugin/web_search', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: this.config.apiKey, + }, + body: JSON.stringify(requestBody), + signal, + }, + ); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error( + `DashScope API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + ); + } + + const data = (await response.json()) as DashScopeSearchResponse; + + if (data.status !== 0) { + throw new Error( + `DashScope API error: ${data.message || 'Unknown error'}`, + ); + } + + const results: WebSearchResultItem[] = (data.data?.docs || []).map( + (item) => ({ + title: item.title, + url: item.url, + content: item.snippet, + score: item._score, + publishedDate: item.timestamp_format, + }), + ); + + return this.formatResults(results, query, undefined); + } +} diff --git a/packages/core/src/tools/web-search/providers/google-provider.ts b/packages/core/src/tools/web-search/providers/google-provider.ts new file mode 100644 index 00000000..c166f015 --- /dev/null +++ b/packages/core/src/tools/web-search/providers/google-provider.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseWebSearchProvider } from '../base-provider.js'; +import type { WebSearchResult, WebSearchResultItem } from '../types.js'; + +interface GoogleSearchItem { + title: string; + link: string; + snippet?: string; + displayLink?: string; + formattedUrl?: string; +} + +interface GoogleSearchResponse { + items?: GoogleSearchItem[]; + searchInformation?: { + totalResults?: string; + searchTime?: number; + }; +} + +/** + * Configuration for Google provider. + */ +export interface GoogleConfig { + apiKey: string; + searchEngineId: string; + maxResults?: number; + safeSearch?: 'off' | 'medium' | 'high'; + language?: string; + country?: string; +} + +/** + * Web search provider using Google Custom Search API. + */ +export class GoogleProvider extends BaseWebSearchProvider { + readonly name = 'Google'; + + constructor(private readonly config: GoogleConfig) { + super(); + } + + isAvailable(): boolean { + return !!(this.config.apiKey && this.config.searchEngineId); + } + + protected async performSearch( + query: string, + signal: AbortSignal, + ): Promise { + const params = new URLSearchParams({ + key: this.config.apiKey, + cx: this.config.searchEngineId, + q: query, + num: String(this.config.maxResults || 10), + safe: this.config.safeSearch || 'medium', + }); + + if (this.config.language) { + params.append('lr', `lang_${this.config.language}`); + } + + if (this.config.country) { + params.append('cr', `country${this.config.country}`); + } + + const url = `https://www.googleapis.com/customsearch/v1?${params.toString()}`; + + const response = await fetch(url, { + method: 'GET', + signal, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error( + `Google Search API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + ); + } + + const data = (await response.json()) as GoogleSearchResponse; + + const results: WebSearchResultItem[] = (data.items || []).map((item) => ({ + title: item.title, + url: item.link, + content: item.snippet, + })); + + return this.formatResults(results, query, undefined); + } +} diff --git a/packages/core/src/tools/web-search/providers/tavily-provider.ts b/packages/core/src/tools/web-search/providers/tavily-provider.ts new file mode 100644 index 00000000..9afaee7d --- /dev/null +++ b/packages/core/src/tools/web-search/providers/tavily-provider.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseWebSearchProvider } from '../base-provider.js'; +import type { WebSearchResult, WebSearchResultItem } from '../types.js'; + +interface TavilyResultItem { + title: string; + url: string; + content?: string; + score?: number; + published_date?: string; +} + +interface TavilySearchResponse { + query: string; + answer?: string; + results: TavilyResultItem[]; +} + +/** + * Configuration for Tavily provider. + */ +export interface TavilyConfig { + apiKey: string; + searchDepth?: 'basic' | 'advanced'; + maxResults?: number; + includeAnswer?: boolean; +} + +/** + * Web search provider using Tavily API. + */ +export class TavilyProvider extends BaseWebSearchProvider { + readonly name = 'Tavily'; + + constructor(private readonly config: TavilyConfig) { + super(); + } + + isAvailable(): boolean { + return !!this.config.apiKey; + } + + protected async performSearch( + query: string, + signal: AbortSignal, + ): Promise { + const response = await fetch('https://api.tavily.com/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + api_key: this.config.apiKey, + query, + search_depth: this.config.searchDepth || 'advanced', + max_results: this.config.maxResults || 5, + include_answer: this.config.includeAnswer !== false, + }), + signal, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error( + `Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + ); + } + + const data = (await response.json()) as TavilySearchResponse; + + const results: WebSearchResultItem[] = (data.results || []).map((r) => ({ + title: r.title, + url: r.url, + content: r.content, + score: r.score, + publishedDate: r.published_date, + })); + + return this.formatResults(results, query, data.answer?.trim()); + } +} diff --git a/packages/core/src/tools/web-search/types.ts b/packages/core/src/tools/web-search/types.ts new file mode 100644 index 00000000..12da8116 --- /dev/null +++ b/packages/core/src/tools/web-search/types.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ToolResult } from '../tools.js'; + +/** + * Common interface for all web search providers. + */ +export interface WebSearchProvider { + /** + * The name of the provider. + */ + readonly name: string; + + /** + * Whether the provider is available (has required configuration). + */ + isAvailable(): boolean; + + /** + * Perform a web search with the given query. + * @param query The search query + * @param signal Abort signal for cancellation + * @returns Promise resolving to search results + */ + search(query: string, signal: AbortSignal): Promise; +} + +/** + * Result item from a web search. + */ +export interface WebSearchResultItem { + title: string; + url: string; + content?: string; + score?: number; + publishedDate?: string; +} + +/** + * Result from a web search operation. + */ +export interface WebSearchResult { + /** + * The search query that was executed. + */ + query: string; + + /** + * A concise answer if available from the provider. + */ + answer?: string; + + /** + * List of search result items. + */ + results: WebSearchResultItem[]; + + /** + * Provider-specific metadata. + */ + metadata?: Record; +} + +/** + * Extended tool result that includes sources for web search. + */ +export interface WebSearchToolResult extends ToolResult { + sources?: Array<{ title: string; url: string }>; +} + +/** + * Parameters for the WebSearchTool. + */ +export interface WebSearchToolParams { + /** + * The search query. + */ + query: string; + + /** + * Optional provider to use for the search. + * If not specified, the default provider will be used. + */ + provider?: string; +} + +/** + * Configuration for web search providers. + */ +export interface WebSearchConfig { + /** + * List of available providers with their configurations. + */ + providers: WebSearchProviderConfig[]; + + /** + * The default provider to use. + */ + default: string; +} + +/** + * Base configuration for a web search provider. + */ +export interface WebSearchProviderConfig { + /** + * The type of provider. + */ + type: 'tavily' | 'google' | 'dashscope'; + + /** + * Provider-specific configuration. + */ + config?: Record; +} From b1ece177b72c0cf73e42709a23518076b60e959c Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 27 Oct 2025 11:01:48 +0800 Subject: [PATCH 02/25] feat: Optimize the code --- packages/core/src/config/config.ts | 25 +-- .../src/tools/web-search/base-provider.ts | 42 +--- packages/core/src/tools/web-search/errors.ts | 19 ++ packages/core/src/tools/web-search/index.ts | 181 +++++++++++++----- .../src/tools/web-search/provider-factory.ts | 110 ----------- .../providers/dashscope-provider.ts | 40 ++-- .../web-search/providers/google-provider.ts | 30 ++- .../web-search/providers/tavily-provider.ts | 24 +-- packages/core/src/tools/web-search/types.ts | 55 ++++-- packages/core/src/tools/web-search/utils.ts | 52 +++++ 10 files changed, 296 insertions(+), 282 deletions(-) create mode 100644 packages/core/src/tools/web-search/errors.ts delete mode 100644 packages/core/src/tools/web-search/provider-factory.ts create mode 100644 packages/core/src/tools/web-search/utils.ts diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 75ec3766..74dbef22 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -908,29 +908,8 @@ export class Config { return this.tavilyApiKey; } - getWebSearchConfig(): - | { - provider: Array<{ - type: 'tavily' | 'google' | 'dashscope'; - config: Record; - }>; - default: string; - } - | undefined { - if (!this.webSearch) { - return undefined; - } - - return { - provider: this.webSearch.provider.map((p) => ({ - type: p.type, - config: { - apiKey: p.apiKey, - searchEngineId: p.searchEngineId, - }, - })), - default: this.webSearch.default, - }; + getWebSearchConfig() { + return this.webSearch; } getIdeMode(): boolean { diff --git a/packages/core/src/tools/web-search/base-provider.ts b/packages/core/src/tools/web-search/base-provider.ts index 63e8aafc..8bae63ae 100644 --- a/packages/core/src/tools/web-search/base-provider.ts +++ b/packages/core/src/tools/web-search/base-provider.ts @@ -9,10 +9,11 @@ import type { WebSearchResult, WebSearchResultItem, } from './types.js'; -import { getErrorMessage } from '../../utils/errors.js'; +import { WebSearchError } from './errors.js'; /** * Base implementation for web search providers. + * Provides common functionality for error handling and result formatting. */ export abstract class BaseWebSearchProvider implements WebSearchProvider { abstract readonly name: string; @@ -41,16 +42,19 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider { */ async search(query: string, signal: AbortSignal): Promise { if (!this.isAvailable()) { - throw new Error( - `${this.name} provider is not available. Please check your configuration.`, + throw new WebSearchError( + this.name, + 'Provider is not available. Please check your configuration.', ); } try { return await this.performSearch(query, signal); } catch (error: unknown) { - const errorMessage = getErrorMessage(error); - throw new Error(`Error during ${this.name} search: ${errorMessage}`); + if (error instanceof WebSearchError) { + throw error; + } + throw new WebSearchError(this.name, 'Search failed', error); } } @@ -58,6 +62,7 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider { * Format search results into a consistent format. * @param results Raw results from the provider * @param query The original search query + * @param answer Optional answer from the provider * @returns Formatted search results */ protected formatResults( @@ -71,31 +76,4 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider { results, }; } - - /** - * Create a formatted source list for display. - * @param results Search result items - * @returns Formatted source list - */ - protected createSourceList(results: WebSearchResultItem[]): string { - return results - .map((r, i) => `[${i + 1}] ${r.title || 'Untitled'} (${r.url})`) - .join('\n'); - } - - /** - * Build a concise summary from search results. - * @param results Search result items - * @param maxResults Maximum number of results to include - * @returns Concise summary string - */ - protected buildSummary( - results: WebSearchResultItem[], - maxResults: number = 3, - ): string { - return results - .slice(0, maxResults) - .map((r, i) => `${i + 1}. ${r.title} - ${r.url}`) - .join('\n'); - } } diff --git a/packages/core/src/tools/web-search/errors.ts b/packages/core/src/tools/web-search/errors.ts new file mode 100644 index 00000000..4e795f85 --- /dev/null +++ b/packages/core/src/tools/web-search/errors.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Custom error class for web search operations. + */ +export class WebSearchError extends Error { + constructor( + readonly provider: string, + message: string, + readonly originalError?: unknown, + ) { + super(`[${provider}] ${message}`); + this.name = 'WebSearchError'; + } +} diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 3aba841f..06e3cc5f 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -13,16 +13,21 @@ import { type ToolInfoConfirmationDetails, ToolConfirmationOutcome, } from '../tools.js'; +import { ToolErrorType } from '../tool-error.js'; import type { Config } from '../../config/config.js'; import { ApprovalMode } from '../../config/config.js'; import { getErrorMessage } from '../../utils/errors.js'; -import { WebSearchProviderFactory } from './provider-factory.js'; +import { buildContentWithSources, buildSummary } from './utils.js'; +import { TavilyProvider } from './providers/tavily-provider.js'; +import { GoogleProvider } from './providers/google-provider.js'; +import { DashScopeProvider } from './providers/dashscope-provider.js'; import type { WebSearchToolParams, WebSearchToolResult, WebSearchProvider, WebSearchResultItem, + WebSearchProviderConfig, } from './types.js'; class WebSearchToolInvocation extends BaseToolInvocation< @@ -37,7 +42,6 @@ class WebSearchToolInvocation extends BaseToolInvocation< } override getDescription(): string { - // Try to determine which provider will be used const webSearchConfig = this.config.getWebSearchConfig(); const provider = this.params.provider || webSearchConfig?.default || 'tavily'; @@ -64,6 +68,103 @@ class WebSearchToolInvocation extends BaseToolInvocation< return confirmationDetails; } + /** + * Create a provider instance from configuration. + */ + private createProvider(config: WebSearchProviderConfig): WebSearchProvider { + switch (config.type) { + case 'tavily': + return new TavilyProvider(config); + case 'google': + return new GoogleProvider(config); + case 'dashscope': + return new DashScopeProvider(config); + default: + throw new Error('Unknown provider type'); + } + } + + /** + * Create all configured providers. + */ + private createProviders( + configs: WebSearchProviderConfig[], + ): Map { + const providers = new Map(); + + for (const config of configs) { + try { + const provider = this.createProvider(config); + if (provider.isAvailable()) { + providers.set(config.type, provider); + } + } catch (error) { + console.warn(`Failed to create ${config.type} provider:`, error); + } + } + + return providers; + } + + /** + * Select the appropriate provider based on configuration and parameters. + */ + private selectProvider( + providers: Map, + requestedProvider?: string, + defaultProvider?: string, + ): { provider: WebSearchProvider | null; error?: string } { + // Use requested provider if specified + if (requestedProvider) { + const provider = providers.get(requestedProvider); + if (!provider) { + const availableProviders = Array.from(providers.keys()).join(', '); + return { + provider: null, + error: `The specified provider "${requestedProvider}" is not available or not configured. Available providers: ${availableProviders}`, + }; + } + return { provider }; + } + + // Use default provider if specified and available + if (defaultProvider && providers.has(defaultProvider)) { + const provider = providers.get(defaultProvider)!; + return { provider }; + } + + // Fallback to first available provider + const firstProvider = providers.values().next().value; + return { provider: firstProvider || null }; + } + + /** + * Format search results into a content string. + */ + private formatSearchResults(searchResult: { + answer?: string; + results: WebSearchResultItem[]; + }): { + content: string; + sources: Array<{ title: string; url: string }>; + } { + const sources = searchResult.results.map((r) => ({ + title: r.title, + url: r.url, + })); + + let content = searchResult.answer?.trim() || ''; + if (!content) { + // Fallback: build a concise summary from top results + content = buildSummary(sources, 3); + } + + // Add sources section + content = buildContentWithSources(content, sources); + + return { content, sources }; + } + async execute(signal: AbortSignal): Promise { const webSearchConfig = this.config.getWebSearchConfig(); if (!webSearchConfig) { @@ -75,37 +176,35 @@ class WebSearchToolInvocation extends BaseToolInvocation< }; } - const providers = WebSearchProviderFactory.createProviders( - webSearchConfig.provider, + const providers = this.createProviders(webSearchConfig.provider); + + const { provider: selectedProvider, error } = this.selectProvider( + providers, + this.params.provider, + webSearchConfig.default, ); - // Determine which provider to use - let selectedProvider: WebSearchProvider | null = null; - - if (this.params.provider) { - // Use the specified provider if available - const provider = providers.get(this.params.provider); - if (provider && provider.isAvailable()) { - selectedProvider = provider; - } else { - return { - llmContent: `The specified provider "${this.params.provider}" is not available or not configured. Available providers: ${Array.from(providers.keys()).join(', ')}`, - returnDisplay: `The WebSearch Provider "${this.params.provider}" not available.`, - }; - } - } else { - // Use default provider - selectedProvider = WebSearchProviderFactory.getDefaultProvider( - providers, - webSearchConfig.default, - ); + if (error) { + return { + llmContent: error, + returnDisplay: `Provider "${this.params.provider}" not available.`, + error: { + message: error, + type: ToolErrorType.INVALID_TOOL_PARAMS, + }, + }; } if (!selectedProvider) { + const errorMsg = + 'Web search is disabled because no web search providers are available. Please check your configuration.'; return { - llmContent: - 'Web search is disabled because no web search providers are available. Please check your configuration.', + llmContent: errorMsg, returnDisplay: 'Web search disabled. No available providers.', + error: { + message: errorMsg, + type: ToolErrorType.EXECUTION_FAILED, + }, }; } @@ -115,31 +214,7 @@ class WebSearchToolInvocation extends BaseToolInvocation< signal, ); - const sources = searchResult.results.map((r: WebSearchResultItem) => ({ - title: r.title, - url: r.url, - })); - - const sourceListFormatted = sources.map( - (s: { title: string; url: string }, i: number) => - `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`, - ); - - let content = searchResult.answer?.trim() || ''; - if (!content) { - // Fallback: build a concise summary from top results - content = sources - .slice(0, 3) - .map( - (s: { title: string; url: string }, i: number) => - `${i + 1}. ${s.title} - ${s.url}`, - ) - .join('\n'); - } - - if (sourceListFormatted.length > 0) { - content += `\n\nSources:\n${sourceListFormatted.join('\n')}`; - } + const { content, sources } = this.formatSearchResults(searchResult); if (!content.trim()) { return { @@ -161,6 +236,10 @@ class WebSearchToolInvocation extends BaseToolInvocation< return { llmContent: `Error: ${errorMessage}`, returnDisplay: `Error performing web search.`, + error: { + message: errorMessage, + type: ToolErrorType.EXECUTION_FAILED, + }, }; } } diff --git a/packages/core/src/tools/web-search/provider-factory.ts b/packages/core/src/tools/web-search/provider-factory.ts deleted file mode 100644 index 0be0d953..00000000 --- a/packages/core/src/tools/web-search/provider-factory.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { WebSearchProvider, WebSearchProviderConfig } from './types.js'; -import { - TavilyProvider, - type TavilyConfig, -} from './providers/tavily-provider.js'; -import { - GoogleProvider, - type GoogleConfig, -} from './providers/google-provider.js'; -import { - DashScopeProvider, - type DashScopeConfig, -} from './providers/dashscope-provider.js'; - -/** - * Factory for creating web search providers based on configuration. - */ -export class WebSearchProviderFactory { - /** - * Create a web search provider from configuration. - * @param config Provider configuration - * @returns Web search provider instance - */ - static createProvider(config: WebSearchProviderConfig): WebSearchProvider { - switch (config.type) { - case 'tavily': { - const tavilyConfig = config.config as unknown as TavilyConfig; - if (!tavilyConfig?.apiKey) { - throw new Error('Tavily provider requires apiKey in configuration'); - } - return new TavilyProvider(tavilyConfig); - } - - case 'google': { - const googleConfig = config.config as unknown as GoogleConfig; - if (!googleConfig?.apiKey || !googleConfig?.searchEngineId) { - throw new Error( - 'Google provider requires apiKey and searchEngineId in configuration', - ); - } - return new GoogleProvider(googleConfig); - } - - case 'dashscope': { - const dashscopeConfig = config.config as unknown as DashScopeConfig; - return new DashScopeProvider(dashscopeConfig); - } - - default: - throw new Error( - `Unsupported web search provider type: ${(config as WebSearchProviderConfig).type}`, - ); - } - } - - /** - * Create multiple providers from configuration list. - * @param configs List of provider configurations - * @returns Map of provider name to provider instance - */ - static createProviders( - configs: WebSearchProviderConfig[], - ): Map { - const providers = new Map(); - - for (const config of configs) { - try { - const provider = this.createProvider(config); - providers.set(config.type, provider); - } catch (error) { - console.warn(`Failed to create ${config.type} provider:`, error); - } - } - - return providers; - } - - /** - * Get the default provider from a list of providers. - * @param providers Map of available providers - * @param defaultProviderName Name of the default provider - * @returns Default provider or the first available provider - */ - static getDefaultProvider( - providers: Map, - defaultProviderName?: string, - ): WebSearchProvider | null { - if (defaultProviderName && providers.has(defaultProviderName)) { - const provider = providers.get(defaultProviderName)!; - if (provider.isAvailable()) { - return provider; - } - } - - // Fallback to first available provider - for (const provider of providers.values()) { - if (provider.isAvailable()) { - return provider; - } - } - - return null; - } -} diff --git a/packages/core/src/tools/web-search/providers/dashscope-provider.ts b/packages/core/src/tools/web-search/providers/dashscope-provider.ts index cca0a09f..df4177d8 100644 --- a/packages/core/src/tools/web-search/providers/dashscope-provider.ts +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -5,7 +5,12 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import type { WebSearchResult, WebSearchResultItem } from '../types.js'; +import { WebSearchError } from '../errors.js'; +import type { + WebSearchResult, + WebSearchResultItem, + DashScopeProviderConfig, +} from '../types.js'; interface DashScopeSearchItem { _id: string; @@ -50,30 +55,19 @@ interface DashScopeSearchResponse { success: boolean; } -/** - * Configuration for DashScope provider. - */ -export interface DashScopeConfig { - apiKey: string; - uid: string; - appId: string; - maxResults?: number; - scene?: string; - timeout?: number; -} - /** * Web search provider using Alibaba Cloud DashScope API. */ export class DashScopeProvider extends BaseWebSearchProvider { readonly name = 'DashScope'; - constructor(private readonly config: DashScopeConfig) { + constructor(private readonly config: DashScopeProviderConfig) { super(); } isAvailable(): boolean { - return !!(this.config.apiKey && this.config.uid && this.config.appId); + return true; + // return !!(this.config.apiKey && this.config.uid && this.config.appId); } protected async performSearch( @@ -82,7 +76,7 @@ export class DashScopeProvider extends BaseWebSearchProvider { ): Promise { const requestBody = { rid: '', - uid: this.config.uid, + uid: this.config.uid!, scene: this.config.scene || 'dolphin_search_inner_turbo', uq: query, fields: [], @@ -91,7 +85,7 @@ export class DashScopeProvider extends BaseWebSearchProvider { customConfigInfo: {}, headers: { __d_head_qto: this.config.timeout || 8000, - __d_head_app: this.config.appId, + __d_head_app: this.config.appId!, }, }; @@ -101,7 +95,7 @@ export class DashScopeProvider extends BaseWebSearchProvider { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: this.config.apiKey, + Authorization: this.config.apiKey!, }, body: JSON.stringify(requestBody), signal, @@ -110,16 +104,18 @@ export class DashScopeProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new Error( - `DashScope API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + throw new WebSearchError( + this.name, + `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } const data = (await response.json()) as DashScopeSearchResponse; if (data.status !== 0) { - throw new Error( - `DashScope API error: ${data.message || 'Unknown error'}`, + throw new WebSearchError( + this.name, + `API error: ${data.message || 'Unknown error'}`, ); } diff --git a/packages/core/src/tools/web-search/providers/google-provider.ts b/packages/core/src/tools/web-search/providers/google-provider.ts index c166f015..8dddb5a6 100644 --- a/packages/core/src/tools/web-search/providers/google-provider.ts +++ b/packages/core/src/tools/web-search/providers/google-provider.ts @@ -5,7 +5,12 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import type { WebSearchResult, WebSearchResultItem } from '../types.js'; +import { WebSearchError } from '../errors.js'; +import type { + WebSearchResult, + WebSearchResultItem, + GoogleProviderConfig, +} from '../types.js'; interface GoogleSearchItem { title: string; @@ -23,25 +28,13 @@ interface GoogleSearchResponse { }; } -/** - * Configuration for Google provider. - */ -export interface GoogleConfig { - apiKey: string; - searchEngineId: string; - maxResults?: number; - safeSearch?: 'off' | 'medium' | 'high'; - language?: string; - country?: string; -} - /** * Web search provider using Google Custom Search API. */ export class GoogleProvider extends BaseWebSearchProvider { readonly name = 'Google'; - constructor(private readonly config: GoogleConfig) { + constructor(private readonly config: GoogleProviderConfig) { super(); } @@ -54,8 +47,8 @@ export class GoogleProvider extends BaseWebSearchProvider { signal: AbortSignal, ): Promise { const params = new URLSearchParams({ - key: this.config.apiKey, - cx: this.config.searchEngineId, + key: this.config.apiKey!, + cx: this.config.searchEngineId!, q: query, num: String(this.config.maxResults || 10), safe: this.config.safeSearch || 'medium', @@ -78,8 +71,9 @@ export class GoogleProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new Error( - `Google Search API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + throw new WebSearchError( + this.name, + `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } diff --git a/packages/core/src/tools/web-search/providers/tavily-provider.ts b/packages/core/src/tools/web-search/providers/tavily-provider.ts index 9afaee7d..78745858 100644 --- a/packages/core/src/tools/web-search/providers/tavily-provider.ts +++ b/packages/core/src/tools/web-search/providers/tavily-provider.ts @@ -5,7 +5,12 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import type { WebSearchResult, WebSearchResultItem } from '../types.js'; +import { WebSearchError } from '../errors.js'; +import type { + WebSearchResult, + WebSearchResultItem, + TavilyProviderConfig, +} from '../types.js'; interface TavilyResultItem { title: string; @@ -21,23 +26,13 @@ interface TavilySearchResponse { results: TavilyResultItem[]; } -/** - * Configuration for Tavily provider. - */ -export interface TavilyConfig { - apiKey: string; - searchDepth?: 'basic' | 'advanced'; - maxResults?: number; - includeAnswer?: boolean; -} - /** * Web search provider using Tavily API. */ export class TavilyProvider extends BaseWebSearchProvider { readonly name = 'Tavily'; - constructor(private readonly config: TavilyConfig) { + constructor(private readonly config: TavilyProviderConfig) { super(); } @@ -66,8 +61,9 @@ export class TavilyProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new Error( - `Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + throw new WebSearchError( + this.name, + `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } diff --git a/packages/core/src/tools/web-search/types.ts b/packages/core/src/tools/web-search/types.ts index 12da8116..1983e166 100644 --- a/packages/core/src/tools/web-search/types.ts +++ b/packages/core/src/tools/web-search/types.ts @@ -95,7 +95,7 @@ export interface WebSearchConfig { /** * List of available providers with their configurations. */ - providers: WebSearchProviderConfig[]; + provider: WebSearchProviderConfig[]; /** * The default provider to use. @@ -104,16 +104,47 @@ export interface WebSearchConfig { } /** - * Base configuration for a web search provider. + * Base configuration for Tavily provider. */ -export interface WebSearchProviderConfig { - /** - * The type of provider. - */ - type: 'tavily' | 'google' | 'dashscope'; - - /** - * Provider-specific configuration. - */ - config?: Record; +export interface TavilyProviderConfig { + type: 'tavily'; + apiKey?: string; + searchDepth?: 'basic' | 'advanced'; + maxResults?: number; + includeAnswer?: boolean; } + +/** + * Base configuration for Google provider. + */ +export interface GoogleProviderConfig { + type: 'google'; + apiKey?: string; + searchEngineId?: string; + maxResults?: number; + safeSearch?: 'off' | 'medium' | 'high'; + language?: string; + country?: string; +} + +/** + * Base configuration for DashScope provider. + */ +export interface DashScopeProviderConfig { + type: 'dashscope'; + apiKey?: string; + uid?: string; + appId?: string; + maxResults?: number; + scene?: string; + timeout?: number; +} + +/** + * Discriminated union type for web search provider configurations. + * This ensures type safety when working with different provider configs. + */ +export type WebSearchProviderConfig = + | TavilyProviderConfig + | GoogleProviderConfig + | DashScopeProviderConfig; diff --git a/packages/core/src/tools/web-search/utils.ts b/packages/core/src/tools/web-search/utils.ts new file mode 100644 index 00000000..b2634a56 --- /dev/null +++ b/packages/core/src/tools/web-search/utils.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Utility functions for web search formatting and processing. + */ + +/** + * Format sources into a numbered list with titles and URLs. + * @param sources Array of source objects with title and url + * @returns Formatted source list string + */ +export function formatSources( + sources: Array<{ title: string; url: string }>, +): string { + return sources + .map((s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`) + .join('\n'); +} + +/** + * Build content string with appended sources section. + * @param content Main content text + * @param sources Array of source objects + * @returns Combined content with sources + */ +export function buildContentWithSources( + content: string, + sources: Array<{ title: string; url: string }>, +): string { + if (!sources.length) return content; + return `${content}\n\nSources:\n${formatSources(sources)}`; +} + +/** + * Build a concise summary from top search results. + * @param sources Array of source objects + * @param maxResults Maximum number of results to include + * @returns Concise summary string + */ +export function buildSummary( + sources: Array<{ title: string; url: string }>, + maxResults: number = 3, +): string { + return sources + .slice(0, maxResults) + .map((s, i) => `${i + 1}. ${s.title} - ${s.url}`) + .join('\n'); +} From 79b4821499e8562a9d921dc4a0a629bb32b69436 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 27 Oct 2025 11:24:38 +0800 Subject: [PATCH 03/25] feat: Optimize the code --- .../src/tools/web-search/base-provider.ts | 41 +++++-------------- packages/core/src/tools/web-search/errors.ts | 19 --------- .../providers/dashscope-provider.ts | 14 +++---- .../web-search/providers/google-provider.ts | 9 ++-- .../web-search/providers/tavily-provider.ts | 10 +++-- packages/core/src/tools/web-search/utils.ts | 18 ++------ 6 files changed, 31 insertions(+), 80 deletions(-) delete mode 100644 packages/core/src/tools/web-search/errors.ts diff --git a/packages/core/src/tools/web-search/base-provider.ts b/packages/core/src/tools/web-search/base-provider.ts index 8bae63ae..a9bdc5b0 100644 --- a/packages/core/src/tools/web-search/base-provider.ts +++ b/packages/core/src/tools/web-search/base-provider.ts @@ -4,16 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - WebSearchProvider, - WebSearchResult, - WebSearchResultItem, -} from './types.js'; -import { WebSearchError } from './errors.js'; +import type { WebSearchProvider, WebSearchResult } from './types.js'; /** * Base implementation for web search providers. - * Provides common functionality for error handling and result formatting. + * Provides common functionality for error handling. */ export abstract class BaseWebSearchProvider implements WebSearchProvider { abstract readonly name: string; @@ -42,38 +37,22 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider { */ async search(query: string, signal: AbortSignal): Promise { if (!this.isAvailable()) { - throw new WebSearchError( - this.name, - 'Provider is not available. Please check your configuration.', + throw new Error( + `[${this.name}] Provider is not available. Please check your configuration.`, ); } try { return await this.performSearch(query, signal); } catch (error: unknown) { - if (error instanceof WebSearchError) { + if ( + error instanceof Error && + error.message.startsWith(`[${this.name}]`) + ) { throw error; } - throw new WebSearchError(this.name, 'Search failed', error); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`[${this.name}] Search failed: ${message}`); } } - - /** - * Format search results into a consistent format. - * @param results Raw results from the provider - * @param query The original search query - * @param answer Optional answer from the provider - * @returns Formatted search results - */ - protected formatResults( - results: WebSearchResultItem[], - query: string, - answer?: string, - ): WebSearchResult { - return { - query, - answer, - results, - }; - } } diff --git a/packages/core/src/tools/web-search/errors.ts b/packages/core/src/tools/web-search/errors.ts deleted file mode 100644 index 4e795f85..00000000 --- a/packages/core/src/tools/web-search/errors.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Custom error class for web search operations. - */ -export class WebSearchError extends Error { - constructor( - readonly provider: string, - message: string, - readonly originalError?: unknown, - ) { - super(`[${provider}] ${message}`); - this.name = 'WebSearchError'; - } -} diff --git a/packages/core/src/tools/web-search/providers/dashscope-provider.ts b/packages/core/src/tools/web-search/providers/dashscope-provider.ts index df4177d8..20491ab7 100644 --- a/packages/core/src/tools/web-search/providers/dashscope-provider.ts +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -5,7 +5,6 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import { WebSearchError } from '../errors.js'; import type { WebSearchResult, WebSearchResultItem, @@ -104,8 +103,7 @@ export class DashScopeProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new WebSearchError( - this.name, + throw new Error( `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } @@ -113,10 +111,7 @@ export class DashScopeProvider extends BaseWebSearchProvider { const data = (await response.json()) as DashScopeSearchResponse; if (data.status !== 0) { - throw new WebSearchError( - this.name, - `API error: ${data.message || 'Unknown error'}`, - ); + throw new Error(`API error: ${data.message || 'Unknown error'}`); } const results: WebSearchResultItem[] = (data.data?.docs || []).map( @@ -129,6 +124,9 @@ export class DashScopeProvider extends BaseWebSearchProvider { }), ); - return this.formatResults(results, query, undefined); + return { + query, + results, + }; } } diff --git a/packages/core/src/tools/web-search/providers/google-provider.ts b/packages/core/src/tools/web-search/providers/google-provider.ts index 8dddb5a6..0293bfdc 100644 --- a/packages/core/src/tools/web-search/providers/google-provider.ts +++ b/packages/core/src/tools/web-search/providers/google-provider.ts @@ -5,7 +5,6 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import { WebSearchError } from '../errors.js'; import type { WebSearchResult, WebSearchResultItem, @@ -71,8 +70,7 @@ export class GoogleProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new WebSearchError( - this.name, + throw new Error( `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } @@ -85,6 +83,9 @@ export class GoogleProvider extends BaseWebSearchProvider { content: item.snippet, })); - return this.formatResults(results, query, undefined); + return { + query, + results, + }; } } diff --git a/packages/core/src/tools/web-search/providers/tavily-provider.ts b/packages/core/src/tools/web-search/providers/tavily-provider.ts index 78745858..b6284050 100644 --- a/packages/core/src/tools/web-search/providers/tavily-provider.ts +++ b/packages/core/src/tools/web-search/providers/tavily-provider.ts @@ -5,7 +5,6 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import { WebSearchError } from '../errors.js'; import type { WebSearchResult, WebSearchResultItem, @@ -61,8 +60,7 @@ export class TavilyProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new WebSearchError( - this.name, + throw new Error( `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } @@ -77,6 +75,10 @@ export class TavilyProvider extends BaseWebSearchProvider { publishedDate: r.published_date, })); - return this.formatResults(results, query, data.answer?.trim()); + return { + query, + answer: data.answer?.trim(), + results, + }; } } diff --git a/packages/core/src/tools/web-search/utils.ts b/packages/core/src/tools/web-search/utils.ts index b2634a56..4f4f24db 100644 --- a/packages/core/src/tools/web-search/utils.ts +++ b/packages/core/src/tools/web-search/utils.ts @@ -8,19 +8,6 @@ * Utility functions for web search formatting and processing. */ -/** - * Format sources into a numbered list with titles and URLs. - * @param sources Array of source objects with title and url - * @returns Formatted source list string - */ -export function formatSources( - sources: Array<{ title: string; url: string }>, -): string { - return sources - .map((s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`) - .join('\n'); -} - /** * Build content string with appended sources section. * @param content Main content text @@ -32,7 +19,10 @@ export function buildContentWithSources( sources: Array<{ title: string; url: string }>, ): string { if (!sources.length) return content; - return `${content}\n\nSources:\n${formatSources(sources)}`; + const sourceList = sources + .map((s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`) + .join('\n'); + return `${content}\n\nSources:\n${sourceList}`; } /** From 741eaf91c2d37c9fffa45624c8f7d25fb3f7bb7a Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 27 Oct 2025 17:05:47 +0800 Subject: [PATCH 04/25] feat: add web_search docs --- docs/tools/web-search.md | 168 ++++++++++++--- packages/cli/src/config/config.ts | 25 ++- packages/cli/src/config/settingsSchema.ts | 4 +- packages/cli/src/config/webSearch.ts | 91 ++++++++ packages/cli/src/gemini.test.tsx | 3 + packages/core/src/config/config.ts | 7 - packages/core/src/tools/web-search.test.ts | 166 --------------- packages/core/src/tools/web-search.ts | 218 -------------------- packages/core/src/tools/web-search/index.ts | 85 +++----- 9 files changed, 293 insertions(+), 474 deletions(-) create mode 100644 packages/cli/src/config/webSearch.ts delete mode 100644 packages/core/src/tools/web-search.test.ts delete mode 100644 packages/core/src/tools/web-search.ts diff --git a/docs/tools/web-search.md b/docs/tools/web-search.md index 7f78cf70..60617d71 100644 --- a/docs/tools/web-search.md +++ b/docs/tools/web-search.md @@ -1,43 +1,165 @@ # Web Search Tool (`web_search`) -This document describes the `web_search` tool. +This document describes the `web_search` tool for performing web searches using multiple providers. ## Description -Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible. +Use `web_search` to perform a web search and get information from the internet. The tool supports multiple search providers and returns a concise answer with source citations when available. + +### Supported Providers + +1. **DashScope** (Official, Free) - Default provider, always available when using Qwen OAuth authentication +2. **Tavily** - High-quality search API with built-in answer generation +3. **Google Custom Search** - Google's Custom Search JSON API ### Arguments -`web_search` takes one argument: +`web_search` takes two arguments: -- `query` (string, required): The search query. +- `query` (string, required): The search query +- `provider` (string, optional): Specific provider to use ("dashscope", "tavily", "google") + - If not specified, uses the default provider from configuration -## How to use `web_search` +## Configuration -`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods: +### Method 1: Settings File (Recommended) -1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json` -2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file -3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI +Add to your `settings.json`: -If the key is not configured, the tool will be disabled and skipped. - -Usage: - -``` -web_search(query="Your query goes here.") +```json +{ + "webSearch": { + "provider": [ + { "type": "dashscope" }, + { "type": "tavily", "apiKey": "tvly-xxxxx" }, + { + "type": "google", + "apiKey": "your-google-api-key", + "searchEngineId": "your-search-engine-id" + } + ], + "default": "dashscope" + } +} ``` -## `web_search` examples +**Notes:** -Get information on a topic: +- DashScope doesn't require an API key (official, free service) +- Only configure the providers you want to use +- Set `default` to specify which provider to use by default -``` -web_search(query="latest advancements in AI-powered code generation") +### Method 2: Environment Variables + +Set environment variables in your shell or `.env` file: + +```bash +# Tavily +export TAVILY_API_KEY="tvly-xxxxx" + +# Google +export GOOGLE_API_KEY="your-api-key" +export GOOGLE_SEARCH_ENGINE_ID="your-engine-id" ``` -## Important notes +### Method 3: Command Line Arguments -- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links. -- **Citations:** Source links are appended as a numbered list. -- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered. +Pass API keys when running Qwen Code: + +```bash +# Tavily +qwen --tavily-api-key tvly-xxxxx + +# Google +qwen --google-api-key your-key --google-search-engine-id your-id + +# Specify default provider +qwen --web-search-default tavily +``` + +### Backward Compatibility (Deprecated) + +⚠️ **DEPRECATED:** The legacy `tavilyApiKey` configuration is still supported for backward compatibility but is deprecated: + +```json +{ + "advanced": { + "tavilyApiKey": "tvly-xxxxx" // ⚠️ Deprecated + } +} +``` + +**Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration. + +## Usage Examples + +### Basic search (using default provider) + +``` +web_search(query="latest advancements in AI") +``` + +### Search with specific provider + +``` +web_search(query="latest advancements in AI", provider="tavily") +``` + +### Real-world examples + +``` +web_search(query="weather in San Francisco today") +web_search(query="latest Node.js LTS version", provider="google") +web_search(query="best practices for React 19", provider="dashscope") +``` + +## Provider Details + +### DashScope (Official) + +- **Cost:** Free +- **Authentication:** Automatically available with Qwen OAuth +- **Configuration:** No API key required +- **Best for:** General queries, always available + +### Tavily + +- **Cost:** Requires API key (paid service with free tier) +- **Sign up:** https://tavily.com +- **Features:** High-quality results with AI-generated answers +- **Best for:** Research, comprehensive answers with citations + +### Google Custom Search + +- **Cost:** Free tier available (100 queries/day) +- **Setup:** + 1. Enable Custom Search API in Google Cloud Console + 2. Create a Custom Search Engine at https://programmablesearchengine.google.com +- **Features:** Google's search quality +- **Best for:** Specific, factual queries + +## Important Notes + +- **Response format:** Returns a concise answer with numbered source citations +- **Citations:** Source links are appended as a numbered list: [1], [2], etc. +- **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter +- **DashScope availability:** Always available when authenticated with Qwen OAuth, no configuration needed + +## Troubleshooting + +**Tool not available?** + +- Check if at least one provider is configured +- For DashScope: Ensure you're authenticated with Qwen OAuth +- For Tavily/Google: Verify your API keys are correct + +**Provider-specific errors?** + +- Use the `provider` parameter to try a different search provider +- Check your API quotas and rate limits +- Verify API keys are properly set in configuration + +**Need help?** + +- Check your configuration: Run `qwen` and use the settings dialog +- View your current settings in `~/.qwen-code/settings.json` (macOS/Linux) or `%USERPROFILE%\.qwen-code\settings.json` (Windows) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 9655a1ec..61a856d6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -43,6 +43,7 @@ import { mcpCommand } from '../commands/mcp.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; +import { buildWebSearchConfig } from './webSearch.js'; // Simple console logger for now - replace with actual logger if available const logger = { @@ -117,6 +118,9 @@ export interface CliArgs { proxy: string | undefined; includeDirectories: string[] | undefined; tavilyApiKey: string | undefined; + googleApiKey: string | undefined; + googleSearchEngineId: string | undefined; + webSearchDefault: string | undefined; screenReader: boolean | undefined; vlmSwitchMode: string | undefined; useSmartEdit: boolean | undefined; @@ -325,7 +329,20 @@ export async function parseArguments(settings: Settings): Promise { }) .option('tavily-api-key', { type: 'string', - description: 'Tavily API key for web search functionality', + description: 'Tavily API key for web search', + }) + .option('google-api-key', { + type: 'string', + description: 'Google Custom Search API key', + }) + .option('google-search-engine-id', { + type: 'string', + description: 'Google Custom Search Engine ID', + }) + .option('web-search-default', { + type: 'string', + description: + 'Default web search provider (dashscope, tavily, google)', }) .option('screen-reader', { type: 'boolean', @@ -747,10 +764,7 @@ export async function loadCliConfig( : argv.openaiLogging) ?? false, }, cliVersion: await getCliVersion(), - tavilyApiKey: - argv.tavilyApiKey || - settings.advanced?.tavilyApiKey || - process.env['TAVILY_API_KEY'], + webSearch: buildWebSearchConfig(argv, settings), summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, chatCompression: settings.model?.chatCompression, @@ -771,7 +785,6 @@ export async function loadCliConfig( output: { format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, - webSearch: settings.webSearch, }); } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 4265cc5c..509f7666 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1044,12 +1044,12 @@ const SETTINGS_SCHEMA = { }, tavilyApiKey: { type: 'string', - label: 'Tavily API Key', + label: 'Tavily API Key (Deprecated)', category: 'Advanced', requiresRestart: false, default: undefined as string | undefined, description: - 'The API key for the Tavily API. Required to enable the web_search tool functionality.', + '⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.', showInDialog: false, }, }, diff --git a/packages/cli/src/config/webSearch.ts b/packages/cli/src/config/webSearch.ts new file mode 100644 index 00000000..a558de17 --- /dev/null +++ b/packages/cli/src/config/webSearch.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WebSearchProviderConfig } from '@qwen-code/qwen-code-core'; +import type { Settings } from './settings.js'; + +/** + * CLI arguments related to web search configuration + */ +export interface WebSearchCliArgs { + tavilyApiKey?: string; + googleApiKey?: string; + googleSearchEngineId?: string; + webSearchDefault?: string; +} + +/** + * Web search configuration structure + */ +export interface WebSearchConfig { + provider: WebSearchProviderConfig[]; + default: string; +} + +/** + * Build webSearch configuration from multiple sources with priority: + * 1. settings.json (new format) - highest priority + * 2. Command line args + environment variables + * 3. Legacy tavilyApiKey (backward compatibility) + * + * @param argv - Command line arguments + * @param settings - User settings from settings.json + * @returns WebSearch configuration or undefined if no providers available + */ +export function buildWebSearchConfig( + argv: WebSearchCliArgs, + settings: Settings, +): WebSearchConfig | undefined { + // Priority 1: Use settings.json webSearch config if present + if (settings.webSearch) { + return settings.webSearch; + } + + // Priority 2: Build from command line args and environment variables + const providers: WebSearchProviderConfig[] = []; + + // DashScope is always available (official, free) + providers.push({ type: 'dashscope' } as WebSearchProviderConfig); + + // Tavily from args/env/legacy settings + const tavilyKey = + argv.tavilyApiKey || + settings.advanced?.tavilyApiKey || + process.env['TAVILY_API_KEY']; + if (tavilyKey) { + providers.push({ + type: 'tavily', + apiKey: tavilyKey, + } as WebSearchProviderConfig); + } + + // Google from args/env + const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY']; + const googleEngineId = + argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID']; + if (googleKey && googleEngineId) { + providers.push({ + type: 'google', + apiKey: googleKey, + searchEngineId: googleEngineId, + } as WebSearchProviderConfig); + } + + // If no providers configured, return undefined + if (providers.length === 0) { + return undefined; + } + + // Determine default provider + // Priority: CLI arg > has Tavily key > DashScope (fallback) + const defaultProvider = + argv.webSearchDefault || (tavilyKey ? 'tavily' : 'dashscope'); + + return { + provider: providers, + default: defaultProvider, + }; +} diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 231c34a7..76d2c772 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -330,6 +330,9 @@ describe('gemini.tsx main function kitty protocol', () => { proxy: undefined, includeDirectories: undefined, tavilyApiKey: undefined, + googleApiKey: undefined, + googleSearchEngineId: undefined, + webSearchDefault: undefined, screenReader: undefined, vlmSwitchMode: undefined, useSmartEdit: undefined, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 74dbef22..c3b4dbc1 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -260,7 +260,6 @@ export interface ConfigParameters { cliVersion?: string; loadMemoryFromIncludeDirectories?: boolean; // Web search providers - tavilyApiKey?: string; webSearch?: { provider: Array<{ type: 'tavily' | 'google' | 'dashscope'; @@ -356,7 +355,6 @@ export class Config { private readonly cliVersion?: string; private readonly experimentalZedIntegration: boolean = false; private readonly loadMemoryFromIncludeDirectories: boolean = false; - private readonly tavilyApiKey?: string; private readonly webSearch?: { provider: Array<{ type: 'tavily' | 'google' | 'dashscope'; @@ -469,7 +467,6 @@ export class Config { this.skipLoopDetection = params.skipLoopDetection ?? false; // Web search - this.tavilyApiKey = params.tavilyApiKey; this.webSearch = params.webSearch; this.useRipgrep = params.useRipgrep ?? true; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; @@ -904,10 +901,6 @@ export class Config { } // Web search provider configuration - getTavilyApiKey(): string | undefined { - return this.tavilyApiKey; - } - getWebSearchConfig() { return this.webSearch; } diff --git a/packages/core/src/tools/web-search.test.ts b/packages/core/src/tools/web-search.test.ts deleted file mode 100644 index 5b0a8f26..00000000 --- a/packages/core/src/tools/web-search.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { WebSearchTool, type WebSearchToolParams } from './web-search.js'; -import type { Config } from '../config/config.js'; -import { GeminiClient } from '../core/client.js'; - -// Mock GeminiClient and Config constructor -vi.mock('../core/client.js'); -vi.mock('../config/config.js'); - -// Mock global fetch -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -describe('WebSearchTool', () => { - const abortSignal = new AbortController().signal; - let mockGeminiClient: GeminiClient; - let tool: WebSearchTool; - - beforeEach(() => { - vi.clearAllMocks(); - const mockConfigInstance = { - getGeminiClient: () => mockGeminiClient, - getProxy: () => undefined, - getTavilyApiKey: () => 'test-api-key', // Add the missing method - } as unknown as Config; - mockGeminiClient = new GeminiClient(mockConfigInstance); - tool = new WebSearchTool(mockConfigInstance); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('build', () => { - it('should return an invocation for a valid query', () => { - const params: WebSearchToolParams = { query: 'test query' }; - const invocation = tool.build(params); - expect(invocation).toBeDefined(); - expect(invocation.params).toEqual(params); - }); - - it('should throw an error for an empty query', () => { - const params: WebSearchToolParams = { query: '' }; - expect(() => tool.build(params)).toThrow( - "The 'query' parameter cannot be empty.", - ); - }); - - it('should throw an error for a query with only whitespace', () => { - const params: WebSearchToolParams = { query: ' ' }; - expect(() => tool.build(params)).toThrow( - "The 'query' parameter cannot be empty.", - ); - }); - }); - - describe('getDescription', () => { - it('should return a description of the search', () => { - const params: WebSearchToolParams = { query: 'test query' }; - const invocation = tool.build(params); - expect(invocation.getDescription()).toBe( - 'Searching the web for: "test query"', - ); - }); - }); - - describe('execute', () => { - it('should return search results for a successful query', async () => { - const params: WebSearchToolParams = { query: 'successful query' }; - - // Mock the fetch response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - answer: 'Here are your results.', - results: [], - }), - } as Response); - - const invocation = tool.build(params); - const result = await invocation.execute(abortSignal); - - expect(result.llmContent).toBe( - 'Web search results for "successful query":\n\nHere are your results.', - ); - expect(result.returnDisplay).toBe( - 'Search results for "successful query" returned.', - ); - expect(result.sources).toEqual([]); - }); - - it('should handle no search results found', async () => { - const params: WebSearchToolParams = { query: 'no results query' }; - - // Mock the fetch response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - answer: '', - results: [], - }), - } as Response); - - const invocation = tool.build(params); - const result = await invocation.execute(abortSignal); - - expect(result.llmContent).toBe( - 'No search results or information found for query: "no results query"', - ); - expect(result.returnDisplay).toBe('No information found.'); - }); - - it('should handle API errors gracefully', async () => { - const params: WebSearchToolParams = { query: 'error query' }; - - // Mock the fetch to reject - mockFetch.mockRejectedValueOnce(new Error('API Failure')); - - const invocation = tool.build(params); - const result = await invocation.execute(abortSignal); - - expect(result.llmContent).toContain('Error:'); - expect(result.llmContent).toContain('API Failure'); - expect(result.returnDisplay).toBe('Error performing web search.'); - }); - - it('should correctly format results with sources', async () => { - const params: WebSearchToolParams = { query: 'grounding query' }; - - // Mock the fetch response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - answer: 'This is a test response.', - results: [ - { title: 'Example Site', url: 'https://example.com' }, - { title: 'Google', url: 'https://google.com' }, - ], - }), - } as Response); - - const invocation = tool.build(params); - const result = await invocation.execute(abortSignal); - - const expectedLlmContent = `Web search results for "grounding query": - -This is a test response. - -Sources: -[1] Example Site (https://example.com) -[2] Google (https://google.com)`; - - expect(result.llmContent).toBe(expectedLlmContent); - expect(result.returnDisplay).toBe( - 'Search results for "grounding query" returned.', - ); - expect(result.sources).toHaveLength(2); - }); - }); -}); diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts deleted file mode 100644 index e99997a8..00000000 --- a/packages/core/src/tools/web-search.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind, - type ToolInvocation, - type ToolResult, - type ToolCallConfirmationDetails, - type ToolInfoConfirmationDetails, - ToolConfirmationOutcome, -} from './tools.js'; - -import type { Config } from '../config/config.js'; -import { ApprovalMode } from '../config/config.js'; -import { getErrorMessage } from '../utils/errors.js'; - -interface TavilyResultItem { - title: string; - url: string; - content?: string; - score?: number; - published_date?: string; -} - -interface TavilySearchResponse { - query: string; - answer?: string; - results: TavilyResultItem[]; -} - -/** - * Parameters for the WebSearchTool. - */ -export interface WebSearchToolParams { - /** - * The search query. - */ - query: string; -} - -/** - * Extends ToolResult to include sources for web search. - */ -export interface WebSearchToolResult extends ToolResult { - sources?: Array<{ title: string; url: string }>; -} - -class WebSearchToolInvocation extends BaseToolInvocation< - WebSearchToolParams, - WebSearchToolResult -> { - constructor( - private readonly config: Config, - params: WebSearchToolParams, - ) { - super(params); - } - - override getDescription(): string { - return `Searching the web for: "${this.params.query}"`; - } - - override async shouldConfirmExecute( - _abortSignal: AbortSignal, - ): Promise { - if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { - return false; - } - - const confirmationDetails: ToolInfoConfirmationDetails = { - type: 'info', - title: 'Confirm Web Search', - prompt: `Search the web for: "${this.params.query}"`, - onConfirm: async (outcome: ToolConfirmationOutcome) => { - if (outcome === ToolConfirmationOutcome.ProceedAlways) { - this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); - } - }, - }; - return confirmationDetails; - } - - async execute(signal: AbortSignal): Promise { - const apiKey = this.config.getTavilyApiKey(); - if (!apiKey) { - return { - llmContent: - 'Web search is disabled because TAVILY_API_KEY is not configured. Please set it in your settings.json, .env file, or via --tavily-api-key command line argument to enable web search.', - returnDisplay: - 'Web search disabled. Configure TAVILY_API_KEY to enable Tavily search.', - }; - } - - try { - const response = await fetch('https://api.tavily.com/search', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - api_key: apiKey, - query: this.params.query, - search_depth: 'advanced', - max_results: 5, - include_answer: true, - }), - signal, - }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error( - `Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, - ); - } - - const data = (await response.json()) as TavilySearchResponse; - - const sources = (data.results || []).map((r) => ({ - title: r.title, - url: r.url, - })); - - const sourceListFormatted = sources.map( - (s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`, - ); - - let content = data.answer?.trim() || ''; - if (!content) { - // Fallback: build a concise summary from top results - content = sources - .slice(0, 3) - .map((s, i) => `${i + 1}. ${s.title} - ${s.url}`) - .join('\n'); - } - - if (sourceListFormatted.length > 0) { - content += `\n\nSources:\n${sourceListFormatted.join('\n')}`; - } - - if (!content.trim()) { - return { - llmContent: `No search results or information found for query: "${this.params.query}"`, - returnDisplay: 'No information found.', - }; - } - - return { - llmContent: `Web search results for "${this.params.query}":\n\n${content}`, - returnDisplay: `Search results for "${this.params.query}" returned.`, - sources, - }; - } catch (error: unknown) { - const errorMessage = `Error during web search for query "${this.params.query}": ${getErrorMessage( - error, - )}`; - console.error(errorMessage, error); - return { - llmContent: `Error: ${errorMessage}`, - returnDisplay: `Error performing web search.`, - }; - } - } -} - -/** - * A tool to perform web searches using Tavily Search via the Tavily API. - */ -export class WebSearchTool extends BaseDeclarativeTool< - WebSearchToolParams, - WebSearchToolResult -> { - static readonly Name: string = 'web_search'; - - constructor(private readonly config: Config) { - super( - WebSearchTool.Name, - 'WebSearch', - 'Performs a web search using the Tavily API and returns a concise answer with sources. Requires the TAVILY_API_KEY environment variable.', - Kind.Search, - { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search query to find information on the web.', - }, - }, - required: ['query'], - }, - ); - } - - /** - * Validates the parameters for the WebSearchTool. - * @param params The parameters to validate - * @returns An error message string if validation fails, null if valid - */ - protected override validateToolParamValues( - params: WebSearchToolParams, - ): string | null { - if (!params.query || params.query.trim() === '') { - return "The 'query' parameter cannot be empty."; - } - return null; - } - - protected createInvocation( - params: WebSearchToolParams, - ): ToolInvocation { - return new WebSearchToolInvocation(this.config, params); - } -} diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 06e3cc5f..52b62266 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -108,34 +108,36 @@ class WebSearchToolInvocation extends BaseToolInvocation< /** * Select the appropriate provider based on configuration and parameters. + * Throws error if provider not found. */ private selectProvider( providers: Map, requestedProvider?: string, defaultProvider?: string, - ): { provider: WebSearchProvider | null; error?: string } { + ): WebSearchProvider { // Use requested provider if specified if (requestedProvider) { const provider = providers.get(requestedProvider); if (!provider) { - const availableProviders = Array.from(providers.keys()).join(', '); - return { - provider: null, - error: `The specified provider "${requestedProvider}" is not available or not configured. Available providers: ${availableProviders}`, - }; + const available = Array.from(providers.keys()).join(', '); + throw new Error( + `The specified provider "${requestedProvider}" is not available. Available: ${available}`, + ); } - return { provider }; + return provider; } // Use default provider if specified and available if (defaultProvider && providers.has(defaultProvider)) { - const provider = providers.get(defaultProvider)!; - return { provider }; + return providers.get(defaultProvider)!; } // Fallback to first available provider const firstProvider = providers.values().next().value; - return { provider: firstProvider || null }; + if (!firstProvider) { + throw new Error('No web search providers are available.'); + } + return firstProvider; } /** @@ -166,76 +168,55 @@ class WebSearchToolInvocation extends BaseToolInvocation< } async execute(signal: AbortSignal): Promise { + // Guard: Check configuration exists const webSearchConfig = this.config.getWebSearchConfig(); if (!webSearchConfig) { + const message = + 'Web search is disabled. Please configure web search providers in settings.json.'; return { - llmContent: - 'Web search is disabled because no web search configuration is available. Please configure web search providers in your settings.json.', + llmContent: message, returnDisplay: - 'Web search disabled. Configure web search providers to enable search.', - }; - } - - const providers = this.createProviders(webSearchConfig.provider); - - const { provider: selectedProvider, error } = this.selectProvider( - providers, - this.params.provider, - webSearchConfig.default, - ); - - if (error) { - return { - llmContent: error, - returnDisplay: `Provider "${this.params.provider}" not available.`, + 'Web search disabled. Configure providers to enable search.', error: { - message: error, - type: ToolErrorType.INVALID_TOOL_PARAMS, - }, - }; - } - - if (!selectedProvider) { - const errorMsg = - 'Web search is disabled because no web search providers are available. Please check your configuration.'; - return { - llmContent: errorMsg, - returnDisplay: 'Web search disabled. No available providers.', - error: { - message: errorMsg, + message, type: ToolErrorType.EXECUTION_FAILED, }, }; } try { - const searchResult = await selectedProvider.search( - this.params.query, - signal, + // Create and select provider + const providers = this.createProviders(webSearchConfig.provider); + const provider = this.selectProvider( + providers, + this.params.provider, + webSearchConfig.default, ); + // Perform search + const searchResult = await provider.search(this.params.query, signal); const { content, sources } = this.formatSearchResults(searchResult); + // Guard: Check if we got results if (!content.trim()) { return { - llmContent: `No search results or information found for query: "${this.params.query}" (searched via ${selectedProvider.name})`, + llmContent: `No search results found for query: "${this.params.query}" (via ${provider.name})`, returnDisplay: `No information found for "${this.params.query}".`, }; } + // Success result return { - llmContent: `Web search results for "${this.params.query}" (via ${selectedProvider.name}):\n\n${content}`, + llmContent: `Web search results for "${this.params.query}" (via ${provider.name}):\n\n${content}`, returnDisplay: `Search results for "${this.params.query}".`, sources, }; } catch (error: unknown) { - const errorMessage = `Error during web search for query "${this.params.query}": ${getErrorMessage( - error, - )}`; + const errorMessage = `Error during web search: ${getErrorMessage(error)}`; console.error(errorMessage, error); return { - llmContent: `Error: ${errorMessage}`, - returnDisplay: `Error performing web search.`, + llmContent: errorMessage, + returnDisplay: 'Error performing web search.', error: { message: errorMessage, type: ToolErrorType.EXECUTION_FAILED, From 799d2bf0db3511f159106e93e73f7d3bff8fe853 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 27 Oct 2025 19:59:13 +0800 Subject: [PATCH 05/25] feat: add oauth credit token --- .../providers/dashscope-provider.ts | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/core/src/tools/web-search/providers/dashscope-provider.ts b/packages/core/src/tools/web-search/providers/dashscope-provider.ts index 20491ab7..ddf44bb6 100644 --- a/packages/core/src/tools/web-search/providers/dashscope-provider.ts +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -4,12 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { promises as fs } from 'node:fs'; +import * as os from 'os'; +import * as path from 'path'; import { BaseWebSearchProvider } from '../base-provider.js'; import type { WebSearchResult, WebSearchResultItem, DashScopeProviderConfig, } from '../types.js'; +import type { QwenCredentials } from '../../../qwen/qwenOAuth2.js'; interface DashScopeSearchItem { _id: string; @@ -54,6 +58,30 @@ interface DashScopeSearchResponse { success: boolean; } +// File System Configuration +const QWEN_DIR = '.qwen'; +const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json'; + +/** + * Get the path to the cached OAuth credentials file. + */ +function getQwenCachedCredentialPath(): string { + return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME); +} + +/** + * Load cached Qwen OAuth credentials from disk. + */ +async function loadQwenCredentials(): Promise { + try { + const keyFile = getQwenCachedCredentialPath(); + const creds = await fs.readFile(keyFile, 'utf-8'); + return JSON.parse(creds) as QwenCredentials; + } catch { + return null; + } +} + /** * Web search provider using Alibaba Cloud DashScope API. */ @@ -69,32 +97,49 @@ export class DashScopeProvider extends BaseWebSearchProvider { // return !!(this.config.apiKey && this.config.uid && this.config.appId); } + /** + * Get the access token for authentication. + * Tries OAuth credentials first, falls back to apiKey if OAuth is not available. + */ + private async getAccessToken(): Promise { + // Try to load OAuth credentials first + const credentials = await loadQwenCredentials(); + if (credentials?.access_token) { + // Check if token is not expired + if (credentials.expiry_date && credentials.expiry_date > Date.now()) { + return credentials.access_token; + } + } + + // Fallback to apiKey from config if OAuth is not available + return this.config.apiKey || null; + } + protected async performSearch( query: string, signal: AbortSignal, ): Promise { + // Get access token from OAuth credentials or fallback to apiKey + const accessToken = await this.getAccessToken(); + if (!accessToken) { + throw new Error( + 'No access token available. Please authenticate using OAuth', + ); + } + const requestBody = { - rid: '', - uid: this.config.uid!, - scene: this.config.scene || 'dolphin_search_inner_turbo', uq: query, - fields: [], page: 1, rows: this.config.maxResults || 10, - customConfigInfo: {}, - headers: { - __d_head_qto: this.config.timeout || 8000, - __d_head_app: this.config.appId!, - }, }; const response = await fetch( - 'https://dashscope.aliyuncs.com/api/v1/indices/plugin/web_search', + 'https://pre-portal.qwen.ai/api/v1/indices/plugin/web_search', { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: this.config.apiKey!, + Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify(requestBody), signal, From 4781736f99a274f948a03c04032206f58467e8bb Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 30 Oct 2025 16:15:42 +0800 Subject: [PATCH 06/25] Improve web search fallback with snippet and web_fetch hint --- packages/core/src/tools/web-search/index.ts | 47 ++++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 52b62266..3f8796ab 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -18,7 +18,7 @@ import { ToolErrorType } from '../tool-error.js'; import type { Config } from '../../config/config.js'; import { ApprovalMode } from '../../config/config.js'; import { getErrorMessage } from '../../utils/errors.js'; -import { buildContentWithSources, buildSummary } from './utils.js'; +import { buildContentWithSources } from './utils.js'; import { TavilyProvider } from './providers/tavily-provider.js'; import { GoogleProvider } from './providers/google-provider.js'; import { DashScopeProvider } from './providers/dashscope-provider.js'; @@ -156,13 +156,46 @@ class WebSearchToolInvocation extends BaseToolInvocation< })); let content = searchResult.answer?.trim() || ''; - if (!content) { - // Fallback: build a concise summary from top results - content = buildSummary(sources, 3); - } - // Add sources section - content = buildContentWithSources(content, sources); + if (!content) { + // Fallback: Build an informative summary with title + snippet + source link + // This provides enough context for the LLM while keeping token usage efficient + content = searchResult.results + .slice(0, 5) // Top 5 results + .map((r, i) => { + const parts = [`${i + 1}. **${r.title}**`]; + + // Include snippet/content if available + if (r.content?.trim()) { + parts.push(` ${r.content.trim()}`); + } + + // Always include the source URL + parts.push(` Source: ${r.url}`); + + // Optionally include relevance score if available + if (r.score !== undefined) { + parts.push(` Relevance: ${(r.score * 100).toFixed(0)}%`); + } + + // Optionally include publish date if available + if (r.publishedDate) { + parts.push(` Published: ${r.publishedDate}`); + } + + return parts.join('\n'); + }) + .join('\n\n'); + + // Add a note about using web_fetch for detailed content + if (content) { + content += + '\n\n*Note: For detailed content from any source above, use the web_fetch tool with the URL.*'; + } + } else { + // When answer is available, append sources section + content = buildContentWithSources(content, sources); + } return { content, sources }; } From 9a41db612a0a741a50952d4174767cb03a1370d1 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 30 Oct 2025 16:18:41 +0800 Subject: [PATCH 07/25] Add unit tests for web search core logic --- .../core/src/tools/web-search/index.test.ts | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 packages/core/src/tools/web-search/index.test.ts diff --git a/packages/core/src/tools/web-search/index.test.ts b/packages/core/src/tools/web-search/index.test.ts new file mode 100644 index 00000000..288aa5ea --- /dev/null +++ b/packages/core/src/tools/web-search/index.test.ts @@ -0,0 +1,276 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WebSearchTool } from './index.js'; +import type { Config } from '../../config/config.js'; +import type { WebSearchConfig } from './types.js'; +import { ApprovalMode } from '../../config/config.js'; + +describe('WebSearchTool', () => { + let mockConfig: Config; + + beforeEach(() => { + vi.resetAllMocks(); + mockConfig = { + getApprovalMode: vi.fn(() => ApprovalMode.AUTO_EDIT), + setApprovalMode: vi.fn(), + getWebSearchConfig: vi.fn(), + } as unknown as Config; + }); + + describe('formatSearchResults', () => { + it('should use answer when available and append sources', async () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'tavily', + apiKey: 'test-key', + }, + ], + default: 'tavily', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + // Mock fetch to return search results with answer + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: 'test query', + answer: 'This is a concise answer from the search provider.', + results: [ + { + title: 'Result 1', + url: 'https://example.com/1', + content: 'Content 1', + }, + { + title: 'Result 2', + url: 'https://example.com/2', + content: 'Content 2', + }, + ], + }), + }); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain( + 'This is a concise answer from the search provider.', + ); + expect(result.llmContent).toContain('Sources:'); + expect(result.llmContent).toContain( + '[1] Result 1 (https://example.com/1)', + ); + expect(result.llmContent).toContain( + '[2] Result 2 (https://example.com/2)', + ); + }); + + it('should build informative summary when answer is not available', async () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'google', + apiKey: 'test-key', + searchEngineId: 'test-engine', + }, + ], + default: 'google', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + // Mock fetch to return search results without answer + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + items: [ + { + title: 'Google Result 1', + link: 'https://example.com/1', + snippet: 'This is a helpful snippet from the first result.', + }, + { + title: 'Google Result 2', + link: 'https://example.com/2', + snippet: 'This is a helpful snippet from the second result.', + }, + ], + }), + }); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + // Should contain formatted results with title, snippet, and source + expect(result.llmContent).toContain('1. **Google Result 1**'); + expect(result.llmContent).toContain( + 'This is a helpful snippet from the first result.', + ); + expect(result.llmContent).toContain('Source: https://example.com/1'); + expect(result.llmContent).toContain('2. **Google Result 2**'); + expect(result.llmContent).toContain( + 'This is a helpful snippet from the second result.', + ); + expect(result.llmContent).toContain('Source: https://example.com/2'); + + // Should include web_fetch hint + expect(result.llmContent).toContain('web_fetch tool'); + }); + + it('should include optional fields when available', async () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'tavily', + apiKey: 'test-key', + }, + ], + default: 'tavily', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + // Mock fetch to return results with score and publishedDate + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: 'test query', + results: [ + { + title: 'Result with metadata', + url: 'https://example.com', + content: 'Content with metadata', + score: 0.95, + published_date: '2024-01-15', + }, + ], + }), + }); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + // Should include relevance score + expect(result.llmContent).toContain('Relevance: 95%'); + // Should include published date + expect(result.llmContent).toContain('Published: 2024-01-15'); + }); + + it('should handle empty results gracefully', async () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'google', + apiKey: 'test-key', + searchEngineId: 'test-engine', + }, + ], + default: 'google', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + // Mock fetch to return empty results + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + items: [], + }), + }); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('No search results found'); + }); + + it('should limit to top 5 results in fallback mode', async () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'google', + apiKey: 'test-key', + searchEngineId: 'test-engine', + }, + ], + default: 'google', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + // Mock fetch to return 10 results + const items = Array.from({ length: 10 }, (_, i) => ({ + title: `Result ${i + 1}`, + link: `https://example.com/${i + 1}`, + snippet: `Snippet ${i + 1}`, + })); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ items }), + }); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + // Should only contain first 5 results + expect(result.llmContent).toContain('1. **Result 1**'); + expect(result.llmContent).toContain('5. **Result 5**'); + expect(result.llmContent).not.toContain('6. **Result 6**'); + expect(result.llmContent).not.toContain('10. **Result 10**'); + }); + }); + + describe('validation', () => { + it('should throw validation error when query is empty', () => { + const tool = new WebSearchTool(mockConfig); + expect(() => tool.build({ query: '' })).toThrow( + "The 'query' parameter cannot be empty", + ); + }); + + it('should throw validation error when provider is empty string', () => { + const tool = new WebSearchTool(mockConfig); + expect(() => tool.build({ query: 'test', provider: '' })).toThrow( + "The 'provider' parameter cannot be empty", + ); + }); + }); + + describe('configuration', () => { + it('should return error when web search is not configured', async () => { + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(null); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error?.message).toContain('Web search is disabled'); + expect(result.llmContent).toContain('Web search is disabled'); + }); + }); +}); From 864bf03fee85be1045bffe7fcd4ef464091dd99c Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 30 Oct 2025 19:06:46 +0800 Subject: [PATCH 08/25] docs: add DashScope quota limits to web search documentation - Add quota information (200 requests/minute, 2000 requests/day) to DashScope provider description - Update provider details section with quota limits --- docs/tools/web-search.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/tools/web-search.md b/docs/tools/web-search.md index 60617d71..b7085ea2 100644 --- a/docs/tools/web-search.md +++ b/docs/tools/web-search.md @@ -8,7 +8,7 @@ Use `web_search` to perform a web search and get information from the internet. ### Supported Providers -1. **DashScope** (Official, Free) - Default provider, always available when using Qwen OAuth authentication +1. **DashScope** (Official, Free) - Default provider, always available when using Qwen OAuth authentication (200 requests/minute, 2000 requests/day) 2. **Tavily** - High-quality search API with built-in answer generation 3. **Google Custom Search** - Google's Custom Search JSON API @@ -120,6 +120,7 @@ web_search(query="best practices for React 19", provider="dashscope") - **Cost:** Free - **Authentication:** Automatically available with Qwen OAuth - **Configuration:** No API key required +- **Quota:** 200 requests/minute, 2000 requests/day - **Best for:** General queries, always available ### Tavily From a40479d40a3f3bdc1e2c7b53b3efd7b6895b7ff1 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 30 Oct 2025 20:21:30 +0800 Subject: [PATCH 09/25] feat: adjust the description of the web search tool --- packages/core/src/tools/web-search/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 3f8796ab..a25002e1 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -272,7 +272,7 @@ export class WebSearchTool extends BaseDeclarativeTool< super( WebSearchTool.Name, 'WebSearch', - 'Performs a web search using configurable providers and returns a concise answer with sources.', + 'Allows searching the web and using results to inform responses. Provides up-to-date information for current events and recent data beyond the training data cutoff. Returns search results formatted with concise answers and source links. Use this tool when accessing information that may be outdated or beyond the knowledge cutoff.', Kind.Search, { type: 'object', From 40d82a2b254e831124ffa6a9f3f6974d33c7fc16 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Fri, 31 Oct 2025 10:19:44 +0800 Subject: [PATCH 10/25] feat: add docs for web search tool --- docs/tools/web-search.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/tools/web-search.md b/docs/tools/web-search.md index b7085ea2..a2c7f6fa 100644 --- a/docs/tools/web-search.md +++ b/docs/tools/web-search.md @@ -91,6 +91,20 @@ qwen --web-search-default tavily **Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration. +## Disabling Web Search + +If you want to disable the web search functionality, you can exclude the `web_search` tool in your `settings.json`: + +```json +{ + "tools": { + "exclude": ["web_search"] + } +} +``` + +**Note:** This setting requires a restart of Qwen Code to take effect. Once disabled, the `web_search` tool will not be available to the model, even if web search providers are configured. + ## Usage Examples ### Basic search (using default provider) From 50d5cc2f6a4b3449fa70e7312ac9881b33124e85 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Fri, 31 Oct 2025 17:00:28 +0800 Subject: [PATCH 11/25] fix: handle AbortError gracefully when loading commands --- .../src/services/FileCommandLoader.test.ts | 24 +++++++++++++++++++ .../cli/src/services/FileCommandLoader.ts | 6 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index 7713775c..50c85e66 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -1227,4 +1227,28 @@ describe('FileCommandLoader', () => { expect(commands).toHaveLength(0); }); }); + + describe('AbortError handling', () => { + it('should silently ignore AbortError when operation is cancelled', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test1.toml': 'prompt = "Prompt 1"', + 'test2.toml': 'prompt = "Prompt 2"', + }, + }); + + const loader = new FileCommandLoader(null); + const controller = new AbortController(); + const signal = controller.signal; + + // Start loading and immediately abort + const loadPromise = loader.loadCommands(signal); + controller.abort(); + + // Should not throw or print errors + const commands = await loadPromise; + expect(commands).toHaveLength(0); + }); + }); }); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index fe485fa2..5527aa80 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -120,7 +120,11 @@ export class FileCommandLoader implements ICommandLoader { // Add all commands without deduplication allCommands.push(...commands); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + // Ignore ENOENT (directory doesn't exist) and AbortError (operation was cancelled) + const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT'; + const isAbortError = + error instanceof Error && error.name === 'AbortError'; + if (!isEnoent && !isAbortError) { console.error( `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`, error, From d8cc0a1f04d0a3949b3566e5033d804ccd69be0a Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 4 Nov 2025 15:52:46 +0800 Subject: [PATCH 12/25] fix: #923 missing macos seatbelt files in npm package (#949) --- scripts/prepare-package.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index 7c9865fb..12268d61 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -85,7 +85,7 @@ const distPackageJson = { bin: { qwen: 'cli.js', }, - files: ['cli.js', 'vendor', 'README.md', 'LICENSE'], + files: ['cli.js', 'vendor', '*.sb', 'README.md', 'LICENSE'], config: rootPackageJson.config, dependencies: runtimeDependencies, optionalDependencies: { From 04f0996327f0837f29e7e17893cff57da469454d Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 4 Nov 2025 15:53:03 +0800 Subject: [PATCH 13/25] fix: /ide install failed to run on Windows (#957) --- packages/cli/src/ui/AppContainer.tsx | 12 ++---------- packages/core/src/ide/ide-installer.test.ts | 9 +++++++-- packages/core/src/ide/ide-installer.ts | 5 +++-- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5e76bc19..059d1dc4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -916,17 +916,9 @@ export const AppContainer = (props: AppContainerProps) => { (result: IdeIntegrationNudgeResult) => { if (result.userSelection === 'yes') { handleSlashCommand('/ide install'); - settings.setValue( - SettingScope.User, - 'hasSeenIdeIntegrationNudge', - true, - ); + settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); } else if (result.userSelection === 'dismiss') { - settings.setValue( - SettingScope.User, - 'hasSeenIdeIntegrationNudge', - true, - ); + settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); } setIdePromptAnswered(true); }, diff --git a/packages/core/src/ide/ide-installer.test.ts b/packages/core/src/ide/ide-installer.test.ts index fe83d5b5..fe112f1c 100644 --- a/packages/core/src/ide/ide-installer.test.ts +++ b/packages/core/src/ide/ide-installer.test.ts @@ -112,14 +112,19 @@ describe('ide-installer', () => { platform: 'linux', }); await installer.install(); + + // Note: The implementation uses process.platform, not the mocked platform + const isActuallyWindows = process.platform === 'win32'; + const expectedCommand = isActuallyWindows ? '"code"' : 'code'; + expect(child_process.spawnSync).toHaveBeenCalledWith( - 'code', + expectedCommand, [ '--install-extension', 'qwenlm.qwen-code-vscode-ide-companion', '--force', ], - { stdio: 'pipe' }, + { stdio: 'pipe', shell: isActuallyWindows }, ); }); diff --git a/packages/core/src/ide/ide-installer.ts b/packages/core/src/ide/ide-installer.ts index 577c68a7..ab3e268e 100644 --- a/packages/core/src/ide/ide-installer.ts +++ b/packages/core/src/ide/ide-installer.ts @@ -117,15 +117,16 @@ class VsCodeInstaller implements IdeInstaller { }; } + const isWindows = process.platform === 'win32'; try { const result = child_process.spawnSync( - commandPath, + isWindows ? `"${commandPath}"` : commandPath, [ '--install-extension', 'qwenlm.qwen-code-vscode-ide-companion', '--force', ], - { stdio: 'pipe' }, + { stdio: 'pipe', shell: isWindows }, ); if (result.status !== 0) { From 45f1000dea3449b30c2b3870d51cb0048beb1670 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 4 Nov 2025 15:53:31 +0800 Subject: [PATCH 14/25] fix (#958) --- packages/core/src/ide/ide-client.test.ts | 8 ++++---- packages/core/src/ide/ide-client.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 990b4a5e..ca26f78f 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -113,7 +113,7 @@ describe('IdeClient', () => { 'utf8', ); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( - new URL('http://localhost:8080/mcp'), + new URL('http://127.0.0.1:8080/mcp'), expect.any(Object), ); expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport); @@ -181,7 +181,7 @@ describe('IdeClient', () => { await ideClient.connect(); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( - new URL('http://localhost:9090/mcp'), + new URL('http://127.0.0.1:9090/mcp'), expect.any(Object), ); expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport); @@ -230,7 +230,7 @@ describe('IdeClient', () => { await ideClient.connect(); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( - new URL('http://localhost:8080/mcp'), + new URL('http://127.0.0.1:8080/mcp'), expect.any(Object), ); expect(ideClient.getConnectionStatus().status).toBe( @@ -665,7 +665,7 @@ describe('IdeClient', () => { await ideClient.connect(); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( - new URL('http://localhost:8080/mcp'), + new URL('http://127.0.0.1:8080/mcp'), expect.objectContaining({ requestInit: { headers: { diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 7f926b17..b447f46c 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -667,10 +667,10 @@ export class IdeClient { } private createProxyAwareFetch() { - // ignore proxy for 'localhost' by deafult to allow connecting to the ide mcp server + // ignore proxy for '127.0.0.1' by deafult to allow connecting to the ide mcp server const existingNoProxy = process.env['NO_PROXY'] || ''; const agent = new EnvHttpProxyAgent({ - noProxy: [existingNoProxy, 'localhost'].filter(Boolean).join(','), + noProxy: [existingNoProxy, '127.0.0.1'].filter(Boolean).join(','), }); const undiciPromise = import('undici'); return async (url: string | URL, init?: RequestInit): Promise => { @@ -851,5 +851,5 @@ export class IdeClient { function getIdeServerHost() { const isInContainer = fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv'); - return isInContainer ? 'host.docker.internal' : 'localhost'; + return isInContainer ? 'host.docker.internal' : '127.0.0.1'; } From d1507e73fe26bb45c71bd4ab6e2c661f249793c9 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Tue, 4 Nov 2025 16:59:30 +0800 Subject: [PATCH 15/25] feat(web-search): use resource_url from credentials for DashScope endpoint --- .../providers/dashscope-provider.ts | 59 +++++++++++++------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/packages/core/src/tools/web-search/providers/dashscope-provider.ts b/packages/core/src/tools/web-search/providers/dashscope-provider.ts index ddf44bb6..4222d747 100644 --- a/packages/core/src/tools/web-search/providers/dashscope-provider.ts +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -98,29 +98,53 @@ export class DashScopeProvider extends BaseWebSearchProvider { } /** - * Get the access token for authentication. + * Get the access token and API endpoint for authentication and web search. * Tries OAuth credentials first, falls back to apiKey if OAuth is not available. + * Returns both token and endpoint to avoid loading credentials multiple times. */ - private async getAccessToken(): Promise { - // Try to load OAuth credentials first + private async getAuthConfig(): Promise<{ + accessToken: string | null; + apiEndpoint: string; + }> { + // Load credentials once const credentials = await loadQwenCredentials(); + + // Get access token: try OAuth credentials first, fallback to apiKey + let accessToken: string | null = null; if (credentials?.access_token) { // Check if token is not expired if (credentials.expiry_date && credentials.expiry_date > Date.now()) { - return credentials.access_token; + accessToken = credentials.access_token; } } + if (!accessToken) { + accessToken = this.config.apiKey || null; + } - // Fallback to apiKey from config if OAuth is not available - return this.config.apiKey || null; + // Get API endpoint: use resource_url from credentials + if (!credentials?.resource_url) { + throw new Error( + 'No resource_url found in credentials. Please authenticate using OAuth', + ); + } + + // Normalize the URL: add protocol if missing + const baseUrl = credentials.resource_url.startsWith('http') + ? credentials.resource_url + : `https://${credentials.resource_url}`; + // Remove trailing slash if present + const normalizedBaseUrl = baseUrl.replace(/\/$/, ''); + const apiEndpoint = `${normalizedBaseUrl}/api/v1/indices/plugin/web_search`; + + return { accessToken, apiEndpoint }; } protected async performSearch( query: string, signal: AbortSignal, ): Promise { - // Get access token from OAuth credentials or fallback to apiKey - const accessToken = await this.getAccessToken(); + // Get access token and API endpoint (loads credentials once) + const { accessToken, apiEndpoint } = await this.getAuthConfig(); if (!accessToken) { throw new Error( 'No access token available. Please authenticate using OAuth', @@ -133,18 +157,15 @@ export class DashScopeProvider extends BaseWebSearchProvider { rows: this.config.maxResults || 10, }; - const response = await fetch( - 'https://pre-portal.qwen.ai/api/v1/indices/plugin/web_search', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify(requestBody), - signal, + const response = await fetch(apiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, }, - ); + body: JSON.stringify(requestBody), + signal, + }); if (!response.ok) { const text = await response.text().catch(() => ''); From 7e827833bf399c5048fe21395e2f0927a0008650 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 4 Nov 2025 19:22:37 +0800 Subject: [PATCH 16/25] chore: pump version to 0.1.4 (#962) --- package-lock.json | 12 ++++++------ package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f7d65e0..5949a5e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.1.3", + "version": "0.1.4", "workspaces": [ "packages/*" ], @@ -16024,7 +16024,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { "@google/genai": "1.16.0", "@iarna/toml": "^2.2.5", @@ -16139,7 +16139,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.1.3", + "version": "0.1.4", "hasInstallScript": true, "dependencies": { "@google/genai": "1.16.0", @@ -16278,7 +16278,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.1.3", + "version": "0.1.4", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -16290,7 +16290,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.1.3", + "version": "0.1.4", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index 9a09c952..dd45471e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.1.3", + "version": "0.1.4", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.3" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.4" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5c738498..e30e8e86 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.1.3", + "version": "0.1.4", "description": "Qwen Code", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.3" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.4" }, "dependencies": { "@google/genai": "1.16.0", diff --git a/packages/core/package.json b/packages/core/package.json index 4f9d13b4..7f800797 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.1.3", + "version": "0.1.4", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index c77edc0c..6ff662f7 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.1.3", + "version": "0.1.4", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index ce122438..982a6118 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.1.3", + "version": "0.1.4", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { From 6357a5c87e83ce932f552328e005b71f54bdcc96 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Tue, 4 Nov 2025 19:59:19 +0800 Subject: [PATCH 17/25] feat(web-search): enable DashScope provider only for Qwen OAuth auth type --- packages/core/src/tools/web-search/index.ts | 26 +++++++++++++++---- .../providers/dashscope-provider.ts | 5 ++-- packages/core/src/tools/web-search/types.ts | 6 +++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index a25002e1..5a8e3b3b 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -17,6 +17,7 @@ import { ToolErrorType } from '../tool-error.js'; import type { Config } from '../../config/config.js'; import { ApprovalMode } from '../../config/config.js'; +import { AuthType } from '../../core/contentGenerator.js'; import { getErrorMessage } from '../../utils/errors.js'; import { buildContentWithSources } from './utils.js'; import { TavilyProvider } from './providers/tavily-provider.js'; @@ -28,6 +29,7 @@ import type { WebSearchProvider, WebSearchResultItem, WebSearchProviderConfig, + DashScopeProviderConfig, } from './types.js'; class WebSearchToolInvocation extends BaseToolInvocation< @@ -43,8 +45,15 @@ class WebSearchToolInvocation extends BaseToolInvocation< override getDescription(): string { const webSearchConfig = this.config.getWebSearchConfig(); - const provider = - this.params.provider || webSearchConfig?.default || 'tavily'; + const authType = this.config.getAuthType(); + let defaultProvider = webSearchConfig?.default; + + // If auth type is QWEN_OAUTH, prefer dashscope as default + if (authType === AuthType.QWEN_OAUTH && !defaultProvider) { + defaultProvider = 'dashscope'; + } + + const provider = this.params.provider || defaultProvider; return ` (Searching the web via ${provider})`; } @@ -77,8 +86,15 @@ class WebSearchToolInvocation extends BaseToolInvocation< return new TavilyProvider(config); case 'google': return new GoogleProvider(config); - case 'dashscope': - return new DashScopeProvider(config); + case 'dashscope': { + // Pass auth type to DashScope provider for availability check + const authType = this.config.getAuthType(); + const dashscopeConfig: DashScopeProviderConfig = { + ...config, + authType: authType as string | undefined, + }; + return new DashScopeProvider(dashscopeConfig); + } default: throw new Error('Unknown provider type'); } @@ -284,7 +300,7 @@ export class WebSearchTool extends BaseDeclarativeTool< provider: { type: 'string', description: - 'Optional provider to use for the search (e.g., "tavily", "google", "dashscope"). If not specified, the default provider will be used.', + 'Optional provider to use for the search (e.g., "tavily", "google", "dashscope"). IMPORTANT: Only specify this parameter if you explicitly know which provider to use. Otherwise, omit this parameter entirely and let the system automatically select the appropriate provider based on availability and configuration. The system will choose the best available provider automatically.', }, }, required: ['query'], diff --git a/packages/core/src/tools/web-search/providers/dashscope-provider.ts b/packages/core/src/tools/web-search/providers/dashscope-provider.ts index 4222d747..fce2b49d 100644 --- a/packages/core/src/tools/web-search/providers/dashscope-provider.ts +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -93,8 +93,9 @@ export class DashScopeProvider extends BaseWebSearchProvider { } isAvailable(): boolean { - return true; - // return !!(this.config.apiKey && this.config.uid && this.config.appId); + // DashScope provider is only available when auth type is QWEN_OAUTH + // This ensures it's only used when OAuth credentials are available + return this.config.authType === 'qwen-oauth'; } /** diff --git a/packages/core/src/tools/web-search/types.ts b/packages/core/src/tools/web-search/types.ts index 1983e166..12368df6 100644 --- a/packages/core/src/tools/web-search/types.ts +++ b/packages/core/src/tools/web-search/types.ts @@ -138,6 +138,12 @@ export interface DashScopeProviderConfig { maxResults?: number; scene?: string; timeout?: number; + /** + * Optional auth type to determine provider availability. + * If set to 'qwen-oauth', the provider will be available. + * If set to other values or undefined, the provider will check auth type dynamically. + */ + authType?: string; } /** From 2967bec11c7837c4115a88f8e41cc44b91302503 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 5 Nov 2025 11:23:27 +0800 Subject: [PATCH 18/25] feat: update code --- docs/cli/configuration-v1.md | 7 +- docs/cli/configuration.md | 7 +- docs/tools/web-search.md | 24 +++-- packages/cli/src/config/config.ts | 6 +- packages/cli/src/config/webSearch.ts | 100 +++++++++++++------- packages/core/src/config/config.ts | 10 +- packages/core/src/tools/web-search/index.ts | 31 +----- 7 files changed, 102 insertions(+), 83 deletions(-) diff --git a/docs/cli/configuration-v1.md b/docs/cli/configuration-v1.md index 0a16eb2e..0926d49e 100644 --- a/docs/cli/configuration-v1.md +++ b/docs/cli/configuration-v1.md @@ -309,7 +309,8 @@ If you are experiencing performance issues with file searching (e.g., with `@` c ``` - **`tavilyApiKey`** (string): - - **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped. + - **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality. + - **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format. - **Default:** `undefined` (web search disabled) - **Example:** `"tavilyApiKey": "tvly-your-api-key-here"` - **`chatCompression`** (object): @@ -465,8 +466,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi - This is useful for development and testing. - **`TAVILY_API_KEY`**: - Your API key for the Tavily web search service. - - Required to enable the `web_search` tool functionality. - - If not configured, the web search tool will be disabled and skipped. + - Used to enable the `web_search` tool functionality. + - **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search. - Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` ## Command-Line Arguments diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 4d593096..b152a701 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -305,7 +305,8 @@ Settings are organized into categories. All settings should be placed within the - **Default:** `undefined` - **`advanced.tavilyApiKey`** (string): - - **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped. + - **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality. + - **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format. - **Default:** `undefined` #### `mcpServers` @@ -474,8 +475,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi - Set to a string to customize the title of the CLI. - **`TAVILY_API_KEY`**: - Your API key for the Tavily web search service. - - Required to enable the `web_search` tool functionality. - - If not configured, the web search tool will be disabled and skipped. + - Used to enable the `web_search` tool functionality. + - **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search. - Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` ## Command-Line Arguments diff --git a/docs/tools/web-search.md b/docs/tools/web-search.md index a2c7f6fa..94845339 100644 --- a/docs/tools/web-search.md +++ b/docs/tools/web-search.md @@ -8,7 +8,7 @@ Use `web_search` to perform a web search and get information from the internet. ### Supported Providers -1. **DashScope** (Official, Free) - Default provider, always available when using Qwen OAuth authentication (200 requests/minute, 2000 requests/day) +1. **DashScope** (Official, Free) - Automatically available for Qwen OAuth users (200 requests/minute, 2000 requests/day) 2. **Tavily** - High-quality search API with built-in answer generation 3. **Google Custom Search** - Google's Custom Search JSON API @@ -46,8 +46,9 @@ Add to your `settings.json`: **Notes:** - DashScope doesn't require an API key (official, free service) -- Only configure the providers you want to use -- Set `default` to specify which provider to use by default +- **Qwen OAuth users:** DashScope is automatically added to your provider list, even if not explicitly configured +- Configure additional providers (Tavily, Google) if you want to use them alongside DashScope +- Set `default` to specify which provider to use by default (if not set, priority order: Tavily > Google > DashScope) ### Method 2: Environment Variables @@ -132,10 +133,11 @@ web_search(query="best practices for React 19", provider="dashscope") ### DashScope (Official) - **Cost:** Free -- **Authentication:** Automatically available with Qwen OAuth -- **Configuration:** No API key required +- **Authentication:** Automatically available when using Qwen OAuth authentication +- **Configuration:** No API key required, automatically added to provider list for Qwen OAuth users - **Quota:** 200 requests/minute, 2000 requests/day -- **Best for:** General queries, always available +- **Best for:** General queries, always available as fallback for Qwen OAuth users +- **Auto-registration:** If you're using Qwen OAuth, DashScope is automatically added to your provider list even if you don't configure it explicitly ### Tavily @@ -158,14 +160,18 @@ web_search(query="best practices for React 19", provider="dashscope") - **Response format:** Returns a concise answer with numbered source citations - **Citations:** Source links are appended as a numbered list: [1], [2], etc. - **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter -- **DashScope availability:** Always available when authenticated with Qwen OAuth, no configuration needed +- **DashScope availability:** Automatically available for Qwen OAuth users, no configuration needed +- **Default provider selection:** The system automatically selects a default provider based on availability: + 1. Your explicit `default` configuration (highest priority) + 2. CLI argument `--web-search-default` + 3. First available provider by priority: Tavily > Google > DashScope ## Troubleshooting **Tool not available?** -- Check if at least one provider is configured -- For DashScope: Ensure you're authenticated with Qwen OAuth +- **For Qwen OAuth users:** The tool is automatically registered with DashScope provider, no configuration needed +- **For other authentication types:** Ensure at least one provider (Tavily or Google) is configured - For Tavily/Google: Verify your API keys are correct **Provider-specific errors?** diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7c20ee83..bd016d76 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -766,7 +766,11 @@ export async function loadCliConfig( : argv.openaiLogging) ?? false, }, cliVersion: await getCliVersion(), - webSearch: buildWebSearchConfig(argv, settings), + webSearch: buildWebSearchConfig( + argv, + settings, + settings.security?.auth?.selectedType, + ), summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, chatCompression: settings.model?.chatCompression, diff --git a/packages/cli/src/config/webSearch.ts b/packages/cli/src/config/webSearch.ts index a558de17..260220ac 100644 --- a/packages/cli/src/config/webSearch.ts +++ b/packages/cli/src/config/webSearch.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { AuthType } from '@qwen-code/qwen-code-core'; import type { WebSearchProviderConfig } from '@qwen-code/qwen-code-core'; import type { Settings } from './settings.js'; @@ -33,56 +34,85 @@ export interface WebSearchConfig { * * @param argv - Command line arguments * @param settings - User settings from settings.json + * @param authType - Authentication type (e.g., 'qwen-oauth') * @returns WebSearch configuration or undefined if no providers available */ export function buildWebSearchConfig( argv: WebSearchCliArgs, settings: Settings, + authType?: string, ): WebSearchConfig | undefined { - // Priority 1: Use settings.json webSearch config if present + const isQwenOAuth = authType === AuthType.QWEN_OAUTH; + + // Step 1: Collect providers from settings or command line/env + let providers: WebSearchProviderConfig[] = []; + let userDefault: string | undefined; + if (settings.webSearch) { - return settings.webSearch; + // Use providers from settings.json + providers = [...settings.webSearch.provider]; + userDefault = settings.webSearch.default; + } else { + // Build providers from command line args and environment variables + const tavilyKey = + argv.tavilyApiKey || + settings.advanced?.tavilyApiKey || + process.env['TAVILY_API_KEY']; + if (tavilyKey) { + providers.push({ + type: 'tavily', + apiKey: tavilyKey, + } as WebSearchProviderConfig); + } + + const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY']; + const googleEngineId = + argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID']; + if (googleKey && googleEngineId) { + providers.push({ + type: 'google', + apiKey: googleKey, + searchEngineId: googleEngineId, + } as WebSearchProviderConfig); + } } - // Priority 2: Build from command line args and environment variables - const providers: WebSearchProviderConfig[] = []; - - // DashScope is always available (official, free) - providers.push({ type: 'dashscope' } as WebSearchProviderConfig); - - // Tavily from args/env/legacy settings - const tavilyKey = - argv.tavilyApiKey || - settings.advanced?.tavilyApiKey || - process.env['TAVILY_API_KEY']; - if (tavilyKey) { - providers.push({ - type: 'tavily', - apiKey: tavilyKey, - } as WebSearchProviderConfig); + // Step 2: Ensure dashscope is available for qwen-oauth users + if (isQwenOAuth) { + const hasDashscope = providers.some((p) => p.type === 'dashscope'); + if (!hasDashscope) { + providers.push({ type: 'dashscope' } as WebSearchProviderConfig); + } } - // Google from args/env - const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY']; - const googleEngineId = - argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID']; - if (googleKey && googleEngineId) { - providers.push({ - type: 'google', - apiKey: googleKey, - searchEngineId: googleEngineId, - } as WebSearchProviderConfig); - } - - // If no providers configured, return undefined + // Step 3: If no providers available, return undefined if (providers.length === 0) { return undefined; } - // Determine default provider - // Priority: CLI arg > has Tavily key > DashScope (fallback) - const defaultProvider = - argv.webSearchDefault || (tavilyKey ? 'tavily' : 'dashscope'); + // Step 4: Determine default provider + // Priority: user explicit config > CLI arg > first available provider (tavily > google > dashscope) + const providerPriority: Array<'tavily' | 'google' | 'dashscope'> = [ + 'tavily', + 'google', + 'dashscope', + ]; + + // Determine default provider based on availability + let defaultProvider = userDefault || argv.webSearchDefault; + if (!defaultProvider) { + // Find first available provider by priority order + for (const providerType of providerPriority) { + if (providers.some((p) => p.type === providerType)) { + defaultProvider = providerType; + break; + } + } + // Fallback to first available provider if none found in priority list + if (!defaultProvider) { + defaultProvider = providers[0]?.type || 'dashscope'; + } + } return { provider: providers, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6955c698..754551b4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1166,12 +1166,10 @@ export class Config { registerCoreTool(TodoWriteTool, this); registerCoreTool(ExitPlanModeTool, this); registerCoreTool(WebFetchTool, this); - // Conditionally register web search tool if any web search provider is configured - // or if using qwen-oauth authentication - if ( - this.getWebSearchConfig() || - this.getAuthType() === AuthType.QWEN_OAUTH - ) { + // Conditionally register web search tool if web search provider is configured + // buildWebSearchConfig ensures qwen-oauth users get dashscope provider, so + // if tool is registered, config must exist + if (this.getWebSearchConfig()) { registerCoreTool(WebSearchTool, this); } diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 5a8e3b3b..92855116 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -17,7 +17,6 @@ import { ToolErrorType } from '../tool-error.js'; import type { Config } from '../../config/config.js'; import { ApprovalMode } from '../../config/config.js'; -import { AuthType } from '../../core/contentGenerator.js'; import { getErrorMessage } from '../../utils/errors.js'; import { buildContentWithSources } from './utils.js'; import { TavilyProvider } from './providers/tavily-provider.js'; @@ -44,16 +43,9 @@ class WebSearchToolInvocation extends BaseToolInvocation< } override getDescription(): string { - const webSearchConfig = this.config.getWebSearchConfig(); - const authType = this.config.getAuthType(); - let defaultProvider = webSearchConfig?.default; - - // If auth type is QWEN_OAUTH, prefer dashscope as default - if (authType === AuthType.QWEN_OAUTH && !defaultProvider) { - defaultProvider = 'dashscope'; - } - - const provider = this.params.provider || defaultProvider; + // If tool is registered, config must exist with a default provider + const webSearchConfig = this.config.getWebSearchConfig()!; + const provider = this.params.provider || webSearchConfig.default; return ` (Searching the web via ${provider})`; } @@ -217,21 +209,8 @@ class WebSearchToolInvocation extends BaseToolInvocation< } async execute(signal: AbortSignal): Promise { - // Guard: Check configuration exists - const webSearchConfig = this.config.getWebSearchConfig(); - if (!webSearchConfig) { - const message = - 'Web search is disabled. Please configure web search providers in settings.json.'; - return { - llmContent: message, - returnDisplay: - 'Web search disabled. Configure providers to enable search.', - error: { - message, - type: ToolErrorType.EXECUTION_FAILED, - }, - }; - } + // If tool is registered, config must exist with providers and default + const webSearchConfig = this.config.getWebSearchConfig()!; try { // Create and select provider From 7ff07fd88c655060be037d9c02daa3f551195712 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 5 Nov 2025 11:37:56 +0800 Subject: [PATCH 19/25] fix(web-search): handle unconfigured state and improve tests --- integration-tests/web_search.test.ts | 47 +++++++++++++++++-- .../core/src/tools/web-search/index.test.ts | 36 ++++++++++++++ packages/core/src/tools/web-search/index.ts | 21 +++++++-- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/integration-tests/web_search.test.ts b/integration-tests/web_search.test.ts index 11cd78b6..680a1ffd 100644 --- a/integration-tests/web_search.test.ts +++ b/integration-tests/web_search.test.ts @@ -9,14 +9,53 @@ import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; describe('web_search', () => { it('should be able to search the web', async () => { - // Skip if Tavily key is not configured - if (!process.env['TAVILY_API_KEY']) { - console.warn('Skipping web search test: TAVILY_API_KEY not set'); + // Check if any web search provider is available + const hasTavilyKey = !!process.env['TAVILY_API_KEY']; + const hasGoogleKey = + !!process.env['GOOGLE_API_KEY'] && + !!process.env['GOOGLE_SEARCH_ENGINE_ID']; + + // Skip if no provider is configured + // Note: DashScope provider is automatically available for Qwen OAuth users, + // but we can't easily detect that in tests without actual OAuth credentials + if (!hasTavilyKey && !hasGoogleKey) { + console.warn( + 'Skipping web search test: No web search provider configured. ' + + 'Set TAVILY_API_KEY or GOOGLE_API_KEY+GOOGLE_SEARCH_ENGINE_ID environment variables.', + ); return; } const rig = new TestRig(); - await rig.setup('should be able to search the web'); + // Configure web search in settings if provider keys are available + const webSearchSettings: Record = {}; + const providers: Array<{ + type: string; + apiKey?: string; + searchEngineId?: string; + }> = []; + + if (hasTavilyKey) { + providers.push({ type: 'tavily', apiKey: process.env['TAVILY_API_KEY'] }); + } + if (hasGoogleKey) { + providers.push({ + type: 'google', + apiKey: process.env['GOOGLE_API_KEY'], + searchEngineId: process.env['GOOGLE_SEARCH_ENGINE_ID'], + }); + } + + if (providers.length > 0) { + webSearchSettings.webSearch = { + provider: providers, + default: providers[0]?.type, + }; + } + + await rig.setup('should be able to search the web', { + settings: webSearchSettings, + }); let result; try { diff --git a/packages/core/src/tools/web-search/index.test.ts b/packages/core/src/tools/web-search/index.test.ts index 288aa5ea..d851ceae 100644 --- a/packages/core/src/tools/web-search/index.test.ts +++ b/packages/core/src/tools/web-search/index.test.ts @@ -272,5 +272,41 @@ describe('WebSearchTool', () => { expect(result.error?.message).toContain('Web search is disabled'); expect(result.llmContent).toContain('Web search is disabled'); }); + + it('should return descriptive message in getDescription when web search is not configured', () => { + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(null); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const description = invocation.getDescription(); + + expect(description).toBe( + ' (Web search is disabled - configure a provider in settings.json)', + ); + }); + + it('should return provider name in getDescription when web search is configured', () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'tavily', + apiKey: 'test-key', + }, + ], + default: 'tavily', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const description = invocation.getDescription(); + + expect(description).toBe(' (Searching the web via tavily)'); + }); }); }); diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 92855116..f9962b52 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -43,8 +43,10 @@ class WebSearchToolInvocation extends BaseToolInvocation< } override getDescription(): string { - // If tool is registered, config must exist with a default provider - const webSearchConfig = this.config.getWebSearchConfig()!; + const webSearchConfig = this.config.getWebSearchConfig(); + if (!webSearchConfig) { + return ' (Web search is disabled - configure a provider in settings.json)'; + } const provider = this.params.provider || webSearchConfig.default; return ` (Searching the web via ${provider})`; } @@ -209,8 +211,19 @@ class WebSearchToolInvocation extends BaseToolInvocation< } async execute(signal: AbortSignal): Promise { - // If tool is registered, config must exist with providers and default - const webSearchConfig = this.config.getWebSearchConfig()!; + // Check if web search is configured + const webSearchConfig = this.config.getWebSearchConfig(); + if (!webSearchConfig) { + return { + llmContent: + 'Web search is disabled. Please configure a web search provider in your settings.', + returnDisplay: 'Web search is disabled.', + error: { + message: 'Web search is disabled', + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } try { // Create and select provider From 55a3b69a8ec5fb46e251ec06e9e5e5af6b305502 Mon Sep 17 00:00:00 2001 From: chenhuanjie Date: Wed, 5 Nov 2025 15:10:52 +0800 Subject: [PATCH 20/25] fix --- hello/RDMind.md | 8 +++++ packages/cli/.rdmind/settings.json | 6 ++++ .../extensions/examples/context/RDMind.md | 8 +++++ packages/core/src/core/tokenLimits.test.ts | 30 +++++++++++++++++-- packages/core/src/core/tokenLimits.ts | 20 ++++++++++--- 5 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 hello/RDMind.md create mode 100644 packages/cli/.rdmind/settings.json create mode 100644 packages/cli/src/commands/extensions/examples/context/RDMind.md diff --git a/hello/RDMind.md b/hello/RDMind.md new file mode 100644 index 00000000..22f6bbce --- /dev/null +++ b/hello/RDMind.md @@ -0,0 +1,8 @@ +# Ink Library Screen Reader Guidance + +When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience. + +## General Principles + +Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users. +Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on and to provide semantic meaning to screen readers. diff --git a/packages/cli/.rdmind/settings.json b/packages/cli/.rdmind/settings.json new file mode 100644 index 00000000..b435f054 --- /dev/null +++ b/packages/cli/.rdmind/settings.json @@ -0,0 +1,6 @@ +{ + "extensions": { + "disabled": [] + }, + "$version": 2 +} diff --git a/packages/cli/src/commands/extensions/examples/context/RDMind.md b/packages/cli/src/commands/extensions/examples/context/RDMind.md new file mode 100644 index 00000000..22f6bbce --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/context/RDMind.md @@ -0,0 +1,8 @@ +# Ink Library Screen Reader Guidance + +When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience. + +## General Principles + +Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users. +Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on and to provide semantic meaning to screen readers. diff --git a/packages/core/src/core/tokenLimits.test.ts b/packages/core/src/core/tokenLimits.test.ts index bb9f0fd2..b2cbbd24 100644 --- a/packages/core/src/core/tokenLimits.test.ts +++ b/packages/core/src/core/tokenLimits.test.ts @@ -64,6 +64,12 @@ describe('normalize', () => { expect(normalize('qwen-vl-max-latest')).toBe('qwen-vl-max-latest'); }); + it('should preserve date suffixes for Kimi K2 models', () => { + expect(normalize('kimi-k2-0905-preview')).toBe('kimi-k2-0905'); + expect(normalize('kimi-k2-0711-preview')).toBe('kimi-k2-0711'); + expect(normalize('kimi-k2-turbo-preview')).toBe('kimi-k2-turbo'); + }); + it('should remove date like suffixes', () => { expect(normalize('deepseek-r1-0528')).toBe('deepseek-r1'); }); @@ -213,7 +219,7 @@ describe('tokenLimit', () => { }); }); - describe('Other models', () => { + describe('DeepSeek', () => { it('should return the correct limit for deepseek-r1', () => { expect(tokenLimit('deepseek-r1')).toBe(131072); }); @@ -226,9 +232,27 @@ describe('tokenLimit', () => { it('should return the correct limit for deepseek-v3.2', () => { expect(tokenLimit('deepseek-v3.2-exp')).toBe(131072); }); - it('should return the correct limit for kimi-k2-instruct', () => { - expect(tokenLimit('kimi-k2-instruct')).toBe(131072); + }); + + describe('Moonshot Kimi', () => { + it('should return the correct limit for kimi-k2-0905-preview', () => { + expect(tokenLimit('kimi-k2-0905-preview')).toBe(262144); // 256K + expect(tokenLimit('kimi-k2-0905')).toBe(262144); }); + it('should return the correct limit for kimi-k2-turbo-preview', () => { + expect(tokenLimit('kimi-k2-turbo-preview')).toBe(262144); // 256K + expect(tokenLimit('kimi-k2-turbo')).toBe(262144); + }); + it('should return the correct limit for kimi-k2-0711-preview', () => { + expect(tokenLimit('kimi-k2-0711-preview')).toBe(131072); // 128K + expect(tokenLimit('kimi-k2-0711')).toBe(131072); + }); + it('should return the correct limit for kimi-k2-instruct', () => { + expect(tokenLimit('kimi-k2-instruct')).toBe(131072); // 128K + }); + }); + + describe('Other models', () => { it('should return the correct limit for gpt-oss', () => { expect(tokenLimit('gpt-oss')).toBe(131072); }); diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index cd3a0a0f..f2693075 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -47,8 +47,13 @@ export function normalize(model: string): string { // remove trailing build / date / revision suffixes: // - dates (e.g., -20250219), -v1, version numbers, 'latest', 'preview' etc. s = s.replace(/-preview/g, ''); - // Special handling for Qwen model names that include "-latest" as part of the model name - if (!s.match(/^qwen-(?:plus|flash|vl-max)-latest$/)) { + // Special handling for model names that include date/version as part of the model identifier + // - Qwen models: qwen-plus-latest, qwen-flash-latest, qwen-vl-max-latest + // - Kimi models: kimi-k2-0905, kimi-k2-0711, etc. (keep date for version distinction) + if ( + !s.match(/^qwen-(?:plus|flash|vl-max)-latest$/) && + !s.match(/^kimi-k2-\d{4}$/) + ) { // Regex breakdown: // -(?:...)$ - Non-capturing group for suffixes at the end of the string // The following patterns are matched within the group: @@ -165,9 +170,16 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [ [/^deepseek-v3(?:\.\d+)?(?:-.*)?$/, LIMITS['128k']], // ------------------- - // GPT-OSS / Kimi / Llama & Mistral examples + // Moonshot / Kimi + // ------------------- + [/^kimi-k2-0905$/, LIMITS['256k']], // Kimi-k2-0905-preview: 256K context + [/^kimi-k2-turbo.*$/, LIMITS['256k']], // Kimi-k2-turbo-preview: 256K context + [/^kimi-k2-0711$/, LIMITS['128k']], // Kimi-k2-0711-preview: 128K context + [/^kimi-k2-instruct.*$/, LIMITS['128k']], // Kimi-k2-instruct: 128K context + + // ------------------- + // GPT-OSS / Llama & Mistral examples // ------------------- - [/^kimi-k2-instruct.*$/, LIMITS['128k']], [/^gpt-oss.*$/, LIMITS['128k']], [/^llama-4-scout.*$/, LIMITS['10m']], [/^mistral-large-2.*$/, LIMITS['128k']], From f6f76a17e64d7fde1324151fbe81eed7f8144693 Mon Sep 17 00:00:00 2001 From: chenhuanjie Date: Wed, 5 Nov 2025 15:12:20 +0800 Subject: [PATCH 21/25] fix --- hello/RDMind.md | 8 -------- packages/cli/.rdmind/settings.json | 6 ------ .../src/commands/extensions/examples/context/RDMind.md | 8 -------- 3 files changed, 22 deletions(-) delete mode 100644 hello/RDMind.md delete mode 100644 packages/cli/.rdmind/settings.json delete mode 100644 packages/cli/src/commands/extensions/examples/context/RDMind.md diff --git a/hello/RDMind.md b/hello/RDMind.md deleted file mode 100644 index 22f6bbce..00000000 --- a/hello/RDMind.md +++ /dev/null @@ -1,8 +0,0 @@ -# Ink Library Screen Reader Guidance - -When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience. - -## General Principles - -Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users. -Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on and to provide semantic meaning to screen readers. diff --git a/packages/cli/.rdmind/settings.json b/packages/cli/.rdmind/settings.json deleted file mode 100644 index b435f054..00000000 --- a/packages/cli/.rdmind/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extensions": { - "disabled": [] - }, - "$version": 2 -} diff --git a/packages/cli/src/commands/extensions/examples/context/RDMind.md b/packages/cli/src/commands/extensions/examples/context/RDMind.md deleted file mode 100644 index 22f6bbce..00000000 --- a/packages/cli/src/commands/extensions/examples/context/RDMind.md +++ /dev/null @@ -1,8 +0,0 @@ -# Ink Library Screen Reader Guidance - -When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience. - -## General Principles - -Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users. -Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on and to provide semantic meaning to screen readers. From 448e30bf88ed650e6fd68e0aa1820b111c1fd20b Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 5 Nov 2025 16:06:35 +0800 Subject: [PATCH 22/25] feat: support custom working directory for child process in start.js --- scripts/start.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/start.js b/scripts/start.js index 9898d0be..baa9fd98 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -69,7 +69,14 @@ if (process.env.DEBUG) { // than the relaunched process making it harder to debug. env.GEMINI_CLI_NO_RELAUNCH = 'true'; } -const child = spawn('node', nodeArgs, { stdio: 'inherit', env }); +// Use process.cwd() to inherit the working directory from launch.json cwd setting +// This allows debugging from a specific directory (e.g., .todo) +const workingDir = process.env.QWEN_WORKING_DIR || process.cwd(); +const child = spawn('node', nodeArgs, { + stdio: 'inherit', + env, + cwd: workingDir, +}); child.on('close', (code) => { process.exit(code); From d4ab3286713bbbe354ad285882362bc773e36950 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 5 Nov 2025 18:49:04 +0800 Subject: [PATCH 23/25] feat: support for custom OpenAI logging directory configuration --- packages/cli/src/config/config.ts | 8 + packages/cli/src/config/settingsSchema.ts | 10 + packages/cli/src/gemini.test.tsx | 1 + .../__tests__/openaiTimeoutHandling.test.ts | 3 + packages/core/src/core/contentGenerator.ts | 1 + .../openaiContentGenerator.ts | 1 + .../telemetryService.ts | 17 +- packages/core/src/utils/openaiLogger.test.ts | 381 ++++++++++++++++++ packages/core/src/utils/openaiLogger.ts | 17 +- 9 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/utils/openaiLogger.test.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index bd016d76..d747e128 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -114,6 +114,7 @@ export interface CliArgs { openaiLogging: boolean | undefined; openaiApiKey: string | undefined; openaiBaseUrl: string | undefined; + openaiLoggingDir: string | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; tavilyApiKey: string | undefined; @@ -317,6 +318,11 @@ export async function parseArguments(settings: Settings): Promise { description: 'Enable logging of OpenAI API calls for debugging and analysis', }) + .option('openai-logging-dir', { + type: 'string', + description: + 'Custom directory path for OpenAI API logs. Overrides settings files.', + }) .option('openai-api-key', { type: 'string', description: 'OpenAI API key to use for authentication', @@ -764,6 +770,8 @@ export async function loadCliConfig( (typeof argv.openaiLogging === 'undefined' ? settings.model?.enableOpenAILogging : argv.openaiLogging) ?? false, + openAILoggingDir: + argv.openaiLoggingDir || settings.model?.openAILoggingDir, }, cliVersion: await getCliVersion(), webSearch: buildWebSearchConfig( diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index bd87163a..da504c29 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -558,6 +558,16 @@ const SETTINGS_SCHEMA = { description: 'Enable OpenAI logging.', showInDialog: true, }, + openAILoggingDir: { + type: 'string', + label: 'OpenAI Logging Directory', + category: 'Model', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.', + showInDialog: true, + }, generationConfig: { type: 'object', label: 'Generation Configuration', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 76d2c772..a5b34922 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -327,6 +327,7 @@ describe('gemini.tsx main function kitty protocol', () => { openaiLogging: undefined, openaiApiKey: undefined, openaiBaseUrl: undefined, + openaiLoggingDir: undefined, proxy: undefined, includeDirectories: undefined, tavilyApiKey: undefined, diff --git a/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts b/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts index 7f4eec69..07d3b930 100644 --- a/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts +++ b/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts @@ -21,6 +21,9 @@ vi.mock('../../telemetry/loggers.js', () => ({ })); vi.mock('../../utils/openaiLogger.js', () => ({ + OpenAILogger: vi.fn().mockImplementation(() => ({ + logInteraction: vi.fn(), + })), openaiLogger: { logInteraction: vi.fn(), }, diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 3258cd5c..4d0d33a9 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -58,6 +58,7 @@ export type ContentGeneratorConfig = { vertexai?: boolean; authType?: AuthType | undefined; enableOpenAILogging?: boolean; + openAILoggingDir?: string; // Timeout configuration in milliseconds timeout?: number; // Maximum retries for failed requests diff --git a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts index 91e69527..ae1f43e5 100644 --- a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts +++ b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts @@ -32,6 +32,7 @@ export class OpenAIContentGenerator implements ContentGenerator { telemetryService: new DefaultTelemetryService( cliConfig, contentGeneratorConfig.enableOpenAILogging, + contentGeneratorConfig.openAILoggingDir, ), errorHandler: new EnhancedErrorHandler( (error: unknown, request: GenerateContentParameters) => diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.ts index 23560793..9fa47263 100644 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.ts +++ b/packages/core/src/core/openaiContentGenerator/telemetryService.ts @@ -7,7 +7,7 @@ import type { Config } from '../../config/config.js'; import { logApiError, logApiResponse } from '../../telemetry/loggers.js'; import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js'; -import { openaiLogger } from '../../utils/openaiLogger.js'; +import { OpenAILogger } from '../../utils/openaiLogger.js'; import type { GenerateContentResponse } from '@google/genai'; import type OpenAI from 'openai'; @@ -43,10 +43,17 @@ export interface TelemetryService { } export class DefaultTelemetryService implements TelemetryService { + private logger: OpenAILogger; + constructor( private config: Config, private enableOpenAILogging: boolean = false, - ) {} + openAILoggingDir?: string, + ) { + // Always create a new logger instance to ensure correct working directory + // If no custom directory is provided, undefined will use the default path + this.logger = new OpenAILogger(openAILoggingDir); + } async logSuccess( context: RequestContext, @@ -68,7 +75,7 @@ export class DefaultTelemetryService implements TelemetryService { // Log interaction if enabled if (this.enableOpenAILogging && openaiRequest && openaiResponse) { - await openaiLogger.logInteraction(openaiRequest, openaiResponse); + await this.logger.logInteraction(openaiRequest, openaiResponse); } } @@ -97,7 +104,7 @@ export class DefaultTelemetryService implements TelemetryService { // Log error interaction if enabled if (this.enableOpenAILogging && openaiRequest) { - await openaiLogger.logInteraction( + await this.logger.logInteraction( openaiRequest, undefined, error as Error, @@ -137,7 +144,7 @@ export class DefaultTelemetryService implements TelemetryService { openaiChunks.length > 0 ) { const combinedResponse = this.combineOpenAIChunksForLogging(openaiChunks); - await openaiLogger.logInteraction(openaiRequest, combinedResponse); + await this.logger.logInteraction(openaiRequest, combinedResponse); } } diff --git a/packages/core/src/utils/openaiLogger.test.ts b/packages/core/src/utils/openaiLogger.test.ts new file mode 100644 index 00000000..17a07486 --- /dev/null +++ b/packages/core/src/utils/openaiLogger.test.ts @@ -0,0 +1,381 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as path from 'node:path'; +import * as os from 'os'; +import { promises as fs } from 'node:fs'; +import { OpenAILogger } from './openaiLogger.js'; + +describe('OpenAILogger', () => { + let originalCwd: string; + let testTempDir: string; + const createdDirs: string[] = []; + + beforeEach(() => { + originalCwd = process.cwd(); + testTempDir = path.join(os.tmpdir(), `openai-logger-test-${Date.now()}`); + createdDirs.length = 0; // Clear array + }); + + afterEach(async () => { + // Clean up all created directories + const cleanupPromises = [ + testTempDir, + ...createdDirs, + path.resolve(process.cwd(), 'relative-logs'), + path.resolve(process.cwd(), 'custom-logs'), + path.resolve(process.cwd(), 'test-relative-logs'), + path.join(os.homedir(), 'custom-logs'), + path.join(os.homedir(), 'test-openai-logs'), + ].map(async (dir) => { + try { + await fs.rm(dir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + await Promise.all(cleanupPromises); + process.chdir(originalCwd); + }); + + describe('constructor', () => { + it('should use default directory when no custom directory is provided', () => { + const logger = new OpenAILogger(); + // We can't directly access private logDir, but we can verify behavior + expect(logger).toBeInstanceOf(OpenAILogger); + }); + + it('should accept absolute path as custom directory', () => { + const customDir = '/absolute/path/to/logs'; + const logger = new OpenAILogger(customDir); + expect(logger).toBeInstanceOf(OpenAILogger); + }); + + it('should resolve relative path to absolute path', async () => { + const relativeDir = 'custom-logs'; + const logger = new OpenAILogger(relativeDir); + const expectedDir = path.resolve(process.cwd(), relativeDir); + createdDirs.push(expectedDir); + expect(logger).toBeInstanceOf(OpenAILogger); + }); + + it('should expand ~ to home directory', () => { + const customDir = '~/custom-logs'; + const logger = new OpenAILogger(customDir); + expect(logger).toBeInstanceOf(OpenAILogger); + }); + + it('should expand ~/ to home directory', () => { + const customDir = '~/custom-logs'; + const logger = new OpenAILogger(customDir); + expect(logger).toBeInstanceOf(OpenAILogger); + }); + + it('should handle just ~ as home directory', () => { + const customDir = '~'; + const logger = new OpenAILogger(customDir); + expect(logger).toBeInstanceOf(OpenAILogger); + }); + }); + + describe('initialize', () => { + it('should create directory if it does not exist', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + + const dirExists = await fs + .access(testTempDir) + .then(() => true) + .catch(() => false); + expect(dirExists).toBe(true); + }); + + it('should create nested directories recursively', async () => { + const nestedDir = path.join(testTempDir, 'nested', 'deep', 'path'); + const logger = new OpenAILogger(nestedDir); + await logger.initialize(); + + const dirExists = await fs + .access(nestedDir) + .then(() => true) + .catch(() => false); + expect(dirExists).toBe(true); + }); + + it('should not throw if directory already exists', async () => { + await fs.mkdir(testTempDir, { recursive: true }); + const logger = new OpenAILogger(testTempDir); + await expect(logger.initialize()).resolves.not.toThrow(); + }); + }); + + describe('logInteraction', () => { + it('should create log file with correct format', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + + const request = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'test' }], + }; + const response = { id: 'test-id', choices: [] }; + + const logPath = await logger.logInteraction(request, response); + + expect(logPath).toContain(testTempDir); + expect(logPath).toMatch( + /openai-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z-[a-f0-9]{8}\.json/, + ); + + const fileExists = await fs + .access(logPath) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(true); + }); + + it('should write correct log data structure', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + + const request = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'test' }], + }; + const response = { id: 'test-id', choices: [] }; + + const logPath = await logger.logInteraction(request, response); + const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8')); + + expect(logContent).toHaveProperty('timestamp'); + expect(logContent).toHaveProperty('request', request); + expect(logContent).toHaveProperty('response', response); + expect(logContent).toHaveProperty('error', null); + expect(logContent).toHaveProperty('system'); + expect(logContent.system).toHaveProperty('hostname'); + expect(logContent.system).toHaveProperty('platform'); + expect(logContent.system).toHaveProperty('release'); + expect(logContent.system).toHaveProperty('nodeVersion'); + }); + + it('should log error when provided', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + + const request = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'test' }], + }; + const error = new Error('Test error'); + + const logPath = await logger.logInteraction(request, undefined, error); + const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8')); + + expect(logContent).toHaveProperty('error'); + expect(logContent.error).toHaveProperty('message', 'Test error'); + expect(logContent.error).toHaveProperty('stack'); + expect(logContent.response).toBeNull(); + }); + + it('should use custom directory when provided', async () => { + const customDir = path.join(testTempDir, 'custom-logs'); + const logger = new OpenAILogger(customDir); + await logger.initialize(); + + const request = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'test' }], + }; + const response = { id: 'test-id', choices: [] }; + + const logPath = await logger.logInteraction(request, response); + + expect(logPath).toContain(customDir); + expect(logPath.startsWith(customDir)).toBe(true); + }); + + it('should resolve relative path correctly', async () => { + const relativeDir = 'relative-logs'; + const logger = new OpenAILogger(relativeDir); + await logger.initialize(); + + const request = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'test' }], + }; + const response = { id: 'test-id', choices: [] }; + + const logPath = await logger.logInteraction(request, response); + const expectedDir = path.resolve(process.cwd(), relativeDir); + createdDirs.push(expectedDir); + + expect(logPath).toContain(expectedDir); + }); + + it('should expand ~ correctly', async () => { + const customDir = '~/test-openai-logs'; + const logger = new OpenAILogger(customDir); + await logger.initialize(); + + const request = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'test' }], + }; + const response = { id: 'test-id', choices: [] }; + + const logPath = await logger.logInteraction(request, response); + const expectedDir = path.join(os.homedir(), 'test-openai-logs'); + createdDirs.push(expectedDir); + + expect(logPath).toContain(expectedDir); + }); + }); + + describe('getLogFiles', () => { + it('should return empty array when directory does not exist', async () => { + const logger = new OpenAILogger(testTempDir); + const files = await logger.getLogFiles(); + expect(files).toEqual([]); + }); + + it('should return log files after initialization', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + + const request = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'test' }], + }; + const response = { id: 'test-id', choices: [] }; + + await logger.logInteraction(request, response); + const files = await logger.getLogFiles(); + + expect(files.length).toBeGreaterThan(0); + expect(files[0]).toMatch(/openai-.*\.json$/); + }); + + it('should return only log files matching pattern', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + + // Create a log file + await logger.logInteraction({ test: 'request' }, { test: 'response' }); + + // Create a non-log file + await fs.writeFile(path.join(testTempDir, 'other-file.txt'), 'content'); + + const files = await logger.getLogFiles(); + expect(files.length).toBe(1); + expect(files[0]).toMatch(/openai-.*\.json$/); + }); + + it('should respect limit parameter', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + + // Create multiple log files + for (let i = 0; i < 5; i++) { + await logger.logInteraction( + { test: `request-${i}` }, + { test: `response-${i}` }, + ); + // Small delay to ensure different timestamps + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + const allFiles = await logger.getLogFiles(); + expect(allFiles.length).toBe(5); + + const limitedFiles = await logger.getLogFiles(3); + expect(limitedFiles.length).toBe(3); + }); + + it('should return files sorted by most recent first', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + + const files: string[] = []; + for (let i = 0; i < 3; i++) { + const logPath = await logger.logInteraction( + { test: `request-${i}` }, + { test: `response-${i}` }, + ); + files.push(logPath); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + const retrievedFiles = await logger.getLogFiles(); + expect(retrievedFiles[0]).toBe(files[2]); // Most recent first + expect(retrievedFiles[1]).toBe(files[1]); + expect(retrievedFiles[2]).toBe(files[0]); + }); + }); + + describe('readLogFile', () => { + it('should read and parse log file correctly', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + + const request = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'test' }], + }; + const response = { id: 'test-id', choices: [] }; + + const logPath = await logger.logInteraction(request, response); + const logData = await logger.readLogFile(logPath); + + expect(logData).toHaveProperty('timestamp'); + expect(logData).toHaveProperty('request', request); + expect(logData).toHaveProperty('response', response); + }); + + it('should throw error when file does not exist', async () => { + const logger = new OpenAILogger(testTempDir); + const nonExistentPath = path.join(testTempDir, 'non-existent.json'); + + await expect(logger.readLogFile(nonExistentPath)).rejects.toThrow(); + }); + }); + + describe('path resolution', () => { + it('should normalize absolute paths', () => { + const absolutePath = '/tmp/test/logs'; + const logger = new OpenAILogger(absolutePath); + expect(logger).toBeInstanceOf(OpenAILogger); + }); + + it('should resolve relative paths based on current working directory', async () => { + const relativePath = 'test-relative-logs'; + const logger = new OpenAILogger(relativePath); + await logger.initialize(); + + const request = { test: 'request' }; + const response = { test: 'response' }; + + const logPath = await logger.logInteraction(request, response); + const expectedBaseDir = path.resolve(process.cwd(), relativePath); + createdDirs.push(expectedBaseDir); + + expect(logPath).toContain(expectedBaseDir); + }); + + it('should handle paths with special characters', async () => { + const specialPath = path.join(testTempDir, 'logs-with-special-chars'); + const logger = new OpenAILogger(specialPath); + await logger.initialize(); + + const request = { test: 'request' }; + const response = { test: 'response' }; + + const logPath = await logger.logInteraction(request, response); + expect(logPath).toContain(specialPath); + }); + }); +}); diff --git a/packages/core/src/utils/openaiLogger.ts b/packages/core/src/utils/openaiLogger.ts index 8e53e86e..a4fc41ec 100644 --- a/packages/core/src/utils/openaiLogger.ts +++ b/packages/core/src/utils/openaiLogger.ts @@ -18,10 +18,23 @@ export class OpenAILogger { /** * Creates a new OpenAI logger - * @param customLogDir Optional custom log directory path + * @param customLogDir Optional custom log directory path (supports relative paths, absolute paths, and ~ expansion) */ constructor(customLogDir?: string) { - this.logDir = customLogDir || path.join(process.cwd(), 'logs', 'openai'); + if (customLogDir) { + // Resolve relative paths to absolute paths + // Handle ~ expansion + let resolvedPath = customLogDir; + if (customLogDir === '~' || customLogDir.startsWith('~/')) { + resolvedPath = path.join(os.homedir(), customLogDir.slice(1)); + } else if (!path.isAbsolute(customLogDir)) { + // If it's a relative path, resolve it relative to current working directory + resolvedPath = path.resolve(process.cwd(), customLogDir); + } + this.logDir = path.normalize(resolvedPath); + } else { + this.logDir = path.join(process.cwd(), 'logs', 'openai'); + } } /** From 3a69931791c28f28bb7961c43510a5416249f1de Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 5 Nov 2025 18:58:53 +0800 Subject: [PATCH 24/25] feat: add docs for logging dir configuration --- docs/cli/configuration-v1.md | 3 +++ docs/cli/configuration.md | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/cli/configuration-v1.md b/docs/cli/configuration-v1.md index 0926d49e..73ea325c 100644 --- a/docs/cli/configuration-v1.md +++ b/docs/cli/configuration-v1.md @@ -541,6 +541,9 @@ Arguments passed directly when running the CLI can override other configurations - Displays the version of the CLI. - **`--openai-logging`**: - Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`. +- **`--openai-logging-dir `**: + - Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. + - **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` - **`--tavily-api-key `**: - Sets the Tavily API key for web search functionality for this session. - Example: `qwen --tavily-api-key tvly-your-api-key-here` diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index b152a701..bc7fce20 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -171,6 +171,18 @@ Settings are organized into categories. All settings should be placed within the - **Description:** Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. - **Default:** `false` +- **`model.enableOpenAILogging`** (boolean): + - **Description:** Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. + - **Default:** `false` + +- **`model.openAILoggingDir`** (string): + - **Description:** Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory). + - **Default:** `undefined` + - **Examples:** + - `"~/qwen-logs"` - Logs to `~/qwen-logs` directory + - `"./custom-logs"` - Logs to `./custom-logs` relative to current directory + - `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs` + #### `context` - **`context.fileName`** (string or array of strings): @@ -387,6 +399,8 @@ Here is an example of a `settings.json` file with the nested structure, new as o "model": { "name": "qwen3-coder-plus", "maxSessionTurns": 10, + "enableOpenAILogging": false, + "openAILoggingDir": "~/qwen-logs", "summarizeToolOutput": { "run_shell_command": { "tokenBudget": 100 @@ -557,6 +571,9 @@ Arguments passed directly when running the CLI can override other configurations - Displays the version of the CLI. - **`--openai-logging`**: - Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`. +- **`--openai-logging-dir `**: + - Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. + - **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` - **`--tavily-api-key `**: - Sets the Tavily API key for web search functionality for this session. - Example: `qwen --tavily-api-key tvly-your-api-key-here` From 3bd0cb36c4e70a0f1ab6ad06cb2739b13a6fed5c Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 5 Nov 2025 19:35:17 +0800 Subject: [PATCH 25/25] chore: pump version to 0.1.5 --- package-lock.json | 55 +++++++++++++--------- package.json | 4 +- packages/cli/package.json | 4 +- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 6 files changed, 39 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5949a5e7..6f68eccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.1.4", + "version": "0.1.5", "workspaces": [ "packages/*" ], @@ -555,6 +555,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -578,6 +579,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2118,6 +2120,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3279,6 +3282,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3717,6 +3721,7 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3727,6 +3732,7 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3932,6 +3938,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -4700,6 +4707,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5054,8 +5062,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -6220,7 +6227,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -7254,6 +7260,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -7723,7 +7730,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7785,7 +7791,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -7795,7 +7800,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -7805,7 +7809,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -7972,7 +7975,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -7991,7 +7993,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8000,15 +8001,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9047,6 +9046,7 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -10950,7 +10950,6 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -12158,8 +12157,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -12663,6 +12661,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12673,6 +12672,7 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -12706,6 +12706,7 @@ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -14515,6 +14516,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14688,7 +14690,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -14696,6 +14699,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -14880,6 +14884,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15149,7 +15154,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -15205,6 +15209,7 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -15318,6 +15323,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15331,6 +15337,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -16009,6 +16016,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -16024,7 +16032,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@google/genai": "1.16.0", "@iarna/toml": "^2.2.5", @@ -16139,7 +16147,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.1.4", + "version": "0.1.5", "hasInstallScript": true, "dependencies": { "@google/genai": "1.16.0", @@ -16269,6 +16277,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16278,7 +16287,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.1.4", + "version": "0.1.5", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -16290,7 +16299,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.1.4", + "version": "0.1.5", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index dd45471e..0208d435 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.1.4", + "version": "0.1.5", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.4" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.5" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index e30e8e86..3456a365 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.1.4", + "version": "0.1.5", "description": "Qwen Code", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.4" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.5" }, "dependencies": { "@google/genai": "1.16.0", diff --git a/packages/core/package.json b/packages/core/package.json index 7f800797..3b864724 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.1.4", + "version": "0.1.5", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 6ff662f7..089b883c 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.1.4", + "version": "0.1.5", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 982a6118..f76d4113 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.1.4", + "version": "0.1.5", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": {